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 new file mode 100644 index 00000000..8ca79726 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java @@ -0,0 +1,31 @@ +package site.icebang.domain.workflow.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +import site.icebang.common.dto.ApiResponse; +import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.service.WorkflowHistoryService; + +@RestController +@RequestMapping("/v0/workflow-runs") +@RequiredArgsConstructor +public class WorkflowHistoryController { + private final WorkflowHistoryService workflowHistoryService; + + /** + * 워크플로우 실행 상세 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunDetailResponse + */ + @GetMapping("/{runId}") + public ApiResponse getWorkflowRunDetail(@PathVariable Long runId) { + WorkflowRunDetailResponse response = workflowHistoryService.getWorkflowRunDetail(runId); + return ApiResponse.success(response); + } +} 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 new file mode 100644 index 00000000..5dbb5711 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java @@ -0,0 +1,22 @@ +package site.icebang.domain.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionLogDto { + private Long id; // execution_log.id + private String executionType; // workflow, job, task + private Long sourceId; // 모든 데이터에 대한 ID + private Long runId; // 실행 ID (workflow_run, job_run, task_run) + private String logLevel; // info, success, warning, error + private String status; // running, success, failed, etc + private String logMessage; + private String executedAt; + private Integer durationMs; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java new file mode 100644 index 00000000..618a6214 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java @@ -0,0 +1,26 @@ +package site.icebang.domain.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobRunDto { + private Long id; // job_run.id (Job 실행 ID) + private Long workflowRunId; // workflow_run.id (관계) + private Long jobId; // job.id (Job 설계 ID) + private String jobName; + private String jobDescription; + private String status; + private Integer executionOrder; + private String startedAt; + private String finishedAt; + private Integer durationMs; + private List taskRuns; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java new file mode 100644 index 00000000..9005c45a --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java @@ -0,0 +1,24 @@ +package site.icebang.domain.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TaskRunDto { + private Long id; // task_run.id (Task 실행 ID) + private Long jobRunId; // job_run.id (관계) + private Long taskId; // task.id (Task 설계 ID) + private String taskName; + private String taskDescription; + private String taskType; + private String status; + private Integer executionOrder; + private String startedAt; + private String finishedAt; + private Integer durationMs; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java new file mode 100644 index 00000000..194e8583 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java @@ -0,0 +1,18 @@ +package site.icebang.domain.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowRunDetailResponse { + private String traceId; + private WorkflowRunDto workflowRun; + private List jobRuns; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java new file mode 100644 index 00000000..20b8ecd2 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java @@ -0,0 +1,25 @@ +package site.icebang.domain.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowRunDto { + private Long id; // workflow_run.id (실행 ID) + private Long workflowId; // workflow.id (설계 ID) + private String workflowName; + private String workflowDescription; + private String runNumber; + private String status; + private String triggerType; + private String startedAt; + private String finishedAt; + private Integer durationMs; + private Long createdBy; + private String createdAt; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java new file mode 100644 index 00000000..ff5304f5 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java @@ -0,0 +1,17 @@ +package site.icebang.domain.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowRunLogsResponse { + private String traceId; + private List logs; +} 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 new file mode 100644 index 00000000..6833a6d7 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java @@ -0,0 +1,44 @@ +package site.icebang.domain.workflow.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.workflow.dto.JobRunDto; +import site.icebang.domain.workflow.dto.TaskRunDto; +import site.icebang.domain.workflow.dto.WorkflowRunDto; + +@Mapper +public interface WorkflowHistoryMapper { + /** + * 워크플로우 실행 정보 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunDto + */ + WorkflowRunDto selectWorkflowRun(Long runId); + + /** + * 워크플로우 실행의 Job 목록 조회 + * + * @param workflowRunId workflow_run.id + * @return List + */ + List selectJobRunsByWorkflowRunId(Long workflowRunId); + + /** + * Job 실행의 Task 목록 조회 + * + * @param jobRunId job_run.id + * @return List + */ + List selectTaskRunsByJobRunId(Long jobRunId); + + /** + * 워크플로우 실행 TraceId 조회 + * + * @param runId workflow_run.id + * @return String traceId + */ + String selectTraceIdByRunId(Long runId); +} 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 new file mode 100644 index 00000000..583e2b20 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java @@ -0,0 +1,73 @@ +package site.icebang.domain.workflow.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.workflow.dto.*; +import site.icebang.domain.workflow.mapper.WorkflowHistoryMapper; + +@Service +@RequiredArgsConstructor +public class WorkflowHistoryService { + private final WorkflowHistoryMapper workflowHistoryMapper; + + /** + * 워크플로우 실행 상세 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunDetailResponse + */ + @Transactional(readOnly = true) + public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { + // 1. 워크플로우 실행 정보 조회 + WorkflowRunDto workflowRunDto = workflowHistoryMapper.selectWorkflowRun(runId); + + // 2. Job 실행 목록 조회 + List jobRunDtos = workflowHistoryMapper.selectJobRunsByWorkflowRunId(runId); + + // 3. 각 Job의 Task 실행 목록 조회 + if (jobRunDtos != null) { + jobRunDtos.forEach( + jobRun -> { + List taskRuns = + workflowHistoryMapper.selectTaskRunsByJobRunId(jobRun.getId()); + jobRun.setTaskRuns(taskRuns); + }); + } + + // 4. TraceId 조회 + String traceId = workflowHistoryMapper.selectTraceIdByRunId(runId); + + return WorkflowRunDetailResponse.builder() + .workflowRun(workflowRunDto) + .jobRuns(jobRunDtos) + .traceId(traceId) + .build(); + } + + /** + * 워크플로우 실행 로그 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunLogsResponse + */ + public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { + // TODO: 구현 예정 + return null; + } + + /** + * TraceId로 워크플로우 실행 조회 + * + * @param traceId workflow_run.trace_id + * @return WorkflowRunDetailResponse + */ + public WorkflowRunDetailResponse getWorkflowRunByTraceId(String traceId) { + // TODO: 구현 예정 + return null; + } +} diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml new file mode 100644 index 00000000..3ad7aa65 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql b/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql new file mode 100644 index 00000000..814c3b5b --- /dev/null +++ b/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql @@ -0,0 +1,76 @@ +-- =================================================================== +-- 워크플로우 히스토리 테스트용 데이터 삽입 +-- =================================================================== + +-- 기존 실행 데이터 삭제 (참조 순서 고려) +DELETE FROM `task_run` WHERE id = 1; +DELETE FROM `job_run` WHERE id = 1; +DELETE FROM `workflow_run` WHERE id = 1; + +-- AUTO_INCREMENT 초기화 +ALTER TABLE `task_run` AUTO_INCREMENT = 1; +ALTER TABLE `job_run` AUTO_INCREMENT = 1; +ALTER TABLE `workflow_run` AUTO_INCREMENT = 1; + +-- 워크플로우 실행 데이터 삽입 (workflow_run) +INSERT INTO `workflow_run` ( + `workflow_id`, + `trace_id`, + `run_number`, + `status`, + `trigger_type`, + `started_at`, + `finished_at`, + `created_by` +) VALUES ( + 1, + '3e3c832d-b51f-48ea-95f9-98f0ae6d3413', + NULL, + 'FAILED', + NULL, + '2025-09-22 18:18:43', + '2025-09-22 18:18:44', + NULL + ); + +-- Job 실행 데이터 삽입 (job_run) +INSERT INTO `job_run` ( + `id`, + `workflow_run_id`, + `job_id`, + `status`, + `execution_order`, + `started_at`, + `finished_at`, + `created_at` +) VALUES ( + 1, + 1, + 1, + 'FAILED', + NULL, + '2025-09-22 18:18:44', + '2025-09-22 18:18:44', + NOW() + ); + +-- Task 실행 데이터 삽입 (task_run) +INSERT INTO `task_run` ( + `id`, + `job_run_id`, + `task_id`, + `status`, + `execution_order`, + `started_at`, + `finished_at`, + `created_at` +) VALUES ( + 1, + 1, + 1, + 'FAILED', + NULL, + '2025-09-22 18:18:44', + '2025-09-22 18:18:44', + NOW() + ); \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java new file mode 100644 index 00000000..4703e9f6 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java @@ -0,0 +1,228 @@ +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.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/01-insert-internal-users.sql", + "classpath:sql/03-insert-workflow.sql", + "classpath:sql/04-insert-workflow-history.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +public class WorkflowHistoryApiIntegrationTest extends IntegrationTestSupport { + @Test + @DisplayName("워크플로우 실행 상세 조회 성공") + @WithUserDetails("admin@icebang.site") + void getWorkflowRunDetail_success() throws Exception { + // given + Long runId = 1L; + + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/workflow-runs/{runId}"), runId) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + // traceId 확인 + .andExpect(jsonPath("$.data.traceId").value("3e3c832d-b51f-48ea-95f9-98f0ae6d3413")) + // workflowRun 필드 확인 + .andExpect(jsonPath("$.data.workflowRun.id").value(1)) + .andExpect(jsonPath("$.data.workflowRun.workflowId").value(1)) + .andExpect(jsonPath("$.data.workflowRun.workflowName").value("상품 분석 및 블로그 자동 발행")) + .andExpect( + jsonPath("$.data.workflowRun.workflowDescription") + .value("키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스")) + .andExpect(jsonPath("$.data.workflowRun.runNumber").isEmpty()) + .andExpect(jsonPath("$.data.workflowRun.status").value("FAILED")) + .andExpect(jsonPath("$.data.workflowRun.triggerType").isEmpty()) + .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22 18:18:43")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.workflowRun.durationMs").value(1000)) + .andExpect(jsonPath("$.data.workflowRun.createdBy").isEmpty()) + .andExpect(jsonPath("$.data.workflowRun.createdAt").exists()) + // jobRuns 배열 확인 + .andExpect(jsonPath("$.data.jobRuns").isArray()) + .andExpect(jsonPath("$.data.jobRuns.length()").value(1)) + // jobRuns[0] 필드 확인 + .andExpect(jsonPath("$.data.jobRuns[0].id").value(1)) + .andExpect(jsonPath("$.data.jobRuns[0].workflowRunId").value(1)) + .andExpect(jsonPath("$.data.jobRuns[0].jobId").value(1)) + .andExpect(jsonPath("$.data.jobRuns[0].jobName").value("상품 분석")) + .andExpect(jsonPath("$.data.jobRuns[0].jobDescription").value("키워드 검색, 상품 크롤링 및 유사도 분석 작업")) + .andExpect(jsonPath("$.data.jobRuns[0].status").value("FAILED")) + .andExpect(jsonPath("$.data.jobRuns[0].executionOrder").isEmpty()) + .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].durationMs").value(0)) + // taskRuns 배열 확인 + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns").isArray()) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns.length()").value(1)) + // taskRuns[0] 필드 확인 + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].id").value(1)) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].jobRunId").value(1)) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskId").value(1)) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskName").value("키워드 검색 태스크")) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskDescription").isEmpty()) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskType").value("FastAPI")) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].status").value("FAILED")) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].executionOrder").isEmpty()) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt").value("2025-09-22 18:18:44")) + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].durationMs").value(0)) + .andDo( + document( + "workflow-run-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("워크플로우 실행 상세 조회") + .description("워크플로우 실행 ID로 상세 정보를 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.traceId") + .type(JsonFieldType.STRING) + .description("워크플로우 실행 추적 ID"), + fieldWithPath("data.workflowRun") + .type(JsonFieldType.OBJECT) + .description("워크플로우 실행 정보"), + fieldWithPath("data.workflowRun.id") + .type(JsonFieldType.NUMBER) + .description("워크플로우 실행 ID"), + fieldWithPath("data.workflowRun.workflowId") + .type(JsonFieldType.NUMBER) + .description("워크플로우 설계 ID"), + fieldWithPath("data.workflowRun.workflowName") + .type(JsonFieldType.STRING) + .description("워크플로우 이름"), + fieldWithPath("data.workflowRun.workflowDescription") + .type(JsonFieldType.STRING) + .description("워크플로우 설명"), + fieldWithPath("data.workflowRun.runNumber") + .type(JsonFieldType.NULL) + .description("실행 번호"), + fieldWithPath("data.workflowRun.status") + .type(JsonFieldType.STRING) + .description("실행 상태"), + fieldWithPath("data.workflowRun.triggerType") + .type(JsonFieldType.NULL) + .description("트리거 유형"), + fieldWithPath("data.workflowRun.startedAt") + .type(JsonFieldType.STRING) + .description("시작 시간"), + fieldWithPath("data.workflowRun.finishedAt") + .type(JsonFieldType.STRING) + .description("완료 시간"), + fieldWithPath("data.workflowRun.durationMs") + .type(JsonFieldType.NUMBER) + .description("실행 시간(ms)"), + fieldWithPath("data.workflowRun.createdBy") + .type(JsonFieldType.NULL) + .description("생성자 ID"), + fieldWithPath("data.workflowRun.createdAt") + .type(JsonFieldType.STRING) + .description("생성 시간"), + fieldWithPath("data.jobRuns") + .type(JsonFieldType.ARRAY) + .description("Job 실행 목록"), + fieldWithPath("data.jobRuns[].id") + .type(JsonFieldType.NUMBER) + .description("Job 실행 ID"), + fieldWithPath("data.jobRuns[].workflowRunId") + .type(JsonFieldType.NUMBER) + .description("워크플로우 실행 ID"), + fieldWithPath("data.jobRuns[].jobId") + .type(JsonFieldType.NUMBER) + .description("Job 설계 ID"), + fieldWithPath("data.jobRuns[].jobName") + .type(JsonFieldType.STRING) + .description("Job 이름"), + fieldWithPath("data.jobRuns[].jobDescription") + .type(JsonFieldType.STRING) + .description("Job 설명"), + fieldWithPath("data.jobRuns[].status") + .type(JsonFieldType.STRING) + .description("Job 실행 상태"), + fieldWithPath("data.jobRuns[].executionOrder") + .type(JsonFieldType.NULL) + .description("실행 순서"), + fieldWithPath("data.jobRuns[].startedAt") + .type(JsonFieldType.STRING) + .description("Job 시작 시간"), + fieldWithPath("data.jobRuns[].finishedAt") + .type(JsonFieldType.STRING) + .description("Job 완료 시간"), + fieldWithPath("data.jobRuns[].durationMs") + .type(JsonFieldType.NUMBER) + .description("Job 실행 시간(ms)"), + fieldWithPath("data.jobRuns[].taskRuns") + .type(JsonFieldType.ARRAY) + .description("Task 실행 목록"), + fieldWithPath("data.jobRuns[].taskRuns[].id") + .type(JsonFieldType.NUMBER) + .description("Task 실행 ID"), + fieldWithPath("data.jobRuns[].taskRuns[].jobRunId") + .type(JsonFieldType.NUMBER) + .description("Job 실행 ID"), + fieldWithPath("data.jobRuns[].taskRuns[].taskId") + .type(JsonFieldType.NUMBER) + .description("Task 설계 ID"), + fieldWithPath("data.jobRuns[].taskRuns[].taskName") + .type(JsonFieldType.STRING) + .description("Task 이름"), + fieldWithPath("data.jobRuns[].taskRuns[].taskDescription") + .type(JsonFieldType.NULL) + .description("Task 설명"), + fieldWithPath("data.jobRuns[].taskRuns[].taskType") + .type(JsonFieldType.STRING) + .description("Task 유형"), + fieldWithPath("data.jobRuns[].taskRuns[].status") + .type(JsonFieldType.STRING) + .description("Task 실행 상태"), + fieldWithPath("data.jobRuns[].taskRuns[].executionOrder") + .type(JsonFieldType.NULL) + .description("Task 실행 순서"), + fieldWithPath("data.jobRuns[].taskRuns[].startedAt") + .type(JsonFieldType.STRING) + .description("Task 시작 시간"), + fieldWithPath("data.jobRuns[].taskRuns[].finishedAt") + .type(JsonFieldType.STRING) + .description("Task 완료 시간"), + fieldWithPath("data.jobRuns[].taskRuns[].durationMs") + .type(JsonFieldType.NUMBER) + .description("Task 실행 시간(ms)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } +}