diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java index fd42ea13..c98ece1f 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java @@ -2,15 +2,20 @@ import java.math.BigInteger; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import site.icebang.common.dto.ApiResponse; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.domain.auth.model.AuthCredential; import site.icebang.domain.workflow.dto.WorkflowCardDto; +import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.domain.workflow.service.WorkflowService; @@ -29,6 +34,23 @@ public ApiResponse> getWorkflowList( return ApiResponse.success(result); } + @PostMapping("") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createWorkflow( + @Valid @RequestBody WorkflowCreateDto workflowCreateDto, + @AuthenticationPrincipal AuthCredential authCredential) { + // 인증 체크 + if (authCredential == null) { + throw new IllegalArgumentException("로그인이 필요합니다"); + } + + // AuthCredential에서 userId 추출 + BigInteger userId = authCredential.getId(); + + workflowService.createWorkflow(workflowCreateDto, userId); + return ApiResponse.success(null); + } + @PostMapping("/{workflowId}/run") public ResponseEntity runWorkflow(@PathVariable Long workflowId) { // HTTP 요청/응답 스레드를 블로킹하지 않도록 비동기 실행 diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java new file mode 100644 index 00000000..bcd0cc56 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -0,0 +1,112 @@ +package site.icebang.domain.workflow.dto; + +import java.math.BigInteger; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 워크플로우 생성 요청 DTO + * + *

프론트엔드에서 워크플로우 생성 시 필요한 모든 정보를 담는 DTO - 기본 정보: 이름, 설명 - 플랫폼 설정: 검색 플랫폼, 포스팅 플랫폼 - 계정 설정: 포스팅 계정 + * 정보 (JSON 형태로 저장) + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class WorkflowCreateDto { + @Null private BigInteger id; + + @NotBlank(message = "워크플로우 이름은 필수입니다") + @Size(max = 100, message = "워크플로우 이름은 100자를 초과할 수 없습니다") + @Pattern( + regexp = "^[가-힣a-zA-Z0-9\\s_-]+$", + message = "워크플로우 이름은 한글, 영문, 숫자, 공백, 언더스코어, 하이픈만 사용 가능합니다") + private String name; + + @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다") + private String description; + + @Pattern(regexp = "^(naver|naver_store)?$", message = "검색 플랫폼은 'naver' 또는 'naver_store'만 가능합니다") + @JsonProperty("search_platform") + private String searchPlatform; + + @Pattern( + regexp = "^(naver_blog|tstory_blog|blogger)?$", + message = "포스팅 플랫폼은 'naver_blog', 'tstory_blog', 'blogger' 중 하나여야 합니다") + @JsonProperty("posting_platform") + private String postingPlatform; + + @Size(max = 100, message = "포스팅 계정 ID는 100자를 초과할 수 없습니다") + @JsonProperty("posting_account_id") + private String postingAccountId; + + @Size(max = 200, message = "포스팅 계정 비밀번호는 200자를 초과할 수 없습니다") + @JsonProperty("posting_account_password") + private String postingAccountPassword; + + @Size(max = 100, message = "블로그 이름은 100자를 초과할 수 없습니다") + @JsonProperty("blog_name") + private String blogName; + + @Builder.Default + @JsonProperty("is_enabled") + private Boolean isEnabled = true; + + // JSON 변환용 필드 (MyBatis에서 사용) + private String defaultConfigJson; + + public String genertateDefaultConfigJson() { + StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.append("{"); + + // 크롤링 플랫폼 설정 (키: "1") + if (searchPlatform != null && !searchPlatform.isBlank()) { + jsonBuilder.append("\"1\": {\"tag\": \"").append(searchPlatform).append("\"}"); + } + + // 포스팅 설정 (키: "8") + if (hasPostingConfig()) { + if (jsonBuilder.length() > 1) { + jsonBuilder.append(", "); + } + jsonBuilder + .append("\"8\": {") + .append("\"tag\": \"") + .append(postingPlatform) + .append("\", ") + .append("\"blog_id\": \"") + .append(postingAccountId) + .append("\", ") + .append("\"blog_pw\": \"") + .append(postingAccountPassword) + .append("\""); + + // tstory_blog인 경우 blog_name 추가 + if ("tstory_blog".equals(postingPlatform) && blogName != null && !blogName.isBlank()) { + jsonBuilder.append(", \"blog_name\": \"").append(blogName).append("\""); + } + + jsonBuilder.append("}"); + } + + jsonBuilder.append("}"); + return jsonBuilder.toString(); + } + + // 포스팅 설정 완성도 체크 (상태 확인 유틸) + public boolean hasPostingConfig() { + return postingPlatform != null + && !postingPlatform.isBlank() + && postingAccountId != null + && !postingAccountId.isBlank() + && postingAccountPassword != null + && !postingAccountPassword.isBlank(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java index 82381737..417dfd1d 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java @@ -13,6 +13,17 @@ public interface WorkflowMapper { int selectWorkflowCount(PageParams pageParams); + int insertWorkflow(Map params); // insert workflow + + // Job 생성 관련 메서드 + void insertJobs(Map params); // 여러 Job을 동적으로 생성 + + void insertWorkflowJobs(Map params); // Workflow-Job 연결 + + void insertJobTasks(Map params); // Job-Task 연결 + + boolean existsByName(String name); + WorkflowCardDto selectWorkflowById(BigInteger id); WorkflowDetailCardDto selectWorkflowDetailById(BigInteger workflowId); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index e8c857f3..06a9ee5c 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -1,6 +1,7 @@ package site.icebang.domain.workflow.service; import java.math.BigInteger; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -8,12 +9,14 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.common.service.PageableService; import site.icebang.domain.workflow.dto.ScheduleDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; +import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.mapper.WorkflowMapper; @@ -32,6 +35,7 @@ * @author jihu0210@naver.com * @since v0.1.0 */ +@Slf4j @Service @RequiredArgsConstructor public class WorkflowService implements PageableService { @@ -86,4 +90,72 @@ public WorkflowDetailCardDto getWorkflowDetail(BigInteger workflowId) { return workflow; } + + /** 워크플로우 생성 */ + @Transactional + public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { + // 1. 기본 검증 + validateBasicInput(dto, createdBy); + + // 2. 비즈니스 검증 + validateBusinessRules(dto); + + // 3. 중복체크 + if (workflowMapper.existsByName(dto.getName())) { + throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다 : " + dto.getName()); + } + + // 4. 워크플로우 생성 + try { + // JSON 설정 생성 + String defaultConfigJson = dto.genertateDefaultConfigJson(); + dto.setDefaultConfigJson(defaultConfigJson); + + // DB 삽입 파라미터 구성 + Map params = new HashMap<>(); + params.put("dto", dto); + params.put("createdBy", createdBy); + + int result = workflowMapper.insertWorkflow(params); + if (result != 1) { + throw new RuntimeException("워크플로우 생성에 실패했습니다"); + } + + log.info("워크플로우 생성 완료: {} (생성자: {})", dto.getName(), createdBy); + + } catch (Exception e) { + log.error("워크플로우 생성 실패: {}", dto.getName(), e); + throw new RuntimeException("워크플로우 생성 중 오류가 발생했습니다", e); + } + } + + /** 기본 입력값 검증 */ + private void validateBasicInput(WorkflowCreateDto dto, BigInteger createdBy) { + if (dto == null) { + throw new IllegalArgumentException("워크플로우 정보가 필요합니다"); + } + if (createdBy == null) { + throw new IllegalArgumentException("생성자 정보가 필요합니다"); + } + } + + /** 비즈니스 규칙 검증 */ + private void validateBusinessRules(WorkflowCreateDto dto) { + // 포스팅 플랫폼 선택 시 계정 정보 필수 검증 + String postingPlatform = dto.getPostingPlatform(); + if (postingPlatform != null && !postingPlatform.isBlank()) { + if (dto.getPostingAccountId() == null || dto.getPostingAccountId().isBlank()) { + throw new IllegalArgumentException("포스팅 플랫폼 선택 시 계정 ID는 필수입니다"); + } + if (dto.getPostingAccountPassword() == null || dto.getPostingAccountPassword().isBlank()) { + throw new IllegalArgumentException("포스팅 플랫폼 선택 시 계정 비밀번호는 필수입니다"); + } + // 티스토리 블로그 추가 검증 + if ("tstory_blog".equals(postingPlatform)) { + if (dto.getBlogName() == null || dto.getBlogName().isBlank()) { + throw new IllegalArgumentException("티스토리 블로그 선택 시 블로그 이름은 필수입니다"); + } + } + } + } } diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml index 63a9f6db..dda398a9 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml @@ -129,4 +129,61 @@ wj.execution_order, j.id, j.name, j.description, j.is_enabled ORDER BY wj.execution_order + + + INSERT INTO workflow ( + name, + description, + is_enabled, + created_by, + created_at, + default_config + ) VALUES ( + #{dto.name}, + #{dto.description}, + #{dto.isEnabled}, + #{createdBy}, + NOW(), + #{dto.defaultConfigJson} + ) + + + + + + + + + SELECT LAST_INSERT_ID() as id + + INSERT INTO job (name, description, created_by, created_at) VALUES + ('상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', #{createdBy}, NOW()), + ('블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', #{createdBy}, NOW()) + + + + + INSERT INTO workflow_job (workflow_id, job_id, execution_order) VALUES + (#{workflowId}, #{job1Id}, 1), + (#{workflowId}, #{job2Id}, 2) + + + + + INSERT INTO job_task (job_id, task_id, execution_order) VALUES + + (#{job1Id}, 1, 1), + (#{job1Id}, 2, 2), + (#{job1Id}, 3, 3), + (#{job1Id}, 4, 4), + (#{job1Id}, 5, 5), + (#{job1Id}, 6, 6), + + (#{job2Id}, 7, 1), + (#{job2Id}, 8, 2) + \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java new file mode 100644 index 00000000..115bec64 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -0,0 +1,219 @@ +package site.icebang.e2e.scenario; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import org.springframework.test.context.jdbc.Sql; + +import site.icebang.e2e.setup.annotation.E2eTest; +import site.icebang.e2e.setup.support.E2eTestSupport; + +@Sql( + value = {"classpath:sql/00-truncate.sql", "classpath:sql/01-insert-internal-users.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@DisplayName("워크플로우 생성 플로우 E2E 테스트") +@E2eTest +class WorkflowCreateFlowE2eTest extends E2eTestSupport { + + @SuppressWarnings("unchecked") + @Test + @DisplayName("사용자가 새 워크플로우를 생성하는 전체 플로우") + void completeWorkflowCreateFlow() throws Exception { + logStep(1, "사용자 로그인"); + + // 1. 로그인 (세션에 userId 저장) + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders loginHeaders = new HttpHeaders(); + loginHeaders.setContentType(MediaType.APPLICATION_JSON); + loginHeaders.set("Origin", "https://admin.icebang.site"); + loginHeaders.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> loginEntity = new HttpEntity<>(loginRequest, loginHeaders); + + ResponseEntity loginResponse = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); + + assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); + + logSuccess("사용자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); + + logStep(2, "네이버 블로그 워크플로우 생성"); + + // 2. 네이버 블로그 워크플로우 생성 + Map naverBlogWorkflow = new HashMap<>(); + naverBlogWorkflow.put("name", "상품 분석 및 네이버 블로그 자동 발행"); + naverBlogWorkflow.put("description", "키워드 검색부터 상품 분석 후 네이버 블로그 발행까지의 자동화 프로세스"); + naverBlogWorkflow.put("search_platform", "naver"); + naverBlogWorkflow.put("posting_platform", "naver_blog"); + naverBlogWorkflow.put("posting_account_id", "test_naver_blog"); + naverBlogWorkflow.put("posting_account_password", "naver_password123"); + naverBlogWorkflow.put("is_enabled", true); + + HttpHeaders workflowHeaders = new HttpHeaders(); + workflowHeaders.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> naverEntity = + new HttpEntity<>(naverBlogWorkflow, workflowHeaders); + + ResponseEntity naverResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), naverEntity, Map.class); + + assertThat(naverResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) naverResponse.getBody().get("success")).isTrue(); + + logSuccess("네이버 블로그 워크플로우 생성 성공"); + + logStep(3, "티스토리 블로그 워크플로우 생성 (블로그명 포함)"); + + // 3. 티스토리 블로그 워크플로우 생성 (블로그명 필수) + Map tstoryWorkflow = new HashMap<>(); + tstoryWorkflow.put("name", "티스토리 자동 발행 워크플로우"); + tstoryWorkflow.put("description", "티스토리 블로그 자동 포스팅"); + tstoryWorkflow.put("search_platform", "naver"); + tstoryWorkflow.put("posting_platform", "tstory_blog"); + tstoryWorkflow.put("posting_account_id", "test_tstory"); + tstoryWorkflow.put("posting_account_password", "tstory_password123"); + tstoryWorkflow.put("blog_name", "my-tech-blog"); // 티스토리는 블로그명 필수 + tstoryWorkflow.put("is_enabled", true); + + HttpEntity> tstoryEntity = + new HttpEntity<>(tstoryWorkflow, workflowHeaders); + + ResponseEntity tstoryResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), tstoryEntity, Map.class); + + assertThat(tstoryResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) tstoryResponse.getBody().get("success")).isTrue(); + + logSuccess("티스토리 워크플로우 생성 성공"); + + logStep(4, "검색만 하는 워크플로우 생성 (포스팅 없음)"); + + // 4. 포스팅 없는 검색 전용 워크플로우 (추후 예정) + Map searchOnlyWorkflow = new HashMap<>(); + searchOnlyWorkflow.put("name", "검색 전용 워크플로우"); + searchOnlyWorkflow.put("description", "상품 검색 및 분석만 수행"); + searchOnlyWorkflow.put("search_platform", "naver"); + searchOnlyWorkflow.put("is_enabled", true); + // posting_platform, posting_account_id, posting_account_password는 선택사항 + + HttpEntity> searchOnlyEntity = + new HttpEntity<>(searchOnlyWorkflow, workflowHeaders); + + ResponseEntity searchOnlyResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), searchOnlyEntity, Map.class); + + assertThat(searchOnlyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) searchOnlyResponse.getBody().get("success")).isTrue(); + + logSuccess("검색 전용 워크플로우 생성 성공"); + + logCompletion("워크플로우 생성 플로우 완료"); + } + + @Test + @DisplayName("중복된 이름으로 워크플로우 생성 시도 시 실패") + void createWorkflow_withDuplicateName_shouldFail() { + // 선행 조건: 로그인 + performUserLogin(); + + logStep(1, "첫 번째 워크플로우 생성"); + + // 첫 번째 워크플로우 생성 + Map firstWorkflow = new HashMap<>(); + firstWorkflow.put("name", "중복테스트워크플로우"); + firstWorkflow.put("search_platform", "naver"); + firstWorkflow.put("is_enabled", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> firstEntity = new HttpEntity<>(firstWorkflow, headers); + + ResponseEntity firstResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), firstEntity, Map.class); + + assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + logSuccess("첫 번째 워크플로우 생성 성공"); + + logStep(2, "동일한 이름으로 두 번째 워크플로우 생성 시도"); + + // 동일한 이름으로 다시 생성 시도 + Map duplicateWorkflow = new HashMap<>(); + duplicateWorkflow.put("name", "중복테스트워크플로우"); // 동일한 이름 + duplicateWorkflow.put("search_platform", "naver_store"); + duplicateWorkflow.put("is_enabled", true); + + HttpEntity> duplicateEntity = new HttpEntity<>(duplicateWorkflow, headers); + + ResponseEntity duplicateResponse = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), duplicateEntity, Map.class); + + // 중복 이름 처리 확인 (400 또는 409 예상) + assertThat(duplicateResponse.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("중복 이름 워크플로우 생성 차단 확인"); + } + + @Test + @DisplayName("필수 필드 누락 시 워크플로우 생성 실패") + void createWorkflow_withMissingRequiredFields_shouldFail() { + // 선행 조건: 로그인 + performUserLogin(); + + logStep(1, "워크플로우 이름 없이 생성 시도"); + + // 이름 없는 요청 + Map noNameWorkflow = new HashMap<>(); + noNameWorkflow.put("search_platform", "naver"); + noNameWorkflow.put("is_enabled", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(noNameWorkflow, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + + assertThat(response.getStatusCode()) + .isIn(HttpStatus.BAD_REQUEST, HttpStatus.UNPROCESSABLE_ENTITY); + + logSuccess("필수 필드 검증 확인"); + } + + /** 사용자 로그인을 수행하는 헬퍼 메서드 */ + private void performUserLogin() { + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Origin", "https://admin.icebang.site"); + headers.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> entity = new HttpEntity<>(loginRequest, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + logError("사용자 로그인 실패: " + response.getStatusCode()); + throw new RuntimeException("User login failed"); + } + + logSuccess("사용자 로그인 완료"); + } +}