From 596bf4923de9075296f43b83b0c2112842bea05d Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Fri, 12 Sep 2025 18:49:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20UserLogoutFlowE2eTest=20=EC=B4=88?= =?UTF-8?q?=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/scenario/UserLogoutFlowE2eTest.java | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java 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 new file mode 100644 index 00000000..7711fa64 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java @@ -0,0 +1,143 @@ +package site.icebang.e2e.scenario; + +import static org.assertj.core.api.Assertions.*; + +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; +import site.icebang.e2e.setup.support.E2eTestSupport; +import site.icebang.e2e.setup.annotation.E2eTest; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Sql( + value = "classpath:sql/01-insert-internal-users.sql", + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@DisplayName("사용자 로그아웃 플로우 E2E 테스트") +@E2eTest +class UserLogoutFlowE2eTest extends E2eTestSupport { + + @SuppressWarnings("unchecked") + @Test + @DisplayName("정상 로그아웃 전체 플로우 - TDD REd 단계") + void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Exception { + logStep(1, "관리자 로그인 (최우선)"); + + // 1. 관리자 로그인으로 인증 상태 확립 + 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("관리자 로그인 성공 - 인증 상태 확립 완료"); + + logStep(2, "로그인 상태에서 보호된 리소스 접근 확인"); + + + // 2. 로그인된 상태에서 본인 프로필 조회로 인증 상태 확인 + // /v0/users/me는 인증된 사용자만 접근 가능한 일반적인 API + ResponseEntity beforeLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); + assertThat(beforeLogoutResponse.getBody().get("data")).isNotNull(); + + logSuccess("인증된 상태에서 본인 프로필 조회 성공"); + + // 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/"); + + HttpEntity> logoutEntity = + new HttpEntity<>(new HashMap<>(), logoutHeaders); + + try { + ResponseEntity logoutResponse = + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); + logStep(4, "로그아웃 응답 검증 (API구현 돼있으면)"); + + // 4. 로그아웃 성공 응답 확인 (API가 구현되어 있다면 이 검증이 통과해야 함) + + logSuccess("로그아웃 API 호출 성공"); + + logStep(5, "로그아웃 후 인증 무효화 확인"); + + // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 + ResponseEntity afterLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 + assertThat(afterLogoutResponse.getStatusCode()) + .withFailMessage("로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", + afterLogoutResponse.getStatusCode()) + .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); + logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); + + logCompletion("일반 사용자 로그아웃 플로우"); + + } catch(org.springframework.web.client.HttpClientErrorException.NotFound ex) { + logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found"); + logError("에러 메시지 : " + ex.getMessage()); + + // TDD Red 단계에서는 이런 실패가 예상됨 + fail("로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " + + "다음 단계에서 API를 구현해야 합니다. 에러: " + ex.getMessage()); + } catch (Exception ex) { + logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); + logError("에러 메시지: " + ex.getMessage()); + + // 기타 예상치 못한 에러도 기록 + fail("로그아웃 API 호출 중 예상치 못한 오류 발생: " + ex.getMessage()); + } + } + + + /** + * 일반 사용자 로그인을 수행하는 헬퍼 메서드 + * 관리자가 아닌 콘텐츠팀장으로 로그인 + */ + private void performRegularUserLogin() { + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "viral.jung@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("Regular user login failed for logout test"); + } + + logSuccess("일반 사용자 로그인 완료 (로그아웃 테스트용)"); + } +} From d13dd474d6b012bcf84ad869dba790d0402b2247 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 15 Sep 2025 16:51:00 +0900 Subject: [PATCH 2/5] feat: UserLogoutFlowE2eTest --- .../e2e/scenario/UserLogoutFlowE2eTest.java | 76 +++++++++++-------- 1 file changed, 46 insertions(+), 30 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 7711fa64..8fea2764 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 @@ -1,21 +1,18 @@ package site.icebang.e2e.scenario; import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; + +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; -import site.icebang.e2e.setup.support.E2eTestSupport; -import site.icebang.e2e.setup.annotation.E2eTest; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; +import site.icebang.e2e.setup.annotation.E2eTest; +import site.icebang.e2e.setup.support.E2eTestSupport; @Sql( value = "classpath:sql/01-insert-internal-users.sql", @@ -52,11 +49,29 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex 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.getForEntity(getV0ApiUrl("/users/me"), Map.class); + restTemplate.exchange( + getV0ApiUrl("/users/me"), HttpMethod.GET, authenticatedEntity, Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -64,46 +79,51 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex logSuccess("인증된 상태에서 본인 프로필 조회 성공"); - // 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/"); - HttpEntity> logoutEntity = - new HttpEntity<>(new HashMap<>(), logoutHeaders); + // 로그아웃 요청에도 세션 쿠키 포함 + 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); + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); logStep(4, "로그아웃 응답 검증 (API구현 돼있으면)"); - // 4. 로그아웃 성공 응답 확인 (API가 구현되어 있다면 이 검증이 통과해야 함) - logSuccess("로그아웃 API 호출 성공"); logStep(5, "로그아웃 후 인증 무효화 확인"); // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 + HttpEntity afterLogoutEntity = new HttpEntity<>(authenticatedHeaders); ResponseEntity afterLogoutResponse = - restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + restTemplate.exchange( + getV0ApiUrl("/users/me"), HttpMethod.GET, afterLogoutEntity, Map.class); // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 assertThat(afterLogoutResponse.getStatusCode()) - .withFailMessage("로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", - afterLogoutResponse.getStatusCode()) - .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); + .withFailMessage( + "로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", afterLogoutResponse.getStatusCode()) + .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); logCompletion("일반 사용자 로그아웃 플로우"); - } catch(org.springframework.web.client.HttpClientErrorException.NotFound ex) { + } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found"); logError("에러 메시지 : " + ex.getMessage()); - // TDD Red 단계에서는 이런 실패가 예상됨 - fail("로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " + - "다음 단계에서 API를 구현해야 합니다. 에러: " + ex.getMessage()); + fail( + "로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " + + "다음 단계에서 API를 구현해야 합니다. 에러: " + + ex.getMessage()); } catch (Exception ex) { logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); logError("에러 메시지: " + ex.getMessage()); @@ -113,11 +133,7 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex } } - - /** - * 일반 사용자 로그인을 수행하는 헬퍼 메서드 - * 관리자가 아닌 콘텐츠팀장으로 로그인 - */ + /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 관리자가 아닌 콘텐츠팀장으로 로그인 */ private void performRegularUserLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "viral.jung@icebang.site"); @@ -131,7 +147,7 @@ private void performRegularUserLogin() { HttpEntity> entity = new HttpEntity<>(loginRequest, headers); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("일반 사용자 로그인 실패: " + response.getStatusCode()); From 4f02c6206ce046d6221bc13d0c4039115ea28775 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 15 Sep 2025 16:51:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20AuthController=EC=97=90=20logout=20?= =?UTF-8?q?api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/controller/AuthController.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java b/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java index d0a98142..2303cf74 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java @@ -61,4 +61,18 @@ public ApiResponse checkSession(@AuthenticationPrincipal AuthCredential public ApiResponse getPermissions(@AuthenticationPrincipal AuthCredential user) { return ApiResponse.success(user); } + + @PostMapping("/logout") + public ApiResponse logout(HttpServletRequest request) { + // SecurityContext 정리 + SecurityContextHolder.clearContext(); + + // 세션 무효화 + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + + return ApiResponse.success(null); + } } From 154e69df84dfa6afc59b9b01c14af1f9df7df8d5 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 15 Sep 2025 16:52:16 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20AuthApiIntegrationTest=EC=97=90=20L?= =?UTF-8?q?ogout=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/auth/AuthApiIntegrationTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/auth/AuthApiIntegrationTest.java index 5c538105..4fe3b00d 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 @@ -13,6 +13,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.*; +import org.springframework.mock.web.MockHttpSession; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.jdbc.Sql; import org.springframework.transaction.annotation.Transactional; @@ -79,4 +80,62 @@ void login_success() throws Exception { .description("HTTP 상태")) .build()))); } + + @Test + @DisplayName("사용자 로그아웃 성공") + void logout_success() throws Exception { + // given - 먼저 로그인 + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + MockHttpSession session = new MockHttpSession(); + + // 로그인 먼저 수행 + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/login")) + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()); + + // when & then - 로그아웃 수행 + mockMvc + .perform( + post(getApiUrlForDocs("/v0/auth/logout")) + .contentType(MediaType.APPLICATION_JSON) + .session(session) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").isEmpty()) + .andDo( + document( + "auth-logout", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Authentication") + .summary("사용자 로그아웃") + .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()))); + } } From 518772573a98e98135e141786a88dace10c687b9 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 15 Sep 2025 17:01:41 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20h2=20=ED=98=B8=ED=99=98=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=ED=8C=8C=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/sql/01-schema-h2.sql | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 apps/user-service/src/main/resources/sql/01-schema-h2.sql 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`);