Compare commits
121 커밋
main
...
feature/bu
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 41b06beeec | |||
| 50badbe2bb | |||
| 01df023966 | |||
| d2c39009ac | |||
| 90ffe68be3 | |||
| 99b59f0ed5 | |||
| 71d95bd6fa | |||
| ada5bbaa0f | |||
| ce9244ca0a | |||
| b77df66b78 | |||
| cfc80bbb0d | |||
| 0743fd4322 | |||
| 82d427bda2 | |||
| 290933f94f | |||
|
|
178ac506bf | ||
| 07368f18cb | |||
| a93942d4d6 | |||
| f53648290c | |||
| 6555c5e28f | |||
| 3cbc2d2e94 | |||
| a59c91ae1f | |||
| 30304de4e6 | |||
| 7a1b24e381 | |||
| 8d2cd09725 | |||
| 6c4ce9a536 | |||
| 9fed34e1bc | |||
| 21368ffaff | |||
| 7ab53d1bbf | |||
| 613980c496 | |||
| e63607a69d | |||
| f4421fa455 | |||
| 43057d74fb | |||
| 64a3a55e78 | |||
| f2c4e0d14f | |||
| 5305f61a41 | |||
| c3dabd370c | |||
| 9c021f298c | |||
| cbb53fd9f1 | |||
| 49d2de1965 | |||
| 1ab78e881f | |||
| 4e79794750 | |||
| abe5ea1a1c | |||
| d8b8a40316 | |||
| b842ec8d54 | |||
| e1fa48768e | |||
| 87a9217853 | |||
| 6e70e921af | |||
| 3fb133e367 | |||
| 31262f5dda | |||
| 99fcd38d24 | |||
| 7360736cb0 | |||
| 6aba0f55b0 | |||
| 1d2a3c53c8 | |||
| 020f16035b | |||
| 94f7d4b5c0 | |||
|
|
0a5e2e56af | ||
| 32af369f23 | |||
| fcf1d74c38 | |||
| 5683000024 | |||
|
|
a7cf1647f8 | ||
| 6d7b7c9eea | |||
| 6885d41ba5 | |||
| 7b1fe1d52c | |||
| bff4de17c7 | |||
| bda2d812ff | |||
|
|
1124c2e84a | ||
|
|
75531ab5e5 | ||
| 4700ec862b | |||
|
|
e7ea47b02c | ||
|
|
63e9253d7f | ||
| acd76bd358 | |||
| 270b2a0b55 | |||
| 084be88b98 | |||
| fb10e3cc39 | |||
| b2167d4ec7 | |||
| 630c366a06 | |||
|
|
e7f4a9d912 | ||
| 1c491de9e2 | |||
|
|
3118df3533 | ||
| 090f009529 | |||
|
|
c46a62268c | ||
|
|
f2970872fd | ||
|
|
ac78a1340a | ||
|
|
3ee6ae1bf7 | ||
| 2a0a80098d | |||
| eb81be5f21 | |||
| 655318e353 | |||
| 2e509560de | |||
| fedd89c9ca | |||
| 3dde3d0167 | |||
|
|
6c98ebc24f | ||
|
|
18ab11068a | ||
| 37f61fe924 | |||
| e9b30f8817 | |||
|
|
34ce85f33f | ||
|
|
919b0fc21a | ||
|
|
7941396d62 | ||
|
|
248e9c2c46 | ||
|
|
2671d613f3 | ||
| 1b7fa47dbd | |||
| 8d8ea53449 | |||
| 322ecb12a6 | |||
| 55d4dd5886 | |||
| c842e982c8 | |||
| 44ae82e2fa | |||
| d6cf58d737 | |||
| 5857a4a822 | |||
| 6af2fccbf0 | |||
|
|
c99b6993a7 | ||
| b3cb4f6f19 | |||
| 4282fc9106 | |||
| 8a3e9a973e | |||
| 68893f9657 | |||
| 5787fb5be0 | |||
| 4ed1070a37 | |||
| f9b20bdc59 | |||
| 7a405bb969 | |||
| 906611c9b8 | |||
|
|
e44637e1f3 | ||
| 6be90723b4 | |||
| 18fa95e903 |
73
.claude/rules/code-style.md
Normal file
73
.claude/rules/code-style.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Java 코드 스타일 규칙
|
||||||
|
|
||||||
|
## 일반
|
||||||
|
- Java 17+ 문법 사용 (record, sealed class, pattern matching, text block 활용)
|
||||||
|
- 들여쓰기: 4 spaces (탭 사용 금지)
|
||||||
|
- 줄 길이: 120자 이하
|
||||||
|
- 파일 끝에 빈 줄 추가
|
||||||
|
|
||||||
|
## 클래스 구조
|
||||||
|
클래스 내 멤버 순서:
|
||||||
|
1. static 상수 (public → private)
|
||||||
|
2. 인스턴스 필드 (public → private)
|
||||||
|
3. 생성자
|
||||||
|
4. public 메서드
|
||||||
|
5. protected/package-private 메서드
|
||||||
|
6. private 메서드
|
||||||
|
7. inner class/enum
|
||||||
|
|
||||||
|
## Spring Boot 규칙
|
||||||
|
|
||||||
|
### 계층 구조
|
||||||
|
- Controller → Service → Repository 단방향 의존
|
||||||
|
- Controller에 비즈니스 로직 금지 (요청/응답 변환만)
|
||||||
|
- Service 계층 간 순환 참조 금지
|
||||||
|
- Repository에 비즈니스 로직 금지
|
||||||
|
|
||||||
|
### DTO와 Entity 분리
|
||||||
|
- API 요청/응답에 Entity 직접 사용 금지
|
||||||
|
- DTO는 record 또는 불변 클래스로 작성
|
||||||
|
- DTO ↔ Entity 변환은 매퍼 클래스 또는 팩토리 메서드 사용
|
||||||
|
|
||||||
|
### 의존성 주입
|
||||||
|
- 생성자 주입 사용 (필드 주입 `@Autowired` 사용 금지)
|
||||||
|
- 단일 생성자는 `@Autowired` 어노테이션 생략
|
||||||
|
- Lombok `@RequiredArgsConstructor` 사용 가능
|
||||||
|
|
||||||
|
### 트랜잭션
|
||||||
|
- `@Transactional` 범위 최소화
|
||||||
|
- 읽기 전용: `@Transactional(readOnly = true)`
|
||||||
|
- Service 메서드 레벨에 적용 (클래스 레벨 지양)
|
||||||
|
|
||||||
|
## Lombok 규칙
|
||||||
|
- `@Getter`, `@Setter` 허용 (Entity에서 Setter는 지양)
|
||||||
|
- `@Builder` 허용
|
||||||
|
- `@Data` 사용 금지 (명시적으로 필요한 어노테이션만)
|
||||||
|
- `@AllArgsConstructor` 단독 사용 금지 (`@Builder`와 함께 사용)
|
||||||
|
|
||||||
|
## 로깅
|
||||||
|
- `@Slf4j` (Lombok) 로거 사용
|
||||||
|
- SLF4J `{}` 플레이스홀더에 printf 포맷 사용 금지 (`{:.1f}`, `{:d}`, `{%s}` 등)
|
||||||
|
- 숫자 포맷이 필요하면 `String.format()`으로 변환 후 전달
|
||||||
|
```java
|
||||||
|
// 잘못됨
|
||||||
|
log.info("처리율: {:.1f}%", rate);
|
||||||
|
// 올바름
|
||||||
|
log.info("처리율: {}%", String.format("%.1f", rate));
|
||||||
|
```
|
||||||
|
- 예외 로깅 시 예외 객체는 마지막 인자로 전달 (플레이스홀더 불필요)
|
||||||
|
```java
|
||||||
|
log.error("처리 실패: {}", id, exception);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 예외 처리
|
||||||
|
- 비즈니스 예외는 커스텀 Exception 클래스 정의
|
||||||
|
- `@ControllerAdvice`로 전역 예외 처리
|
||||||
|
- 예외 메시지에 컨텍스트 정보 포함
|
||||||
|
- catch 블록에서 예외 무시 금지 (`// ignore` 금지)
|
||||||
|
|
||||||
|
## 기타
|
||||||
|
- `Optional`은 반환 타입으로만 사용 (필드, 파라미터에 사용 금지)
|
||||||
|
- `null` 반환보다 빈 컬렉션 또는 `Optional` 반환
|
||||||
|
- Stream API 활용 (단, 3단계 이상 체이닝은 메서드 추출)
|
||||||
|
- 하드코딩된 문자열/숫자 금지 → 상수 또는 설정값으로 추출
|
||||||
84
.claude/rules/git-workflow.md
Normal file
84
.claude/rules/git-workflow.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Git 워크플로우 규칙
|
||||||
|
|
||||||
|
## 브랜치 전략
|
||||||
|
|
||||||
|
### 브랜치 구조
|
||||||
|
```
|
||||||
|
main ← 배포 가능한 안정 브랜치 (보호됨)
|
||||||
|
└── develop ← 개발 통합 브랜치
|
||||||
|
├── feature/ISSUE-123-기능설명
|
||||||
|
├── bugfix/ISSUE-456-버그설명
|
||||||
|
└── hotfix/ISSUE-789-긴급수정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브랜치 네이밍
|
||||||
|
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
|
||||||
|
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
|
||||||
|
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
|
||||||
|
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
|
||||||
|
|
||||||
|
### 브랜치 규칙
|
||||||
|
- main, develop 브랜치에 직접 커밋/푸시 금지
|
||||||
|
- feature 브랜치는 develop에서 분기
|
||||||
|
- hotfix 브랜치는 main에서 분기
|
||||||
|
- 머지는 반드시 MR(Merge Request)을 통해 수행
|
||||||
|
|
||||||
|
## 커밋 메시지 규칙
|
||||||
|
|
||||||
|
### Conventional Commits 형식
|
||||||
|
```
|
||||||
|
type(scope): subject
|
||||||
|
|
||||||
|
body (선택)
|
||||||
|
|
||||||
|
footer (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
### type (필수)
|
||||||
|
| type | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| feat | 새로운 기능 추가 |
|
||||||
|
| fix | 버그 수정 |
|
||||||
|
| docs | 문서 변경 |
|
||||||
|
| style | 코드 포맷팅 (기능 변경 없음) |
|
||||||
|
| refactor | 리팩토링 (기능 변경 없음) |
|
||||||
|
| test | 테스트 추가/수정 |
|
||||||
|
| chore | 빌드, 설정 변경 |
|
||||||
|
| ci | CI/CD 설정 변경 |
|
||||||
|
| perf | 성능 개선 |
|
||||||
|
|
||||||
|
### scope (선택)
|
||||||
|
- 변경 범위를 나타내는 짧은 단어
|
||||||
|
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
|
||||||
|
|
||||||
|
### subject (필수)
|
||||||
|
- 변경 내용을 간결하게 설명
|
||||||
|
- 한국어, 영어 모두 허용
|
||||||
|
- 72자 이내
|
||||||
|
- 마침표(.) 없이 끝냄
|
||||||
|
|
||||||
|
### 예시
|
||||||
|
```
|
||||||
|
feat(auth): JWT 기반 로그인 구현
|
||||||
|
fix(배치): 야간 배치 타임아웃 수정
|
||||||
|
docs: README에 빌드 방법 추가
|
||||||
|
refactor(user-service): 중복 로직 추출
|
||||||
|
test(결제): 환불 로직 단위 테스트 추가
|
||||||
|
chore: Gradle 의존성 버전 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
## MR(Merge Request) 규칙
|
||||||
|
|
||||||
|
### MR 생성
|
||||||
|
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
|
||||||
|
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
|
||||||
|
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
|
||||||
|
|
||||||
|
### MR 리뷰
|
||||||
|
- 최소 1명의 리뷰어 승인 필수
|
||||||
|
- CI 검증 통과 필수 (설정된 경우)
|
||||||
|
- 리뷰 코멘트 모두 해결 후 머지
|
||||||
|
|
||||||
|
### MR 머지
|
||||||
|
- Squash Merge 권장 (깔끔한 히스토리)
|
||||||
|
- 머지 후 소스 브랜치 삭제
|
||||||
60
.claude/rules/naming.md
Normal file
60
.claude/rules/naming.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Java 네이밍 규칙
|
||||||
|
|
||||||
|
## 패키지
|
||||||
|
- 모두 소문자, 단수형
|
||||||
|
- 도메인 역순: `com.gcsc.프로젝트명.모듈`
|
||||||
|
- 예: `com.gcsc.batch.scheduler`, `com.gcsc.api.auth`
|
||||||
|
|
||||||
|
## 클래스
|
||||||
|
- PascalCase
|
||||||
|
- 명사 또는 명사구
|
||||||
|
- 접미사로 역할 표시:
|
||||||
|
|
||||||
|
| 계층 | 접미사 | 예시 |
|
||||||
|
|------|--------|------|
|
||||||
|
| Controller | `Controller` | `UserController` |
|
||||||
|
| Service | `Service` | `UserService` |
|
||||||
|
| Service 구현 | `ServiceImpl` | `UserServiceImpl` (인터페이스 있을 때만) |
|
||||||
|
| Repository | `Repository` | `UserRepository` |
|
||||||
|
| Entity | (없음) | `User`, `ShipRoute` |
|
||||||
|
| DTO 요청 | `Request` | `CreateUserRequest` |
|
||||||
|
| DTO 응답 | `Response` | `UserResponse` |
|
||||||
|
| 설정 | `Config` | `SecurityConfig` |
|
||||||
|
| 예외 | `Exception` | `UserNotFoundException` |
|
||||||
|
| Enum | (없음) | `UserStatus`, `ShipType` |
|
||||||
|
| Mapper | `Mapper` | `UserMapper` |
|
||||||
|
|
||||||
|
## 메서드
|
||||||
|
- camelCase
|
||||||
|
- 동사로 시작
|
||||||
|
- CRUD 패턴:
|
||||||
|
|
||||||
|
| 작업 | Controller | Service | Repository |
|
||||||
|
|------|-----------|---------|------------|
|
||||||
|
| 조회(단건) | `getUser()` | `getUser()` | `findById()` |
|
||||||
|
| 조회(목록) | `getUsers()` | `getUsers()` | `findAll()` |
|
||||||
|
| 생성 | `createUser()` | `createUser()` | `save()` |
|
||||||
|
| 수정 | `updateUser()` | `updateUser()` | `save()` |
|
||||||
|
| 삭제 | `deleteUser()` | `deleteUser()` | `deleteById()` |
|
||||||
|
| 존재확인 | - | `existsUser()` | `existsById()` |
|
||||||
|
|
||||||
|
## 변수
|
||||||
|
- camelCase
|
||||||
|
- 의미 있는 이름 (단일 문자 변수 금지, 루프 인덱스 `i, j, k` 예외)
|
||||||
|
- boolean: `is`, `has`, `can`, `should` 접두사
|
||||||
|
- 예: `isActive`, `hasPermission`, `canDelete`
|
||||||
|
|
||||||
|
## 상수
|
||||||
|
- UPPER_SNAKE_CASE
|
||||||
|
- 예: `MAX_RETRY_COUNT`, `DEFAULT_PAGE_SIZE`
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- 클래스: `{대상클래스}Test` (예: `UserServiceTest`)
|
||||||
|
- 메서드: `{메서드명}_{시나리오}_{기대결과}` 또는 한국어 `@DisplayName`
|
||||||
|
- 예: `createUser_withDuplicateEmail_throwsException()`
|
||||||
|
- 예: `@DisplayName("중복 이메일로 생성 시 예외 발생")`
|
||||||
|
|
||||||
|
## 파일/디렉토리
|
||||||
|
- Java 파일: PascalCase (클래스명과 동일)
|
||||||
|
- 리소스 파일: kebab-case (예: `application-local.yml`)
|
||||||
|
- SQL 파일: `V{번호}__{설명}.sql` (Flyway) 또는 kebab-case
|
||||||
34
.claude/rules/team-policy.md
Normal file
34
.claude/rules/team-policy.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# 팀 정책 (Team Policy)
|
||||||
|
|
||||||
|
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
|
||||||
|
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
|
||||||
|
|
||||||
|
## 보안 정책
|
||||||
|
|
||||||
|
### 금지 행위
|
||||||
|
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
|
||||||
|
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
|
||||||
|
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
|
||||||
|
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
|
||||||
|
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
|
||||||
|
|
||||||
|
### 인증 정보 관리
|
||||||
|
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
|
||||||
|
- 설정 파일은 `.gitignore`에 반드시 포함
|
||||||
|
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
|
||||||
|
|
||||||
|
## 코드 품질 정책
|
||||||
|
|
||||||
|
### 필수 검증
|
||||||
|
- 커밋 전 빌드(컴파일) 성공 확인
|
||||||
|
- 린트 경고 0개 유지 (CI에서도 검증)
|
||||||
|
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
|
||||||
|
|
||||||
|
### 코드 리뷰
|
||||||
|
- main 브랜치 머지 시 최소 1명 리뷰 필수
|
||||||
|
- 리뷰어 승인 없이 머지 불가
|
||||||
|
|
||||||
|
## 문서화 정책
|
||||||
|
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
|
||||||
|
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
|
||||||
|
- README.md에 프로젝트 빌드/실행 방법 유지
|
||||||
62
.claude/rules/testing.md
Normal file
62
.claude/rules/testing.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Java 테스트 규칙
|
||||||
|
|
||||||
|
## 테스트 프레임워크
|
||||||
|
- JUnit 5 + AssertJ 조합
|
||||||
|
- Mockito로 의존성 모킹
|
||||||
|
- Spring Boot Test (`@SpringBootTest`) 는 통합 테스트에만 사용
|
||||||
|
|
||||||
|
## 테스트 구조
|
||||||
|
|
||||||
|
### 단위 테스트 (Unit Test)
|
||||||
|
- Service, Util, Domain 로직 테스트
|
||||||
|
- Spring 컨텍스트 로딩 없이 (`@ExtendWith(MockitoExtension.class)`)
|
||||||
|
- 외부 의존성은 Mockito로 모킹
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServiceTest {
|
||||||
|
@InjectMocks
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("사용자 생성 시 정상 저장")
|
||||||
|
void createUser_withValidInput_savesUser() {
|
||||||
|
// given
|
||||||
|
// when
|
||||||
|
// then
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 (Integration Test)
|
||||||
|
- Controller 테스트: `@WebMvcTest` + `MockMvc`
|
||||||
|
- Repository 테스트: `@DataJpaTest`
|
||||||
|
- 전체 플로우: `@SpringBootTest` (최소화)
|
||||||
|
|
||||||
|
### 테스트 패턴
|
||||||
|
- **Given-When-Then** 구조 사용
|
||||||
|
- 각 섹션을 주석으로 구분
|
||||||
|
- 하나의 테스트에 하나의 검증 원칙 (가능한 범위에서)
|
||||||
|
|
||||||
|
## 테스트 네이밍
|
||||||
|
- 메서드명: `{메서드}_{시나리오}_{기대결과}` 패턴
|
||||||
|
- `@DisplayName`: 한국어로 테스트 의도 설명
|
||||||
|
|
||||||
|
## 테스트 커버리지
|
||||||
|
- 새로 작성하는 Service 클래스: 핵심 비즈니스 로직 테스트 필수
|
||||||
|
- 기존 코드 수정 시: 수정된 로직에 대한 테스트 추가 권장
|
||||||
|
- Controller: 주요 API endpoint 통합 테스트 권장
|
||||||
|
|
||||||
|
## 테스트 데이터
|
||||||
|
- 테스트 데이터는 테스트 메서드 내부 또는 `@BeforeEach`에서 생성
|
||||||
|
- 공통 테스트 데이터는 TestFixture 클래스로 분리
|
||||||
|
- 실제 DB 연결 필요 시 H2 인메모리 또는 Testcontainers 사용
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- `@SpringBootTest`를 단위 테스트에 사용 금지
|
||||||
|
- 테스트 간 상태 공유 금지
|
||||||
|
- `Thread.sleep()` 사용 금지 → `Awaitility` 사용
|
||||||
|
- 실제 외부 API 호출 금지 → WireMock 또는 Mockito 사용
|
||||||
78
.claude/settings.json
Normal file
78
.claude/settings.json
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(./mvnw *)",
|
||||||
|
"Bash(mvn *)",
|
||||||
|
"Bash(java -version)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git diff*)",
|
||||||
|
"Bash(git log*)",
|
||||||
|
"Bash(git branch*)",
|
||||||
|
"Bash(git checkout*)",
|
||||||
|
"Bash(git add*)",
|
||||||
|
"Bash(git commit*)",
|
||||||
|
"Bash(git pull*)",
|
||||||
|
"Bash(git fetch*)",
|
||||||
|
"Bash(git merge*)",
|
||||||
|
"Bash(git stash*)",
|
||||||
|
"Bash(git remote*)",
|
||||||
|
"Bash(git config*)",
|
||||||
|
"Bash(git rev-parse*)",
|
||||||
|
"Bash(git show*)",
|
||||||
|
"Bash(git tag*)",
|
||||||
|
"Bash(curl -s *)",
|
||||||
|
"Bash(sdk *)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(git clean -fd*)",
|
||||||
|
"Bash(git checkout -- .)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(rm -rf .git*)",
|
||||||
|
"Bash(rm -rf /*)",
|
||||||
|
"Read(./**/.env*)",
|
||||||
|
"Read(./**/secrets/**)",
|
||||||
|
"Read(./**/application-local.yml)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
65
.claude/skills/create-mr/SKILL.md
Normal file
65
.claude/skills/create-mr/SKILL.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: create-mr
|
||||||
|
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
|
||||||
|
allowed-tools: "Bash, Read, Grep"
|
||||||
|
argument-hint: "[target-branch: develop|main] (기본: develop)"
|
||||||
|
---
|
||||||
|
|
||||||
|
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
|
||||||
|
타겟 브랜치: $ARGUMENTS (기본: develop)
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 사전 검증
|
||||||
|
- 현재 브랜치가 main/develop이 아닌지 확인
|
||||||
|
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
|
||||||
|
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
|
||||||
|
|
||||||
|
### 2. 변경 내역 분석
|
||||||
|
```bash
|
||||||
|
git log develop..HEAD --oneline
|
||||||
|
git diff develop..HEAD --stat
|
||||||
|
```
|
||||||
|
- 커밋 목록과 변경된 파일 목록 수집
|
||||||
|
- 주요 변경 사항 요약 작성
|
||||||
|
|
||||||
|
### 3. MR 정보 구성
|
||||||
|
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
|
||||||
|
- `feature/ISSUE-42-user-login` → `feat: ISSUE-42 user-login`
|
||||||
|
- **본문**:
|
||||||
|
```markdown
|
||||||
|
## 변경 사항
|
||||||
|
- (커밋 기반 자동 생성)
|
||||||
|
|
||||||
|
## 관련 이슈
|
||||||
|
- closes #이슈번호 (브랜치명에서 추출)
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- [ ] 빌드 성공 확인
|
||||||
|
- [ ] 기존 테스트 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Gitea API로 MR 생성
|
||||||
|
```bash
|
||||||
|
# Gitea remote URL에서 owner/repo 추출
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
|
||||||
|
# Gitea API 호출
|
||||||
|
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "MR 제목",
|
||||||
|
"body": "MR 본문",
|
||||||
|
"head": "현재브랜치",
|
||||||
|
"base": "타겟브랜치"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 결과 출력
|
||||||
|
- MR URL 출력
|
||||||
|
- 리뷰어 지정 안내
|
||||||
|
- 다음 단계: 리뷰 대기 → 승인 → 머지
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)
|
||||||
49
.claude/skills/fix-issue/SKILL.md
Normal file
49
.claude/skills/fix-issue/SKILL.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: fix-issue
|
||||||
|
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
argument-hint: "<issue-number>"
|
||||||
|
---
|
||||||
|
|
||||||
|
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 이슈 조회
|
||||||
|
```bash
|
||||||
|
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}"
|
||||||
|
```
|
||||||
|
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
|
||||||
|
- 이슈 내용을 사용자에게 요약하여 보여줌
|
||||||
|
|
||||||
|
### 2. 브랜치 생성
|
||||||
|
이슈 라벨에 따라 브랜치 타입 결정:
|
||||||
|
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
|
||||||
|
- 그 외 → `feature/ISSUE-번호-설명`
|
||||||
|
- 긴급 → `hotfix/ISSUE-번호-설명`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b {type}/ISSUE-{number}-{slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 이슈 분석
|
||||||
|
이슈 내용을 바탕으로:
|
||||||
|
- 관련 파일 탐색 (Grep, Glob 활용)
|
||||||
|
- 영향 범위 파악
|
||||||
|
- 수정 방향 제안
|
||||||
|
|
||||||
|
### 4. 수정 계획 제시
|
||||||
|
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
|
||||||
|
- 수정할 파일 목록
|
||||||
|
- 변경 내용 요약
|
||||||
|
- 예상 영향
|
||||||
|
|
||||||
|
### 5. 작업 완료 후
|
||||||
|
- 변경 사항 요약
|
||||||
|
- `/create-mr` 실행 안내
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||||
246
.claude/skills/init-project/SKILL.md
Normal file
246
.claude/skills/init-project/SKILL.md
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
name: init-project
|
||||||
|
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
|
||||||
|
---
|
||||||
|
|
||||||
|
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
|
||||||
|
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
|
||||||
|
|
||||||
|
## 프로젝트 타입 자동 감지
|
||||||
|
|
||||||
|
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
|
||||||
|
1. `pom.xml` 존재 → **java-maven**
|
||||||
|
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
|
||||||
|
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
|
||||||
|
4. 감지 실패 → 사용자에게 타입 선택 요청
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 프로젝트 분석
|
||||||
|
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
||||||
|
- 사용 중인 프레임워크, 라이브러리 감지
|
||||||
|
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
||||||
|
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||||
|
|
||||||
|
### 2. CLAUDE.md 생성
|
||||||
|
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||||
|
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||||
|
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||||
|
- 테스트 실행 명령어
|
||||||
|
- lint 실행 명령어 (감지된 도구 기반)
|
||||||
|
- 프로젝트 디렉토리 구조 요약
|
||||||
|
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. .claude/ 디렉토리 구성
|
||||||
|
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||||
|
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조)
|
||||||
|
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
||||||
|
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
|
||||||
|
|
||||||
|
### 4. Hook 스크립트 생성
|
||||||
|
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
|
||||||
|
|
||||||
|
- `.claude/scripts/on-pre-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-post-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-commit.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Git Hooks 설정
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
`.githooks/` 디렉토리에 실행 권한 부여:
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 프로젝트 타입별 추가 설정
|
||||||
|
|
||||||
|
#### java-maven
|
||||||
|
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||||
|
- `.mvn/settings.xml` Nexus 미러 설정 확인
|
||||||
|
- `mvn compile` 빌드 성공 확인
|
||||||
|
|
||||||
|
#### java-gradle
|
||||||
|
- `.sdkmanrc` 생성
|
||||||
|
- `gradle.properties.example` Nexus 설정 확인
|
||||||
|
- `./gradlew compileJava` 빌드 성공 확인
|
||||||
|
|
||||||
|
#### react-ts
|
||||||
|
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
|
||||||
|
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||||
|
- `npm install && npm run build` 성공 확인
|
||||||
|
|
||||||
|
### 7. .gitignore 확인
|
||||||
|
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||||
|
```
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/CLAUDE.local.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Git exclude 설정
|
||||||
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
|
||||||
|
# Claude Code 워크플로우 (로컬 전용)
|
||||||
|
docs/CHANGELOG.md
|
||||||
|
*.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Memory 초기화
|
||||||
|
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/memory/`) 다음 파일들을 생성:
|
||||||
|
|
||||||
|
- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내)
|
||||||
|
- 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크
|
||||||
|
- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트
|
||||||
|
- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작
|
||||||
|
- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약
|
||||||
|
- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록)
|
||||||
|
- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴)
|
||||||
|
|
||||||
|
### 10. Lint 도구 확인
|
||||||
|
- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안
|
||||||
|
- Java: checkstyle, spotless 등 설정 확인
|
||||||
|
- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인
|
||||||
|
|
||||||
|
### 11. workflow-version.json 생성
|
||||||
|
Gitea API로 최신 팀 워크플로우 버전을 조회:
|
||||||
|
```bash
|
||||||
|
curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
|
```
|
||||||
|
조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용.
|
||||||
|
|
||||||
|
`.claude/workflow-version.json` 파일 생성:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied_global_version": "<조회된 버전>",
|
||||||
|
"applied_date": "<현재날짜>",
|
||||||
|
"project_type": "<감지된타입>",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 검증 및 요약
|
||||||
|
- 생성/수정된 파일 목록 출력
|
||||||
|
- `git config core.hooksPath` 확인
|
||||||
|
- 빌드 명령 실행 가능 확인
|
||||||
|
- Hook 스크립트 실행 권한 확인
|
||||||
|
- 다음 단계 안내:
|
||||||
|
- 개발 시작, 첫 커밋 방법
|
||||||
|
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`
|
||||||
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: sync-team-workflow
|
||||||
|
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
||||||
|
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||||
|
---
|
||||||
|
|
||||||
|
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1. 글로벌 버전 조회
|
||||||
|
Gitea API로 template-common 리포의 workflow-version.json 조회:
|
||||||
|
```bash
|
||||||
|
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||||
|
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 버전 비교
|
||||||
|
로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교:
|
||||||
|
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
||||||
|
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
||||||
|
|
||||||
|
### 3. 프로젝트 타입 감지
|
||||||
|
자동 감지 순서:
|
||||||
|
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||||
|
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 파일 다운로드 및 적용
|
||||||
|
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||||
|
|
||||||
|
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||||
|
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||||
|
```
|
||||||
|
.claude/rules/team-policy.md
|
||||||
|
.claude/rules/git-workflow.md
|
||||||
|
.claude/rules/code-style.md (타입별)
|
||||||
|
.claude/rules/naming.md (타입별)
|
||||||
|
.claude/rules/testing.md (타입별)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-2. settings.json (부분 갱신)
|
||||||
|
- `deny` 목록: 글로벌 최신으로 교체
|
||||||
|
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
||||||
|
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
|
||||||
|
- SessionStart(compact) → on-post-compact.sh
|
||||||
|
- PreCompact → on-pre-compact.sh
|
||||||
|
- PostToolUse(Bash) → on-commit.sh
|
||||||
|
|
||||||
|
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||||
|
```
|
||||||
|
.claude/skills/create-mr/SKILL.md
|
||||||
|
.claude/skills/fix-issue/SKILL.md
|
||||||
|
.claude/skills/sync-team-workflow/SKILL.md
|
||||||
|
.claude/skills/init-project/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-5. Hook 스크립트 갱신
|
||||||
|
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
|
||||||
|
```
|
||||||
|
.claude/scripts/on-pre-compact.sh
|
||||||
|
.claude/scripts/on-post-compact.sh
|
||||||
|
.claude/scripts/on-commit.sh
|
||||||
|
```
|
||||||
|
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
|
||||||
|
|
||||||
|
### 5. 로컬 버전 업데이트
|
||||||
|
`.claude/workflow-version.json` 갱신:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied_global_version": "새버전",
|
||||||
|
"applied_date": "오늘날짜",
|
||||||
|
"project_type": "감지된타입",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 변경 보고
|
||||||
|
- `git diff`로 변경 내역 확인
|
||||||
|
- 업데이트된 파일 목록 출력
|
||||||
|
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
|
||||||
|
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"applied_global_version": "1.2.0",
|
||||||
|
"applied_date": "2026-02-14",
|
||||||
|
"project_type": "java-maven",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
33
.editorconfig
Normal file
33
.editorconfig
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{java,kt}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{sh,bash}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{gradle,groovy}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.xml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
71
.githooks/commit-msg
Executable file
71
.githooks/commit-msg
Executable file
@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# commit-msg hook
|
||||||
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
COMMIT_MSG_FILE="$1"
|
||||||
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
# Merge 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Revert 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conventional Commits 정규식
|
||||||
|
# type(scope): subject
|
||||||
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
|
# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택)
|
||||||
|
# - subject: 1자 이상 (길이는 바이트 기반 별도 검증)
|
||||||
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$'
|
||||||
|
MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte
|
||||||
|
|
||||||
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " 올바른 형식: type(scope): subject"
|
||||||
|
echo ""
|
||||||
|
echo " type (필수):"
|
||||||
|
echo " feat — 새로운 기능"
|
||||||
|
echo " fix — 버그 수정"
|
||||||
|
echo " docs — 문서 변경"
|
||||||
|
echo " style — 코드 포맷팅"
|
||||||
|
echo " refactor — 리팩토링"
|
||||||
|
echo " test — 테스트"
|
||||||
|
echo " chore — 빌드/설정 변경"
|
||||||
|
echo " ci — CI/CD 변경"
|
||||||
|
echo " perf — 성능 개선"
|
||||||
|
echo ""
|
||||||
|
echo " scope (선택): 한/영 모두 가능"
|
||||||
|
echo " subject (필수): 1~72자, 한/영 모두 가능"
|
||||||
|
echo ""
|
||||||
|
echo " 예시:"
|
||||||
|
echo " feat(auth): JWT 기반 로그인 구현"
|
||||||
|
echo " fix(배치): 야간 배치 타임아웃 수정"
|
||||||
|
echo " docs: README 업데이트"
|
||||||
|
echo " chore: Gradle 의존성 업데이트"
|
||||||
|
echo ""
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 길이 검증 (바이트 기반 — UTF-8 한글 허용)
|
||||||
|
MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ')
|
||||||
|
if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})"
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
25
.githooks/post-checkout
Executable file
25
.githooks/post-checkout
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# post-checkout hook
|
||||||
|
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
|
||||||
|
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
|
||||||
|
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
|
||||||
|
BRANCH_FLAG="$3"
|
||||||
|
|
||||||
|
# 파일 체크아웃은 건너뜀
|
||||||
|
if [ "$BRANCH_FLAG" = "0" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .githooks 디렉토리 존재 확인
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
if [ -d "${REPO_ROOT}/.githooks" ]; then
|
||||||
|
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
33
.githooks/pre-commit
Executable file
33
.githooks/pre-commit
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# pre-commit hook (Java Maven)
|
||||||
|
# Maven 컴파일 검증 — 컴파일 실패 시 커밋 차단
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
echo "pre-commit: Maven 컴파일 검증 중..."
|
||||||
|
|
||||||
|
# Maven Wrapper 사용 (없으면 mvn 사용)
|
||||||
|
if [ -f "./mvnw" ]; then
|
||||||
|
MVN="./mvnw"
|
||||||
|
elif command -v mvn &>/dev/null; then
|
||||||
|
MVN="mvn"
|
||||||
|
else
|
||||||
|
echo "경고: Maven이 설치되지 않았습니다. 컴파일 검증을 건너뜁니다."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 컴파일 검증 (테스트 제외, 프론트엔드 빌드 제외)
|
||||||
|
$MVN compile -q -DskipTests -Dskip.npm -Dskip.installnodenpm 2>&1
|
||||||
|
RESULT=$?
|
||||||
|
|
||||||
|
if [ $RESULT -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 컴파일 실패! 커밋이 차단되었습니다. ║"
|
||||||
|
echo "║ 컴파일 오류를 수정한 후 다시 커밋해주세요. ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-commit: 컴파일 성공"
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -93,13 +93,15 @@ application-local.yml
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
docs/
|
|
||||||
*.log.*
|
*.log.*
|
||||||
|
|
||||||
# Session continuity files (for AI assistants)
|
# Frontend (Vite + React)
|
||||||
.claude/
|
frontend/node_modules/
|
||||||
CLAUDE.md
|
frontend/node/
|
||||||
BASEREADER_ENHANCEMENT_PLAN.md
|
src/main/resources/static/assets/
|
||||||
README.md
|
src/main/resources/static/index.html
|
||||||
|
src/main/resources/static/vite.svg
|
||||||
|
|
||||||
nul
|
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/scripts/
|
||||||
|
|||||||
22
.mvn/settings.xml
Normal file
22
.mvn/settings.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
|
||||||
|
https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus</id>
|
||||||
|
<username>admin</username>
|
||||||
|
<password>Gcsc!8932</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus</id>
|
||||||
|
<name>GC Nexus Repository</name>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
</settings>
|
||||||
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# SNP-Batch-1 (snp-batch-validation)
|
||||||
|
|
||||||
|
해양 데이터 통합 배치 시스템. 외부 Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
|
||||||
|
- PostgreSQL (스키마: t_std_snp_data)
|
||||||
|
- Quartz Scheduler (JDBC Store)
|
||||||
|
- Spring Kafka (AIS Target → Kafka 파이프라인)
|
||||||
|
- WebFlux WebClient (외부 API 호출)
|
||||||
|
- Thymeleaf (배치 관리 Web GUI)
|
||||||
|
- Springdoc OpenAPI 2.3.0 (Swagger)
|
||||||
|
- Caffeine Cache, JTS (공간 연산)
|
||||||
|
- Lombok, Jackson
|
||||||
|
|
||||||
|
## 빌드 & 실행
|
||||||
|
```bash
|
||||||
|
# 빌드
|
||||||
|
sdk use java 17.0.18-amzn
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# 실행
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# 테스트
|
||||||
|
mvn test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 서버 설정
|
||||||
|
- 포트: 8041
|
||||||
|
- Context Path: /snp-api
|
||||||
|
- Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
```
|
||||||
|
src/main/java/com/snp/batch/
|
||||||
|
├── SnpBatchApplication.java # 메인 애플리케이션
|
||||||
|
├── common/ # 공통 프레임워크
|
||||||
|
│ ├── batch/ # 배치 베이스 클래스 (config, entity, processor, reader, writer)
|
||||||
|
│ ├── util/ # 유틸 (JsonChangeDetector, SafeGetDataUtil)
|
||||||
|
│ └── web/ # Web 베이스 (ApiResponse, BaseController, BaseService)
|
||||||
|
├── global/ # 글로벌 설정 & 배치 관리
|
||||||
|
│ ├── config/ # AsyncConfig, QuartzConfig, SwaggerConfig, WebClientConfig
|
||||||
|
│ ├── controller/ # BatchController (/api/batch), WebViewController
|
||||||
|
│ ├── dto/ # Dashboard, JobExecution, Schedule DTO
|
||||||
|
│ ├── model/ # BatchLastExecution, JobScheduleEntity
|
||||||
|
│ ├── partition/ # 파티션 관리 (PartitionManagerTasklet)
|
||||||
|
│ ├── projection/ # DateRangeProjection
|
||||||
|
│ └── repository/ # BatchApiLog, BatchLastExecution, JobSchedule, Timeline
|
||||||
|
├── jobs/ # 배치 Job 모듈 (도메인별)
|
||||||
|
│ ├── aistarget/ # AIS Target (실시간 위치 + 캐시 + REST API + Kafka 발행)
|
||||||
|
│ ├── aistargetdbsync/ # AIS Target DB Sync (캐시→DB)
|
||||||
|
│ ├── common/ # 공통코드 (FlagCode, Stat5Code)
|
||||||
|
│ ├── compliance/ # 규정준수 (Compliance, CompanyCompliance)
|
||||||
|
│ ├── event/ # 해양사건 (Event, EventDetail, Cargo, HumanCasualty)
|
||||||
|
│ ├── movements/ # 선박이동 (다수 하위 Job)
|
||||||
|
│ ├── psc/ # PSC 검사
|
||||||
|
│ ├── risk/ # 리스크 분석
|
||||||
|
│ └── ship*/ # 선박정보 (ship001~ship028, 30+ 테이블)
|
||||||
|
└── service/ # BatchService, ScheduleService
|
||||||
|
```
|
||||||
|
|
||||||
|
## 배치 Job 패턴
|
||||||
|
각 Job은 `common/batch/` 베이스 클래스를 상속:
|
||||||
|
- **BaseJobConfig** → Job/Step 설정 (chunk-oriented)
|
||||||
|
- **BaseApiReader** → 외부 Maritime API 호출 (WebClient)
|
||||||
|
- **BaseProcessor** → DTO→Entity 변환
|
||||||
|
- **BaseWriter** → PostgreSQL Upsert
|
||||||
|
- **BaseEntity** → 공통 필드 (dataHash, lastModifiedDate 등)
|
||||||
|
|
||||||
|
## 주요 API 경로 (context-path: /snp-api)
|
||||||
|
|
||||||
|
### Batch Management (/api/batch)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | /jobs/{jobName}/execute | 배치 작업 실행 |
|
||||||
|
| GET | /jobs | 작업 목록 |
|
||||||
|
| GET | /jobs/{jobName}/executions | 실행 이력 |
|
||||||
|
| GET | /executions/{id}/detail | 실행 상세 (Step 포함) |
|
||||||
|
| POST | /executions/{id}/stop | 실행 중지 |
|
||||||
|
| GET/POST | /schedules | 스케줄 관리 (CRUD) |
|
||||||
|
| GET | /dashboard | 대시보드 |
|
||||||
|
| GET | /timeline | 타임라인 |
|
||||||
|
|
||||||
|
### AIS Target (/api/ais-target)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /{mmsi} | MMSI로 최신 위치 |
|
||||||
|
| POST | /batch | 다건 MMSI 조회 |
|
||||||
|
| GET/POST | /search | 시간/공간 범위 검색 |
|
||||||
|
| POST | /search/filter | 조건 필터 검색 (SOG, COG 등) |
|
||||||
|
| POST | /search/polygon | 폴리곤 범위 검색 |
|
||||||
|
| POST | /search/wkt | WKT 형식 검색 |
|
||||||
|
| GET | /search/with-distance | 거리 포함 원형 검색 |
|
||||||
|
| GET | /{mmsi}/track | 항적 조회 |
|
||||||
|
| GET | /cache/stats | 캐시 통계 |
|
||||||
|
| DELETE | /cache | 캐시 초기화 |
|
||||||
|
|
||||||
|
## Lint/Format
|
||||||
|
- 별도 lint 도구 미설정 (checkstyle, spotless 없음)
|
||||||
|
- IDE 기본 포매터 사용
|
||||||
1602
DEVELOPMENT_GUIDE.md
1602
DEVELOPMENT_GUIDE.md
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# SNP-Batch (snp-batch-validation)
|
||||||
|
|
||||||
|
해양 데이터 통합 배치 시스템. Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스합니다.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
|
||||||
|
- PostgreSQL, Quartz Scheduler, Caffeine Cache
|
||||||
|
- React 19 + Vite + Tailwind CSS 4 (관리 UI)
|
||||||
|
- frontend-maven-plugin (프론트엔드 빌드 통합)
|
||||||
|
|
||||||
|
## 사전 요구사항
|
||||||
|
|
||||||
|
| 항목 | 버전 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| JDK | 17 | `.sdkmanrc` 참조 (`sdk env`) |
|
||||||
|
| Maven | 3.9+ | |
|
||||||
|
| Node.js | 20+ | 프론트엔드 빌드용 |
|
||||||
|
| npm | 10+ | Node.js에 포함 |
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
> **주의**: frontend-maven-plugin의 Node 호환성 문제로, 프론트엔드와 백엔드를 분리하여 빌드합니다.
|
||||||
|
|
||||||
|
### 터미널
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 프론트엔드 빌드
|
||||||
|
cd frontend && npm install && npm run build && cd ..
|
||||||
|
|
||||||
|
# 2. Maven 패키징 (프론트엔드 빌드 스킵)
|
||||||
|
mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 결과: `target/snp-batch-validation-1.0.0.jar`
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
`Cmd+Shift+B` (기본 빌드 태스크) → 프론트엔드 빌드 + Maven 패키징 순차 실행
|
||||||
|
|
||||||
|
개별 태스크: `Cmd+Shift+P` → "Tasks: Run Task" → 태스크 선택
|
||||||
|
|
||||||
|
> 태스크 설정: [.vscode/tasks.json](.vscode/tasks.json)
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
1. **프론트엔드 빌드**: Terminal 탭에서 `cd frontend && npm run build`
|
||||||
|
2. **Maven 패키징**: Maven 패널 → Lifecycle → `package`
|
||||||
|
- VM Options: `-DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||||
|
- 또는 Run Configuration → Maven → Command line에 `clean package -DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||||
|
|
||||||
|
## 로컬 실행
|
||||||
|
|
||||||
|
### 터미널
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
||||||
|
```
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
Run/Debug 패널(F5) → "SNP-Batch (local)" 선택
|
||||||
|
|
||||||
|
> 실행 설정: [.vscode/launch.json](.vscode/launch.json)
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
Run Configuration → Spring Boot:
|
||||||
|
- Main class: `com.snp.batch.SnpBatchApplication`
|
||||||
|
- Active profiles: `local`
|
||||||
|
|
||||||
|
## 서버 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 빌드 (위 빌드 절차 수행)
|
||||||
|
|
||||||
|
# 2. JAR 전송
|
||||||
|
scp target/snp-batch-validation-1.0.0.jar {서버}:{경로}/
|
||||||
|
|
||||||
|
# 3. 실행
|
||||||
|
java -jar snp-batch-validation-1.0.0.jar --spring.profiles.active=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 접속 정보
|
||||||
|
|
||||||
|
| 항목 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| 관리 UI | `http://localhost:8041/snp-api/` |
|
||||||
|
| Swagger | `http://localhost:8041/snp-api/swagger-ui/index.html` |
|
||||||
|
|
||||||
|
## 프로파일
|
||||||
|
|
||||||
|
| 프로파일 | 용도 | DB |
|
||||||
|
|----------|------|----|
|
||||||
|
| `local` | 로컬 개발 | 개발 DB |
|
||||||
|
| `dev` | 개발 서버 | 개발 DB |
|
||||||
|
| `prod` | 운영 서버 | 운영 DB |
|
||||||
|
|
||||||
|
## Maven 빌드 플래그 요약
|
||||||
|
|
||||||
|
| 플래그 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| `-DskipTests` | 테스트 스킵 |
|
||||||
|
| `-Dskip.npm` | npm install/build 스킵 |
|
||||||
|
| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 |
|
||||||
517
SWAGGER_GUIDE.md
517
SWAGGER_GUIDE.md
@ -1,517 +0,0 @@
|
|||||||
# Swagger API 문서화 가이드
|
|
||||||
|
|
||||||
**작성일**: 2025-10-16
|
|
||||||
**버전**: 1.0.0
|
|
||||||
**프로젝트**: SNP Batch - Spring Batch 기반 데이터 통합 시스템
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Swagger 설정 완료 사항
|
|
||||||
|
|
||||||
### ✅ 수정 완료 파일
|
|
||||||
1. **BaseController.java** - 공통 CRUD Controller 추상 클래스
|
|
||||||
- Java import alias 오류 수정 (`as SwaggerApiResponse` 제거)
|
|
||||||
- `@Operation` 어노테이션 내 `responses` 속성으로 통합
|
|
||||||
- 전체 경로로 어노테이션 사용: `@io.swagger.v3.oas.annotations.responses.ApiResponse`
|
|
||||||
|
|
||||||
2. **ProductWebController.java** - 샘플 제품 API Controller
|
|
||||||
- Java import alias 오류 수정
|
|
||||||
- 커스텀 엔드포인트 Swagger 어노테이션 수정
|
|
||||||
|
|
||||||
3. **SwaggerConfig.java** - Swagger/OpenAPI 3.0 설정
|
|
||||||
- 서버 포트 동적 설정 (`@Value("${server.port:8081}")`)
|
|
||||||
- 상세한 API 문서 설명 추가
|
|
||||||
- Markdown 형식 설명 추가
|
|
||||||
|
|
||||||
4. **BatchController.java** - 배치 관리 API (이미 올바르게 구현됨)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Swagger UI 접속 정보
|
|
||||||
|
|
||||||
### 접속 URL
|
|
||||||
```
|
|
||||||
Swagger UI: http://localhost:8081/swagger-ui/index.html
|
|
||||||
API 문서 (JSON): http://localhost:8081/v3/api-docs
|
|
||||||
API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 제공되는 API 그룹
|
|
||||||
|
|
||||||
> **참고**: BaseController는 추상 클래스이므로 별도의 API 그룹으로 표시되지 않습니다.
|
|
||||||
> 상속받는 Controller(예: ProductWebController)의 `@Tag`로 모든 CRUD 엔드포인트가 그룹화됩니다.
|
|
||||||
|
|
||||||
#### 1. **Batch Management API** (`/api/batch`)
|
|
||||||
배치 작업 실행 및 스케줄 관리
|
|
||||||
|
|
||||||
**엔드포인트**:
|
|
||||||
- `POST /api/batch/jobs/{jobName}/execute` - 배치 작업 실행
|
|
||||||
- `GET /api/batch/jobs` - 배치 작업 목록 조회
|
|
||||||
- `GET /api/batch/jobs/{jobName}/executions` - 실행 이력 조회
|
|
||||||
- `POST /api/batch/executions/{executionId}/stop` - 실행 중지
|
|
||||||
- `GET /api/batch/schedules` - 스케줄 목록 조회
|
|
||||||
- `POST /api/batch/schedules` - 스케줄 생성
|
|
||||||
- `PUT /api/batch/schedules/{jobName}` - 스케줄 수정
|
|
||||||
- `DELETE /api/batch/schedules/{jobName}` - 스케줄 삭제
|
|
||||||
- `PATCH /api/batch/schedules/{jobName}/toggle` - 스케줄 활성화/비활성화
|
|
||||||
- `GET /api/batch/dashboard` - 대시보드 데이터
|
|
||||||
- `GET /api/batch/timeline` - 타임라인 데이터
|
|
||||||
|
|
||||||
#### 2. **Product API** (`/api/products`)
|
|
||||||
샘플 제품 데이터 CRUD (BaseController 상속)
|
|
||||||
|
|
||||||
**모든 엔드포인트가 "Product API" 그룹으로 통합 표시됩니다.**
|
|
||||||
|
|
||||||
**공통 CRUD 엔드포인트** (BaseController에서 상속):
|
|
||||||
- `POST /api/products` - 제품 생성
|
|
||||||
- `GET /api/products/{id}` - 제품 조회 (ID)
|
|
||||||
- `GET /api/products` - 전체 제품 조회
|
|
||||||
- `GET /api/products/page?offset=0&limit=20` - 페이징 조회
|
|
||||||
- `PUT /api/products/{id}` - 제품 수정
|
|
||||||
- `DELETE /api/products/{id}` - 제품 삭제
|
|
||||||
- `GET /api/products/{id}/exists` - 존재 여부 확인
|
|
||||||
|
|
||||||
**커스텀 엔드포인트**:
|
|
||||||
- `GET /api/products/by-product-id/{productId}` - 제품 코드로 조회
|
|
||||||
- `GET /api/products/stats/active-count` - 활성 제품 개수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 애플리케이션 실행 및 테스트
|
|
||||||
|
|
||||||
### 1. 애플리케이션 빌드 및 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Maven 빌드 (IntelliJ IDEA에서)
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
|
|
||||||
# 애플리케이션 실행
|
|
||||||
mvn spring-boot:run
|
|
||||||
```
|
|
||||||
|
|
||||||
또는 IntelliJ IDEA에서:
|
|
||||||
1. `SnpBatchApplication.java` 파일 열기
|
|
||||||
2. 메인 메서드 왼쪽의 ▶ 아이콘 클릭
|
|
||||||
3. "Run 'SnpBatchApplication'" 선택
|
|
||||||
|
|
||||||
### 2. Swagger UI 접속
|
|
||||||
|
|
||||||
브라우저에서 다음 URL 접속:
|
|
||||||
```
|
|
||||||
http://localhost:8081/swagger-ui/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. API 테스트 예시
|
|
||||||
|
|
||||||
#### 예시 1: 배치 작업 목록 조회
|
|
||||||
```http
|
|
||||||
GET http://localhost:8081/api/batch/jobs
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
"sampleProductImportJob",
|
|
||||||
"shipDataImportJob"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시 2: 배치 작업 실행
|
|
||||||
```http
|
|
||||||
POST http://localhost:8081/api/batch/jobs/sampleProductImportJob/execute
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Job started successfully",
|
|
||||||
"executionId": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시 3: 제품 생성 (샘플)
|
|
||||||
```http
|
|
||||||
POST http://localhost:8081/api/products
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"productId": "TEST-001",
|
|
||||||
"productName": "테스트 제품",
|
|
||||||
"category": "Electronics",
|
|
||||||
"price": 99.99,
|
|
||||||
"stockQuantity": 50,
|
|
||||||
"isActive": true,
|
|
||||||
"rating": 4.5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Product created successfully",
|
|
||||||
"data": {
|
|
||||||
"id": 1,
|
|
||||||
"productId": "TEST-001",
|
|
||||||
"productName": "테스트 제품",
|
|
||||||
"category": "Electronics",
|
|
||||||
"price": 99.99,
|
|
||||||
"stockQuantity": 50,
|
|
||||||
"isActive": true,
|
|
||||||
"rating": 4.5,
|
|
||||||
"createdAt": "2025-10-16T10:30:00",
|
|
||||||
"updatedAt": "2025-10-16T10:30:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 예시 4: 페이징 조회
|
|
||||||
```http
|
|
||||||
GET http://localhost:8081/api/products/page?offset=0&limit=10
|
|
||||||
```
|
|
||||||
|
|
||||||
**예상 응답**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Retrieved 10 items (total: 100)",
|
|
||||||
"data": [
|
|
||||||
{ "id": 1, "productName": "Product 1", ... },
|
|
||||||
{ "id": 2, "productName": "Product 2", ... },
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Swagger 어노테이션 가이드
|
|
||||||
|
|
||||||
### BaseController에서 사용된 패턴
|
|
||||||
|
|
||||||
#### ❌ 잘못된 사용법 (Java에서는 불가능)
|
|
||||||
```java
|
|
||||||
// Kotlin의 import alias는 Java에서 지원되지 않음
|
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse;
|
|
||||||
|
|
||||||
@ApiResponses(value = {
|
|
||||||
@SwaggerApiResponse(responseCode = "200", description = "성공")
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ✅ 올바른 사용법 (수정 완료)
|
|
||||||
```java
|
|
||||||
// import alias 제거
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 생성",
|
|
||||||
description = "새로운 리소스를 생성합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "생성 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PostMapping
|
|
||||||
public ResponseEntity<ApiResponse<D>> create(
|
|
||||||
@Parameter(description = "생성할 리소스 데이터", required = true)
|
|
||||||
@RequestBody D dto) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 주요 어노테이션 설명
|
|
||||||
|
|
||||||
#### 1. `@Tag` - API 그룹화
|
|
||||||
```java
|
|
||||||
@Tag(name = "Product API", description = "제품 관리 API")
|
|
||||||
public class ProductWebController extends BaseController<ProductWebDto, Long> {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. `@Operation` - 엔드포인트 문서화
|
|
||||||
```java
|
|
||||||
@Operation(
|
|
||||||
summary = "짧은 설명 (목록에 표시)",
|
|
||||||
description = "상세 설명 (확장 시 표시)",
|
|
||||||
responses = { /* 응답 정의 */ }
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. `@Parameter` - 파라미터 설명
|
|
||||||
```java
|
|
||||||
@Parameter(
|
|
||||||
description = "파라미터 설명",
|
|
||||||
required = true,
|
|
||||||
example = "예시 값"
|
|
||||||
)
|
|
||||||
@PathVariable String id
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. `@io.swagger.v3.oas.annotations.responses.ApiResponse` - 응답 정의
|
|
||||||
```java
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "성공 메시지",
|
|
||||||
content = @Content(
|
|
||||||
mediaType = "application/json",
|
|
||||||
schema = @Schema(implementation = ProductDto.class)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 신규 Controller 개발 시 Swagger 적용 가이드
|
|
||||||
|
|
||||||
### 1. BaseController를 상속하는 경우
|
|
||||||
|
|
||||||
```java
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/myresource")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "My Resource API", description = "나의 리소스 관리 API")
|
|
||||||
public class MyResourceController extends BaseController<MyResourceDto, Long> {
|
|
||||||
|
|
||||||
private final MyResourceService myResourceService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected BaseService<?, MyResourceDto, Long> getService() {
|
|
||||||
return myResourceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected String getResourceName() {
|
|
||||||
return "MyResource";
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseController가 제공하는 CRUD 엔드포인트 자동 생성:
|
|
||||||
// POST /api/myresource
|
|
||||||
// GET /api/myresource/{id}
|
|
||||||
// GET /api/myresource
|
|
||||||
// GET /api/myresource/page
|
|
||||||
// PUT /api/myresource/{id}
|
|
||||||
// DELETE /api/myresource/{id}
|
|
||||||
// GET /api/myresource/{id}/exists
|
|
||||||
|
|
||||||
// 커스텀 엔드포인트 추가 시:
|
|
||||||
@Operation(
|
|
||||||
summary = "커스텀 조회",
|
|
||||||
description = "특정 조건으로 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/custom/{key}")
|
|
||||||
public ResponseEntity<ApiResponse<MyResourceDto>> customEndpoint(
|
|
||||||
@Parameter(description = "커스텀 키", required = true)
|
|
||||||
@PathVariable String key) {
|
|
||||||
// 구현...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 독립적인 Controller를 작성하는 경우
|
|
||||||
|
|
||||||
```java
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/custom")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
@Tag(name = "Custom API", description = "커스텀 API")
|
|
||||||
public class CustomController {
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "커스텀 작업",
|
|
||||||
description = "특정 작업을 수행합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "작업 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PostMapping("/action")
|
|
||||||
public ResponseEntity<Map<String, Object>> customAction(
|
|
||||||
@Parameter(description = "액션 파라미터", required = true)
|
|
||||||
@RequestBody Map<String, String> params) {
|
|
||||||
// 구현...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Swagger UI 화면 구성
|
|
||||||
|
|
||||||
### 메인 화면
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ SNP Batch REST API │
|
|
||||||
│ Version: v1.0.0 │
|
|
||||||
│ Spring Batch 기반 데이터 통합 시스템 REST API │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ Servers: │
|
|
||||||
│ ▼ http://localhost:8081 (로컬 개발 서버) │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ▼ Batch Management API │
|
|
||||||
│ POST /api/batch/jobs/{jobName}/execute │
|
|
||||||
│ GET /api/batch/jobs │
|
|
||||||
│ ... │
|
|
||||||
│ │
|
|
||||||
│ ▼ Product API (9개 엔드포인트 통합 표시) │
|
|
||||||
│ POST /api/products │
|
|
||||||
│ GET /api/products/{id} │
|
|
||||||
│ GET /api/products │
|
|
||||||
│ GET /api/products/page │
|
|
||||||
│ PUT /api/products/{id} │
|
|
||||||
│ DELETE /api/products/{id} │
|
|
||||||
│ GET /api/products/{id}/exists │
|
|
||||||
│ GET /api/products/by-product-id/{...} │
|
|
||||||
│ GET /api/products/stats/active-count │
|
|
||||||
│ │
|
|
||||||
│ (Base API 그룹은 표시되지 않음) │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 실행 화면 예시
|
|
||||||
각 엔드포인트 클릭 시:
|
|
||||||
- **Parameters**: 파라미터 입력 필드
|
|
||||||
- **Request body**: JSON 요청 본문 에디터
|
|
||||||
- **Try it out**: 실제 API 호출 버튼
|
|
||||||
- **Responses**: 응답 코드 및 예시
|
|
||||||
- **Curl**: curl 명령어 생성
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 문제 해결
|
|
||||||
|
|
||||||
### 1. Swagger UI 접속 불가
|
|
||||||
**증상**: `http://localhost:8081/swagger-ui/index.html` 접속 시 404 오류
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
1. 애플리케이션이 실행 중인지 확인
|
|
||||||
2. 포트 번호 확인 (`application.yml`의 `server.port`)
|
|
||||||
3. 다음 URL 시도:
|
|
||||||
- `http://localhost:8081/swagger-ui.html`
|
|
||||||
- `http://localhost:8081/swagger-ui/`
|
|
||||||
|
|
||||||
### 2. API 실행 시 401/403 오류
|
|
||||||
**증상**: "Try it out" 클릭 시 인증 오류
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
- 현재 인증이 설정되지 않음 (기본 허용)
|
|
||||||
- Spring Security 추가 시 Swagger 경로 허용 필요:
|
|
||||||
```java
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 특정 엔드포인트가 보이지 않음
|
|
||||||
**증상**: Controller는 작성했지만 Swagger UI에 표시되지 않음
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
1. `@RestController` 어노테이션 확인
|
|
||||||
2. `@RequestMapping` 경로 확인
|
|
||||||
3. Controller가 `com.snp.batch` 패키지 하위에 있는지 확인
|
|
||||||
4. 애플리케이션 재시작
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 설정 파일
|
|
||||||
|
|
||||||
### application.yml (Swagger 관련 설정)
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
port: 8081 # Swagger UI 접속 포트
|
|
||||||
|
|
||||||
# Springdoc OpenAPI 설정 (필요 시 추가)
|
|
||||||
springdoc:
|
|
||||||
api-docs:
|
|
||||||
path: /v3/api-docs # OpenAPI JSON 경로
|
|
||||||
swagger-ui:
|
|
||||||
path: /swagger-ui.html # Swagger UI 경로
|
|
||||||
enabled: true
|
|
||||||
operations-sorter: alpha # 엔드포인트 정렬 (alpha, method)
|
|
||||||
tags-sorter: alpha # 태그 정렬
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 추가 학습 자료
|
|
||||||
|
|
||||||
### Swagger 어노테이션 공식 문서
|
|
||||||
- [OpenAPI 3.0 Annotations](https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations)
|
|
||||||
- [Springdoc OpenAPI](https://springdoc.org/)
|
|
||||||
|
|
||||||
### 관련 파일 위치
|
|
||||||
```
|
|
||||||
src/main/java/com/snp/batch/
|
|
||||||
├── common/web/controller/BaseController.java # 공통 CRUD Base
|
|
||||||
├── global/config/SwaggerConfig.java # Swagger 설정
|
|
||||||
├── global/controller/BatchController.java # Batch API
|
|
||||||
└── jobs/sample/web/controller/ProductWebController.java # Product API
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 체크리스트
|
|
||||||
|
|
||||||
애플리케이션 실행 전 확인:
|
|
||||||
- [ ] Maven 빌드 성공
|
|
||||||
- [ ] `application.yml` 설정 확인
|
|
||||||
- [ ] PostgreSQL 데이터베이스 연결 확인
|
|
||||||
- [ ] 포트 8081 사용 가능 여부 확인
|
|
||||||
|
|
||||||
Swagger 테스트 확인:
|
|
||||||
- [ ] Swagger UI 접속 성공
|
|
||||||
- [ ] Batch Management API 표시 확인
|
|
||||||
- [ ] Product API 표시 확인
|
|
||||||
- [ ] "Try it out" 기능 동작 확인
|
|
||||||
- [ ] API 응답 정상 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 관련 문서
|
|
||||||
|
|
||||||
### 핵심 문서
|
|
||||||
- **[README.md](README.md)** - 프로젝트 개요 및 빠른 시작 가이드
|
|
||||||
- **[DEVELOPMENT_GUIDE.md](DEVELOPMENT_GUIDE.md)** - 신규 Job 개발 가이드 및 Base 클래스 사용법
|
|
||||||
- **[CLAUDE.md](CLAUDE.md)** - 프로젝트 형상관리 문서 (세션 연속성)
|
|
||||||
|
|
||||||
### 아키텍처 문서
|
|
||||||
- **[docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md)** - 프로젝트 아키텍처 상세 설계
|
|
||||||
- **[docs/architecture/PROJECT_STRUCTURE.md](docs/architecture/PROJECT_STRUCTURE.md)** - Job 중심 패키지 구조 가이드
|
|
||||||
|
|
||||||
### 구현 가이드
|
|
||||||
- **[docs/guides/PROXY_SERVICE_GUIDE.md](docs/guides/PROXY_SERVICE_GUIDE.md)** - 외부 API 프록시 패턴 구현 가이드
|
|
||||||
- **[docs/guides/SHIP_API_EXAMPLE.md](docs/guides/SHIP_API_EXAMPLE.md)** - Maritime API 연동 실전 예제
|
|
||||||
|
|
||||||
### 보안 문서
|
|
||||||
- **[docs/security/README.md](docs/security/README.md)** - 보안 전략 개요 (계획 단계)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**최종 업데이트**: 2025-10-16
|
|
||||||
**작성자**: Claude Code
|
|
||||||
**버전**: 1.1.0
|
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3935
frontend/package-lock.json
generated
Normal file
3935
frontend/package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
frontend/src/App.tsx
Normal file
49
frontend/src/App.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { ToastProvider, useToastContext } from './contexts/ToastContext';
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import ToastContainer from './components/Toast';
|
||||||
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
|
const Jobs = lazy(() => import('./pages/Jobs'));
|
||||||
|
const Executions = lazy(() => import('./pages/Executions'));
|
||||||
|
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail'));
|
||||||
|
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||||
|
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const { toasts, removeToast } = useToastContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-wing-bg text-wing-text">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<Navbar />
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
|
<Route path="/executions" element={<Executions />} />
|
||||||
|
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
||||||
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
|
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter basename="/snp-api">
|
||||||
|
<ToastProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
frontend/src/api/batchApi.ts
Normal file
308
frontend/src/api/batchApi.ts
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
const BASE = import.meta.env.DEV ? '/snp-api/api/batch' : '/snp-api/api/batch';
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalSchedules: number;
|
||||||
|
activeSchedules: number;
|
||||||
|
inactiveSchedules: number;
|
||||||
|
totalJobs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunningJob {
|
||||||
|
jobName: string;
|
||||||
|
executionId: number;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentExecution {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentFailure {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
exitMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailureStats {
|
||||||
|
last24h: number;
|
||||||
|
last7d: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
stats: DashboardStats;
|
||||||
|
runningJobs: RunningJob[];
|
||||||
|
recentExecutions: RecentExecution[];
|
||||||
|
recentFailures: RecentFailure[];
|
||||||
|
staleExecutionCount: number;
|
||||||
|
failureStats: FailureStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job Execution ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface JobExecutionDto {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
exitCode: string | null;
|
||||||
|
exitMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiCallInfo {
|
||||||
|
apiUrl: string;
|
||||||
|
method: string;
|
||||||
|
parameters: Record<string, unknown> | null;
|
||||||
|
totalCalls: number;
|
||||||
|
completedCalls: number;
|
||||||
|
lastCallTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepExecutionDto {
|
||||||
|
stepExecutionId: number;
|
||||||
|
stepName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
readCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
commitCount: number;
|
||||||
|
rollbackCount: number;
|
||||||
|
readSkipCount: number;
|
||||||
|
processSkipCount: number;
|
||||||
|
writeSkipCount: number;
|
||||||
|
filterCount: number;
|
||||||
|
exitCode: string;
|
||||||
|
exitMessage: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
apiCallInfo: ApiCallInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobExecutionDetailDto {
|
||||||
|
executionId: number;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
exitCode: string;
|
||||||
|
exitMessage: string | null;
|
||||||
|
jobParameters: Record<string, string>;
|
||||||
|
jobInstanceId: number;
|
||||||
|
duration: number | null;
|
||||||
|
readCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
filterCount: number;
|
||||||
|
stepExecutions: StepExecutionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScheduleResponse {
|
||||||
|
id: number;
|
||||||
|
jobName: string;
|
||||||
|
cronExpression: string;
|
||||||
|
description: string | null;
|
||||||
|
active: boolean;
|
||||||
|
nextFireTime: string | null;
|
||||||
|
previousFireTime: string | null;
|
||||||
|
triggerState: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleRequest {
|
||||||
|
jobName: string;
|
||||||
|
cronExpression: string;
|
||||||
|
description?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeline ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PeriodInfo {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionInfo {
|
||||||
|
executionId: number | null;
|
||||||
|
status: string;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleTimeline {
|
||||||
|
jobName: string;
|
||||||
|
executions: Record<string, ExecutionInfo | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineResponse {
|
||||||
|
periodLabel: string;
|
||||||
|
periods: PeriodInfo[];
|
||||||
|
schedules: ScheduleTimeline[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: Execution Search ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExecutionSearchResponse {
|
||||||
|
executions: JobExecutionDto[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job Detail ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LastExecution {
|
||||||
|
executionId: number;
|
||||||
|
status: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobDetailDto {
|
||||||
|
jobName: string;
|
||||||
|
lastExecution: LastExecution | null;
|
||||||
|
scheduleCron: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: Statistics ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DailyStat {
|
||||||
|
date: string;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
otherCount: number;
|
||||||
|
avgDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionStatisticsDto {
|
||||||
|
dailyStats: DailyStat[];
|
||||||
|
totalExecutions: number;
|
||||||
|
totalSuccess: number;
|
||||||
|
totalFailed: number;
|
||||||
|
avgDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Functions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const batchApi = {
|
||||||
|
getDashboard: () =>
|
||||||
|
fetchJson<DashboardResponse>(`${BASE}/dashboard`),
|
||||||
|
|
||||||
|
getJobs: () =>
|
||||||
|
fetchJson<string[]>(`${BASE}/jobs`),
|
||||||
|
|
||||||
|
getJobsDetail: () =>
|
||||||
|
fetchJson<JobDetailDto[]>(`${BASE}/jobs/detail`),
|
||||||
|
|
||||||
|
executeJob: (jobName: string, params?: Record<string, string>) =>
|
||||||
|
postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||||
|
`${BASE}/jobs/${jobName}/execute`, params),
|
||||||
|
|
||||||
|
getJobExecutions: (jobName: string) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(`${BASE}/jobs/${jobName}/executions`),
|
||||||
|
|
||||||
|
getRecentExecutions: (limit = 50) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(`${BASE}/executions/recent?limit=${limit}`),
|
||||||
|
|
||||||
|
getExecutionDetail: (id: number) =>
|
||||||
|
fetchJson<JobExecutionDetailDto>(`${BASE}/executions/${id}/detail`),
|
||||||
|
|
||||||
|
stopExecution: (id: number) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/stop`),
|
||||||
|
|
||||||
|
// F1: Abandon
|
||||||
|
getStaleExecutions: (thresholdMinutes = 60) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(`${BASE}/executions/stale?thresholdMinutes=${thresholdMinutes}`),
|
||||||
|
|
||||||
|
abandonExecution: (id: number) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/abandon`),
|
||||||
|
|
||||||
|
abandonAllStale: (thresholdMinutes = 60) =>
|
||||||
|
postJson<{ success: boolean; message: string; abandonedCount?: number }>(
|
||||||
|
`${BASE}/executions/stale/abandon-all?thresholdMinutes=${thresholdMinutes}`),
|
||||||
|
|
||||||
|
// F4: Search
|
||||||
|
searchExecutions: (params: {
|
||||||
|
jobNames?: string[];
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.jobNames && params.jobNames.length > 0) qs.set('jobNames', params.jobNames.join(','));
|
||||||
|
if (params.status) qs.set('status', params.status);
|
||||||
|
if (params.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params.endDate) qs.set('endDate', params.endDate);
|
||||||
|
qs.set('page', String(params.page ?? 0));
|
||||||
|
qs.set('size', String(params.size ?? 50));
|
||||||
|
return fetchJson<ExecutionSearchResponse>(`${BASE}/executions/search?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// F8: Statistics
|
||||||
|
getStatistics: (days = 30) =>
|
||||||
|
fetchJson<ExecutionStatisticsDto>(`${BASE}/statistics?days=${days}`),
|
||||||
|
|
||||||
|
getJobStatistics: (jobName: string, days = 30) =>
|
||||||
|
fetchJson<ExecutionStatisticsDto>(`${BASE}/statistics/${jobName}?days=${days}`),
|
||||||
|
|
||||||
|
// Schedule
|
||||||
|
getSchedules: () =>
|
||||||
|
fetchJson<{ schedules: ScheduleResponse[]; count: number }>(`${BASE}/schedules`),
|
||||||
|
|
||||||
|
getSchedule: (jobName: string) =>
|
||||||
|
fetchJson<ScheduleResponse>(`${BASE}/schedules/${jobName}`),
|
||||||
|
|
||||||
|
createSchedule: (data: ScheduleRequest) =>
|
||||||
|
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(`${BASE}/schedules`, data),
|
||||||
|
|
||||||
|
updateSchedule: (jobName: string, data: { cronExpression: string; description?: string }) =>
|
||||||
|
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
|
||||||
|
`${BASE}/schedules/${jobName}/update`, data),
|
||||||
|
|
||||||
|
deleteSchedule: (jobName: string) =>
|
||||||
|
postJson<{ success: boolean; message: string }>(`${BASE}/schedules/${jobName}/delete`),
|
||||||
|
|
||||||
|
toggleSchedule: (jobName: string, active: boolean) =>
|
||||||
|
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
|
||||||
|
`${BASE}/schedules/${jobName}/toggle`, { active }),
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
getTimeline: (view: string, date: string) =>
|
||||||
|
fetchJson<TimelineResponse>(`${BASE}/timeline?view=${view}&date=${date}`),
|
||||||
|
|
||||||
|
getPeriodExecutions: (jobName: string, view: string, periodKey: string) =>
|
||||||
|
fetchJson<JobExecutionDto[]>(
|
||||||
|
`${BASE}/timeline/period-executions?jobName=${jobName}&view=${view}&periodKey=${periodKey}`),
|
||||||
|
};
|
||||||
74
frontend/src/components/BarChart.tsx
Normal file
74
frontend/src/components/BarChart.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
interface BarValue {
|
||||||
|
color: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarData {
|
||||||
|
label: string;
|
||||||
|
values: BarValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: BarData[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChart({ data, height = 200 }: Props) {
|
||||||
|
const maxTotal = Math.max(...data.map((d) => d.values.reduce((sum, v) => sum + v.value, 0)), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-end gap-1" style={{ height }}>
|
||||||
|
{data.map((bar, i) => {
|
||||||
|
const total = bar.values.reduce((sum, v) => sum + v.value, 0);
|
||||||
|
const ratio = total / maxTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col justify-end h-full min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t overflow-hidden"
|
||||||
|
style={{ height: `${ratio * 100}%` }}
|
||||||
|
title={bar.values.map((v) => `${v.color}: ${v.value}`).join(', ')}
|
||||||
|
>
|
||||||
|
{bar.values
|
||||||
|
.filter((v) => v.value > 0)
|
||||||
|
.map((v, j) => {
|
||||||
|
const segmentRatio = total > 0 ? (v.value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className={colorToClass(v.color)}
|
||||||
|
style={{ height: `${segmentRatio}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{data.map((bar, i) => (
|
||||||
|
<div key={i} className="flex-1 min-w-0">
|
||||||
|
<p className="text-[10px] text-gray-500 text-center truncate" title={bar.label}>
|
||||||
|
{bar.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToClass(color: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
green: 'bg-green-500',
|
||||||
|
red: 'bg-red-500',
|
||||||
|
gray: 'bg-gray-400',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
indigo: 'bg-indigo-500',
|
||||||
|
};
|
||||||
|
return map[color] ?? color;
|
||||||
|
}
|
||||||
49
frontend/src/components/ConfirmModal.tsx
Normal file
49
frontend/src/components/ConfirmModal.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
confirmColor?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
open,
|
||||||
|
title = '확인',
|
||||||
|
message,
|
||||||
|
confirmLabel = '확인',
|
||||||
|
cancelLabel = '취소',
|
||||||
|
confirmColor = 'bg-wing-accent hover:bg-wing-accent/80',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">{title}</h3>
|
||||||
|
<p className="text-wing-muted text-sm mb-6 whitespace-pre-line">{message}</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${confirmColor}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/EmptyState.tsx
Normal file
15
frontend/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
interface Props {
|
||||||
|
icon?: string;
|
||||||
|
message: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({ icon = '📭', message, sub }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-wing-muted">
|
||||||
|
<span className="text-4xl mb-3">{icon}</span>
|
||||||
|
<p className="text-sm font-medium">{message}</p>
|
||||||
|
{sub && <p className="text-xs mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/InfoModal.tsx
Normal file
35
frontend/src/components/InfoModal.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoModal({
|
||||||
|
open,
|
||||||
|
title = '정보',
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">{title}</h3>
|
||||||
|
<div className="text-wing-muted text-sm mb-6">{children}</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/components/LoadingSpinner.tsx
Normal file
7
frontend/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function LoadingSpinner({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center py-12 ${className}`}>
|
||||||
|
<div className="w-8 h-8 border-4 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/components/Navbar.tsx
Normal file
54
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: '대시보드', icon: '📊' },
|
||||||
|
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||||
|
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||||
|
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||||
|
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { theme, toggle } = useThemeContext();
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') return location.pathname === '/';
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<Link to="/" className="text-lg font-bold text-wing-accent no-underline">
|
||||||
|
S&P 배치 관리
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-1 flex-wrap items-center">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
||||||
|
${isActive(item.path)
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||||
|
hover:text-wing-text border border-wing-border transition-colors"
|
||||||
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/StatusBadge.tsx
Normal file
40
frontend/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' },
|
||||||
|
FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' },
|
||||||
|
STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' },
|
||||||
|
STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' },
|
||||||
|
STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' },
|
||||||
|
STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' },
|
||||||
|
ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' },
|
||||||
|
SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' },
|
||||||
|
UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status, className = '' }: Props) {
|
||||||
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${config.bg} ${className}`}>
|
||||||
|
<span>{config.label}</span>
|
||||||
|
{config.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED': return '#10b981';
|
||||||
|
case 'FAILED': return '#ef4444';
|
||||||
|
case 'STARTED': return '#3b82f6';
|
||||||
|
case 'STARTING': return '#06b6d4';
|
||||||
|
case 'STOPPED': return '#f59e0b';
|
||||||
|
case 'STOPPING': return '#f97316';
|
||||||
|
case 'SCHEDULED': return '#8b5cf6';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/src/components/Toast.tsx
Normal file
37
frontend/src/components/Toast.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { Toast as ToastType } from '../hooks/useToast';
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<ToastType['type'], string> = {
|
||||||
|
success: 'bg-emerald-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-amber-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toasts: ToastType[];
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastContainer({ toasts, onRemove }: Props) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`${TYPE_STYLES[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg
|
||||||
|
flex items-center justify-between gap-3 animate-slide-in`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="text-white/80 hover:text-white text-lg leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/contexts/ThemeContext.tsx
Normal file
26
frontend/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'dark',
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useTheme();
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useThemeContext() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
29
frontend/src/contexts/ToastContext.tsx
Normal file
29
frontend/src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useToast, type Toast } from '../hooks/useToast';
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: Toast[];
|
||||||
|
showToast: (message: string, type?: Toast['type']) => void;
|
||||||
|
removeToast: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { toasts, showToast, removeToast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useToastContext(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useToastContext must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
53
frontend/src/hooks/usePoller.ts
Normal file
53
frontend/src/hooks/usePoller.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주기적 폴링 훅
|
||||||
|
* - 마운트 시 즉시 1회 실행 후 intervalMs 주기로 반복
|
||||||
|
* - 탭 비활성(document.hidden) 시 자동 중단, 활성화 시 즉시 재개
|
||||||
|
* - deps 변경 시 타이머 재설정
|
||||||
|
*/
|
||||||
|
export function usePoller(
|
||||||
|
fn: () => Promise<void> | void,
|
||||||
|
intervalMs: number,
|
||||||
|
deps: unknown[] = [],
|
||||||
|
) {
|
||||||
|
const fnRef = useRef(fn);
|
||||||
|
fnRef.current = fn;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
fnRef.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
run();
|
||||||
|
timer = setInterval(run, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop();
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [intervalMs, ...deps]);
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useTheme.ts
Normal file
27
frontend/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'snp-batch-theme';
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { theme, toggle } as const;
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useToast.ts
Normal file
27
frontend/src/hooks/useToast.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||||
|
const id = nextId++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, showToast, removeToast };
|
||||||
|
}
|
||||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./theme/tokens.css";
|
||||||
|
@import "./theme/base.css";
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
507
frontend/src/pages/Dashboard.tsx
Normal file
507
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
batchApi,
|
||||||
|
type DashboardResponse,
|
||||||
|
type DashboardStats,
|
||||||
|
type ExecutionStatisticsDto,
|
||||||
|
} from '../api/batchApi';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import BarChart from '../components/BarChart';
|
||||||
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 5000;
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
gradient: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, gradient, to }: StatCardProps) {
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`${gradient} rounded-xl shadow-md p-6 text-white
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer`}
|
||||||
|
>
|
||||||
|
<p className="text-3xl font-bold">{value}</p>
|
||||||
|
<p className="text-sm mt-1 opacity-90">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return <Link to={to} className="no-underline">{content}</Link>;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Execute Job modal
|
||||||
|
const [showExecuteModal, setShowExecuteModal] = useState(false);
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [selectedJob, setSelectedJob] = useState('');
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [stopDate, setStopDate] = useState('');
|
||||||
|
const [abandoning, setAbandoning] = useState(false);
|
||||||
|
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
||||||
|
|
||||||
|
const loadStatistics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getStatistics(30);
|
||||||
|
setStatistics(data);
|
||||||
|
} catch {
|
||||||
|
/* 통계 로드 실패는 무시 */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatistics();
|
||||||
|
}, [loadStatistics]);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getDashboard();
|
||||||
|
setDashboard(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Dashboard load failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePoller(loadDashboard, POLLING_INTERVAL);
|
||||||
|
|
||||||
|
const handleOpenExecuteModal = async () => {
|
||||||
|
try {
|
||||||
|
const jobList = await batchApi.getJobs();
|
||||||
|
setJobs(jobList);
|
||||||
|
setSelectedJob(jobList[0] ?? '');
|
||||||
|
setShowExecuteModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('작업 목록을 불러올 수 없습니다.', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteJob = async () => {
|
||||||
|
if (!selectedJob) return;
|
||||||
|
setExecuting(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.startDate = startDate;
|
||||||
|
if (stopDate) params.stopDate = stopDate;
|
||||||
|
const result = await batchApi.executeJob(
|
||||||
|
selectedJob,
|
||||||
|
Object.keys(params).length > 0 ? params : undefined,
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
result.message || `${selectedJob} 실행 요청 완료`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
setShowExecuteModal(false);
|
||||||
|
setStartDate('');
|
||||||
|
setStopDate('');
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbandonAllStale = async () => {
|
||||||
|
setAbandoning(true);
|
||||||
|
try {
|
||||||
|
const result = await batchApi.abandonAllStale();
|
||||||
|
showToast(
|
||||||
|
result.message || `${result.abandonedCount ?? 0}건 강제 종료 완료`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
`강제 종료 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAbandoning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const stats: DashboardStats = dashboard?.stats ?? {
|
||||||
|
totalSchedules: 0,
|
||||||
|
activeSchedules: 0,
|
||||||
|
inactiveSchedules: 0,
|
||||||
|
totalJobs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningJobs = dashboard?.runningJobs ?? [];
|
||||||
|
const recentExecutions = dashboard?.recentExecutions ?? [];
|
||||||
|
const recentFailures = dashboard?.recentFailures ?? [];
|
||||||
|
const staleExecutionCount = dashboard?.staleExecutionCount ?? 0;
|
||||||
|
const failureStats = dashboard?.failureStats ?? { last24h: 0, last7d: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">대시보드</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenExecuteModal}
|
||||||
|
className="px-4 py-2 bg-wing-accent text-white font-semibold rounded-lg shadow
|
||||||
|
hover:bg-wing-accent/80 hover:shadow-lg transition-all text-sm"
|
||||||
|
>
|
||||||
|
작업 즉시 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* F1: Stale Execution Warning Banner */}
|
||||||
|
{staleExecutionCount > 0 && (
|
||||||
|
<div className="flex items-center justify-between bg-amber-100 border border-amber-300 rounded-xl px-5 py-3">
|
||||||
|
<span className="text-amber-800 font-medium text-sm">
|
||||||
|
{staleExecutionCount}건의 오래된 실행 중 작업이 있습니다
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleAbandonAllStale}
|
||||||
|
disabled={abandoning}
|
||||||
|
className="px-4 py-1.5 text-sm font-medium text-white bg-amber-600 rounded-lg
|
||||||
|
hover:bg-amber-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{abandoning ? '처리 중...' : '전체 강제 종료'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="전체 스케줄"
|
||||||
|
value={stats.totalSchedules}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
to="/schedules"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="활성 스케줄"
|
||||||
|
value={stats.activeSchedules}
|
||||||
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="비활성 스케줄"
|
||||||
|
value={stats.inactiveSchedules}
|
||||||
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="전체 작업"
|
||||||
|
value={stats.totalJobs}
|
||||||
|
gradient="bg-gradient-to-br from-violet-500 to-violet-600"
|
||||||
|
to="/jobs"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="실패 (24h)"
|
||||||
|
value={failureStats.last24h}
|
||||||
|
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Navigation */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
to="/jobs"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
작업 관리
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/executions"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
실행 이력
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/schedules"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
스케줄 관리
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/schedule-timeline"
|
||||||
|
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||||
|
>
|
||||||
|
타임라인
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Running Jobs */}
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
실행 중인 작업
|
||||||
|
{runningJobs.length > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-accent">
|
||||||
|
({runningJobs.length}건)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{runningJobs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="💤"
|
||||||
|
message="현재 실행 중인 작업이 없습니다."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border text-left text-wing-muted">
|
||||||
|
<th className="pb-2 font-medium">작업명</th>
|
||||||
|
<th className="pb-2 font-medium">실행 ID</th>
|
||||||
|
<th className="pb-2 font-medium">시작 시간</th>
|
||||||
|
<th className="pb-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runningJobs.map((job) => (
|
||||||
|
<tr key={job.executionId} className="border-b border-wing-border/50">
|
||||||
|
<td className="py-3 font-medium text-wing-text">{job.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">#{job.executionId}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent Executions */}
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text">최근 실행 이력</h2>
|
||||||
|
<Link
|
||||||
|
to="/executions"
|
||||||
|
className="text-sm text-wing-accent hover:text-wing-accent no-underline"
|
||||||
|
>
|
||||||
|
전체 보기 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentExecutions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="📋"
|
||||||
|
message="실행 이력이 없습니다."
|
||||||
|
sub="작업을 실행하면 여기에 표시됩니다."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border text-left text-wing-muted">
|
||||||
|
<th className="pb-2 font-medium">실행 ID</th>
|
||||||
|
<th className="pb-2 font-medium">작업명</th>
|
||||||
|
<th className="pb-2 font-medium">시작 시간</th>
|
||||||
|
<th className="pb-2 font-medium">종료 시간</th>
|
||||||
|
<th className="pb-2 font-medium">소요 시간</th>
|
||||||
|
<th className="pb-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentExecutions.slice(0, 5).map((exec) => (
|
||||||
|
<tr key={exec.executionId} className="border-b border-wing-border/50">
|
||||||
|
<td className="py-3">
|
||||||
|
<Link
|
||||||
|
to={`/executions/${exec.executionId}`}
|
||||||
|
className="text-wing-accent hover:text-wing-accent no-underline font-medium"
|
||||||
|
>
|
||||||
|
#{exec.executionId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-wing-text">{exec.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td>
|
||||||
|
<td className="py-3 text-wing-muted">
|
||||||
|
{calculateDuration(exec.startTime, exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* F6: Recent Failures */}
|
||||||
|
{recentFailures.length > 0 && (
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6 border border-red-200">
|
||||||
|
<h2 className="text-lg font-semibold text-red-700 mb-4">
|
||||||
|
최근 실패 이력
|
||||||
|
<span className="ml-2 text-sm font-normal text-red-500">
|
||||||
|
({recentFailures.length}건)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-red-200 text-left text-red-500">
|
||||||
|
<th className="pb-2 font-medium">실행 ID</th>
|
||||||
|
<th className="pb-2 font-medium">작업명</th>
|
||||||
|
<th className="pb-2 font-medium">시작 시간</th>
|
||||||
|
<th className="pb-2 font-medium">오류 메시지</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentFailures.map((fail) => (
|
||||||
|
<tr key={fail.executionId} className="border-b border-red-100">
|
||||||
|
<td className="py-3">
|
||||||
|
<Link
|
||||||
|
to={`/executions/${fail.executionId}`}
|
||||||
|
className="text-red-600 hover:text-red-800 no-underline font-medium"
|
||||||
|
>
|
||||||
|
#{fail.executionId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-wing-text">{fail.jobName}</td>
|
||||||
|
<td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td>
|
||||||
|
<td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}>
|
||||||
|
{fail.exitMessage
|
||||||
|
? fail.exitMessage.length > 50
|
||||||
|
? `${fail.exitMessage.slice(0, 50)}...`
|
||||||
|
: fail.exitMessage
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* F8: Execution Statistics Chart */}
|
||||||
|
{statistics && statistics.dailyStats.length > 0 && (
|
||||||
|
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text">
|
||||||
|
실행 통계 (최근 30일)
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-4 text-xs text-wing-muted">
|
||||||
|
<span>
|
||||||
|
전체 <strong className="text-wing-text">{statistics.totalExecutions}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
성공 <strong className="text-emerald-600">{statistics.totalSuccess}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
실패 <strong className="text-red-600">{statistics.totalFailed}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BarChart
|
||||||
|
data={statistics.dailyStats.map((d) => ({
|
||||||
|
label: d.date.slice(5),
|
||||||
|
values: [
|
||||||
|
{ color: 'green', value: d.successCount },
|
||||||
|
{ color: 'red', value: d.failedCount },
|
||||||
|
{ color: 'gray', value: d.otherCount },
|
||||||
|
],
|
||||||
|
}))}
|
||||||
|
height={180}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4 mt-3 text-xs text-wing-muted justify-end">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-emerald-500" /> 성공
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-red-500" /> 실패
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-gray-400" /> 기타
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute Job Modal */}
|
||||||
|
{showExecuteModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setShowExecuteModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">작업 즉시 실행</h3>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-2">
|
||||||
|
실행할 작업 선택
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedJob}
|
||||||
|
onChange={(e) => setSelectedJob(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
||||||
|
>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<option key={job} value={job}>{job}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
시작일시 <span className="text-wing-muted font-normal">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
종료일시 <span className="text-wing-muted font-normal">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={stopDate}
|
||||||
|
onChange={(e) => setStopDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExecuteModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||||
|
hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExecuteJob}
|
||||||
|
disabled={executing || !selectedJob}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{executing ? '실행 중...' : '실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
frontend/src/pages/ExecutionDetail.tsx
Normal file
335
frontend/src/pages/ExecutionDetail.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto } from '../api/batchApi';
|
||||||
|
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
gradient: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, gradient, icon }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-xl p-5 text-white shadow-md ${gradient}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white/80">{label}</p>
|
||||||
|
<p className="mt-1 text-3xl font-bold">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl opacity-80">{icon}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepCardProps {
|
||||||
|
step: StepExecutionDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard({ step }: StepCardProps) {
|
||||||
|
const stats = [
|
||||||
|
{ label: '읽기', value: step.readCount },
|
||||||
|
{ label: '쓰기', value: step.writeCount },
|
||||||
|
{ label: '커밋', value: step.commitCount },
|
||||||
|
{ label: '롤백', value: step.rollbackCount },
|
||||||
|
{ label: '읽기 건너뜀', value: step.readSkipCount },
|
||||||
|
{ label: '처리 건너뜀', value: step.processSkipCount },
|
||||||
|
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
|
||||||
|
{ label: '필터', value: step.filterCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-wing-text">
|
||||||
|
{step.stepName}
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={step.status} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
{step.duration != null
|
||||||
|
? formatDuration(step.duration)
|
||||||
|
: calculateDuration(step.startTime, step.endTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
||||||
|
<div className="text-wing-muted">
|
||||||
|
시작: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-wing-muted">
|
||||||
|
종료: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{stats.map(({ label, value }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-lg bg-wing-card px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-bold text-wing-text">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-wing-muted">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 호출 정보 */}
|
||||||
|
{step.apiCallInfo && (
|
||||||
|
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-2">API 호출 정보</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">URL:</span>{' '}
|
||||||
|
<span className="text-blue-900 font-mono break-all">{step.apiCallInfo.apiUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">Method:</span>{' '}
|
||||||
|
<span className="text-blue-900 font-semibold">{step.apiCallInfo.method}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">호출:</span>{' '}
|
||||||
|
<span className="text-blue-900">{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}</span>
|
||||||
|
</div>
|
||||||
|
{step.apiCallInfo.lastCallTime && (
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-500">최종:</span>{' '}
|
||||||
|
<span className="text-blue-900">{step.apiCallInfo.lastCallTime}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step.exitMessage && (
|
||||||
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
|
||||||
|
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
||||||
|
{step.exitMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExecutionDetail() {
|
||||||
|
const { id: paramId } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const executionId = paramId
|
||||||
|
? Number(paramId)
|
||||||
|
: Number(searchParams.get('id'));
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isRunning = detail
|
||||||
|
? detail.status === 'STARTED' || detail.status === 'STARTING'
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async () => {
|
||||||
|
if (!executionId || isNaN(executionId)) {
|
||||||
|
setError('유효하지 않은 실행 ID입니다.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getExecutionDetail(executionId);
|
||||||
|
setDetail(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '실행 상세 정보를 불러오지 못했습니다.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [executionId]);
|
||||||
|
|
||||||
|
/* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */
|
||||||
|
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [
|
||||||
|
executionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
if (error || !detail) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/executions')}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>←</span> 목록으로
|
||||||
|
</button>
|
||||||
|
<EmptyState
|
||||||
|
icon="⚠"
|
||||||
|
message={error || '실행 정보를 찾을 수 없습니다.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobParams = Object.entries(detail.jobParameters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 상단 내비게이션 */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>←</span> 목록으로
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Job 기본 정보 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">
|
||||||
|
실행 #{detail.executionId}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
{detail.jobName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={detail.status} className="text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||||
|
<InfoItem label="시작시간" value={formatDateTime(detail.startTime)} />
|
||||||
|
<InfoItem label="종료시간" value={formatDateTime(detail.endTime)} />
|
||||||
|
<InfoItem
|
||||||
|
label="소요시간"
|
||||||
|
value={
|
||||||
|
detail.duration != null
|
||||||
|
? formatDuration(detail.duration)
|
||||||
|
: calculateDuration(detail.startTime, detail.endTime)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoItem label="Exit Code" value={detail.exitCode} />
|
||||||
|
{detail.exitMessage && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
|
<InfoItem label="Exit Message" value={detail.exitMessage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 통계 카드 4개 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="읽기 (Read)"
|
||||||
|
value={detail.readCount}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
icon="📥"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="쓰기 (Write)"
|
||||||
|
value={detail.writeCount}
|
||||||
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
|
icon="📤"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="건너뜀 (Skip)"
|
||||||
|
value={detail.skipCount}
|
||||||
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
|
icon="⏭"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="필터 (Filter)"
|
||||||
|
value={detail.filterCount}
|
||||||
|
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||||
|
icon="🔍"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Parameters */}
|
||||||
|
{jobParams.length > 0 && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
Job Parameters
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border/50">
|
||||||
|
{jobParams.map(([key, value]) => (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
className="hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3 font-mono text-wing-text">
|
||||||
|
{key}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-wing-muted break-all">
|
||||||
|
{value}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 실행 정보 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
Step 실행 정보
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||||
|
({detail.stepExecutions.length}개)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{detail.stepExecutions.length === 0 ? (
|
||||||
|
<EmptyState message="Step 실행 정보가 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{detail.stepExecutions.map((step) => (
|
||||||
|
<StepCard
|
||||||
|
key={step.stepExecutionId}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
587
frontend/src/pages/Executions.tsx
Normal file
587
frontend/src/pages/Executions.tsx
Normal file
@ -0,0 +1,587 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi';
|
||||||
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import InfoModal from '../components/InfoModal';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED';
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: '전체' },
|
||||||
|
{ value: 'COMPLETED', label: '완료' },
|
||||||
|
{ value: 'FAILED', label: '실패' },
|
||||||
|
{ value: 'STARTED', label: '실행중' },
|
||||||
|
{ value: 'STOPPED', label: '중지됨' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
|
const RECENT_LIMIT = 50;
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export default function Executions() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const jobFromQuery = searchParams.get('job') || '';
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
|
||||||
|
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
|
||||||
|
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [stopTarget, setStopTarget] = useState<JobExecutionDto | null>(null);
|
||||||
|
|
||||||
|
// F1: 강제 종료
|
||||||
|
const [abandonTarget, setAbandonTarget] = useState<JobExecutionDto | null>(null);
|
||||||
|
|
||||||
|
// F4: 날짜 범위 필터 + 페이지네이션
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [useSearch, setUseSearch] = useState(false);
|
||||||
|
|
||||||
|
// F9: 실패 로그 뷰어
|
||||||
|
const [failLogTarget, setFailLogTarget] = useState<JobExecutionDto | null>(null);
|
||||||
|
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getJobs();
|
||||||
|
setJobs(data);
|
||||||
|
} catch {
|
||||||
|
/* Job 목록 로드 실패는 무시 */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSearchExecutions = useCallback(async (targetPage: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params: {
|
||||||
|
jobNames?: string[];
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
} = {
|
||||||
|
page: targetPage,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
if (selectedJobs.length > 0) params.jobNames = selectedJobs;
|
||||||
|
if (statusFilter !== 'ALL') params.status = statusFilter;
|
||||||
|
if (startDate) params.startDate = `${startDate}T00:00:00`;
|
||||||
|
if (endDate) params.endDate = `${endDate}T23:59:59`;
|
||||||
|
|
||||||
|
const data: ExecutionSearchResponse = await batchApi.searchExecutions(params);
|
||||||
|
setExecutions(data.executions);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
setTotalCount(data.totalCount);
|
||||||
|
setPage(data.page);
|
||||||
|
} catch {
|
||||||
|
setExecutions([]);
|
||||||
|
setTotalPages(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedJobs, statusFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
const loadExecutions = useCallback(async () => {
|
||||||
|
// 검색 모드에서는 폴링하지 않음 (검색 버튼 클릭 시에만 1회 조회)
|
||||||
|
if (useSearch) return;
|
||||||
|
try {
|
||||||
|
let data: JobExecutionDto[];
|
||||||
|
if (selectedJobs.length === 1) {
|
||||||
|
data = await batchApi.getJobExecutions(selectedJobs[0]);
|
||||||
|
} else if (selectedJobs.length > 1) {
|
||||||
|
// 복수 Job 선택 시 search API 사용
|
||||||
|
const result = await batchApi.searchExecutions({
|
||||||
|
jobNames: selectedJobs, size: RECENT_LIMIT,
|
||||||
|
});
|
||||||
|
data = result.executions;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
data = await batchApi.getRecentExecutions(RECENT_LIMIT);
|
||||||
|
} catch {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExecutions(data);
|
||||||
|
} catch {
|
||||||
|
setExecutions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedJobs, useSearch, page, loadSearchExecutions]);
|
||||||
|
|
||||||
|
/* 마운트 시 Job 목록 1회 로드 */
|
||||||
|
usePoller(loadJobs, 60_000, []);
|
||||||
|
|
||||||
|
/* 실행 이력 5초 폴링 */
|
||||||
|
usePoller(loadExecutions, POLLING_INTERVAL_MS, [selectedJobs, useSearch, page]);
|
||||||
|
|
||||||
|
const filteredExecutions = useMemo(() => {
|
||||||
|
// 검색 모드에서는 서버 필터링 사용
|
||||||
|
if (useSearch) return executions;
|
||||||
|
if (statusFilter === 'ALL') return executions;
|
||||||
|
return executions.filter((e) => e.status === statusFilter);
|
||||||
|
}, [executions, statusFilter, useSearch]);
|
||||||
|
|
||||||
|
const toggleJob = (jobName: string) => {
|
||||||
|
setSelectedJobs((prev) => {
|
||||||
|
const next = prev.includes(jobName)
|
||||||
|
? prev.filter((j) => j !== jobName)
|
||||||
|
: [...prev, jobName];
|
||||||
|
if (next.length === 1) {
|
||||||
|
setSearchParams({ job: next[0] });
|
||||||
|
} else {
|
||||||
|
setSearchParams({});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLoading(true);
|
||||||
|
if (useSearch) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedJobs = () => {
|
||||||
|
setSelectedJobs([]);
|
||||||
|
setSearchParams({});
|
||||||
|
setLoading(true);
|
||||||
|
if (useSearch) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (!stopTarget) return;
|
||||||
|
try {
|
||||||
|
const result = await batchApi.stopExecution(stopTarget.executionId);
|
||||||
|
showToast(result.message || '실행이 중지되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
err instanceof Error ? err.message : '중지 요청에 실패했습니다.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStopTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// F1: 강제 종료 핸들러
|
||||||
|
const handleAbandon = async () => {
|
||||||
|
if (!abandonTarget) return;
|
||||||
|
try {
|
||||||
|
const result = await batchApi.abandonExecution(abandonTarget.executionId);
|
||||||
|
showToast(result.message || '실행이 강제 종료되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
err instanceof Error ? err.message : '강제 종료 요청에 실패했습니다.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setAbandonTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// F4: 검색 핸들러
|
||||||
|
const handleSearch = async () => {
|
||||||
|
setUseSearch(true);
|
||||||
|
setPage(0);
|
||||||
|
await loadSearchExecutions(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// F4: 초기화 핸들러
|
||||||
|
const handleResetSearch = () => {
|
||||||
|
setUseSearch(false);
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setPage(0);
|
||||||
|
setTotalPages(0);
|
||||||
|
setTotalCount(0);
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// F4: 페이지 이동 핸들러
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (newPage < 0 || newPage >= totalPages) return;
|
||||||
|
setPage(newPage);
|
||||||
|
loadSearchExecutions(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRunning = (status: string) =>
|
||||||
|
status === 'STARTED' || status === 'STARTING';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">실행 이력</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
배치 작업 실행 이력을 조회하고 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Job 멀티 선택 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
작업 선택
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setJobDropdownOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
{selectedJobs.length === 0
|
||||||
|
? `전체 (최근 ${RECENT_LIMIT}건)`
|
||||||
|
: `${selectedJobs.length}개 선택됨`}
|
||||||
|
<svg className={`w-4 h-4 text-wing-muted transition-transform ${jobDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
{jobDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setJobDropdownOpen(false)} />
|
||||||
|
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<label
|
||||||
|
key={job}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedJobs.includes(job)}
|
||||||
|
onChange={() => toggleJob(job)}
|
||||||
|
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-wing-text truncate">{job}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedJobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearSelectedJobs}
|
||||||
|
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 선택된 Job 칩 */}
|
||||||
|
{selectedJobs.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedJobs.map((job) => (
|
||||||
|
<span
|
||||||
|
key={job}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full"
|
||||||
|
>
|
||||||
|
{job}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleJob(job)}
|
||||||
|
className="hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 버튼 그룹 */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{STATUS_FILTERS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
if (useSearch) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === value
|
||||||
|
? 'bg-wing-accent text-white shadow-sm'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* F4: 날짜 범위 필터 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
|
||||||
|
<label className="text-sm font-medium text-wing-text shrink-0">
|
||||||
|
기간 검색
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-wing-muted text-sm">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
{useSearch && (
|
||||||
|
<button
|
||||||
|
onClick={handleResetSearch}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 이력 테이블 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : filteredExecutions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message="실행 이력이 없습니다."
|
||||||
|
sub={
|
||||||
|
statusFilter !== 'ALL'
|
||||||
|
? '다른 상태 필터를 선택해 보세요.'
|
||||||
|
: selectedJobs.length > 0
|
||||||
|
? '선택한 작업의 실행 이력이 없습니다.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 font-medium">실행 ID</th>
|
||||||
|
<th className="px-6 py-3 font-medium">작업명</th>
|
||||||
|
<th className="px-6 py-3 font-medium">상태</th>
|
||||||
|
<th className="px-6 py-3 font-medium">시작시간</th>
|
||||||
|
<th className="px-6 py-3 font-medium">종료시간</th>
|
||||||
|
<th className="px-6 py-3 font-medium">소요시간</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-right">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border/50">
|
||||||
|
{filteredExecutions.map((exec) => (
|
||||||
|
<tr
|
||||||
|
key={exec.executionId}
|
||||||
|
className="hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 font-mono text-wing-text">
|
||||||
|
#{exec.executionId}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-text">
|
||||||
|
{exec.jobName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
|
||||||
|
{exec.status === 'FAILED' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setFailLogTarget(exec)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
title="클릭하여 실패 로그 확인"
|
||||||
|
>
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{formatDateTime(exec.startTime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{formatDateTime(exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
|
||||||
|
{calculateDuration(
|
||||||
|
exec.startTime,
|
||||||
|
exec.endTime,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{isRunning(exec.status) ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setStopTarget(exec)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
중지
|
||||||
|
</button>
|
||||||
|
{/* F1: 강제 종료 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setAbandonTarget(exec)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-colors"
|
||||||
|
>
|
||||||
|
강제 종료
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/executions/${exec.executionId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 건수 표시 + F4: 페이지네이션 */}
|
||||||
|
{!loading && filteredExecutions.length > 0 && (
|
||||||
|
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-wing-muted">
|
||||||
|
{useSearch ? (
|
||||||
|
<>총 {totalCount}건</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
총 {filteredExecutions.length}건
|
||||||
|
{statusFilter !== 'ALL' && (
|
||||||
|
<span className="ml-1">
|
||||||
|
(전체 {executions.length}건 중)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* F4: 페이지네이션 UI */}
|
||||||
|
{useSearch && totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중지 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={stopTarget !== null}
|
||||||
|
title="실행 중지"
|
||||||
|
message={
|
||||||
|
stopTarget
|
||||||
|
? `실행 #${stopTarget.executionId} (${stopTarget.jobName})을 중지하시겠습니까?`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="중지"
|
||||||
|
confirmColor="bg-red-600 hover:bg-red-700"
|
||||||
|
onConfirm={handleStop}
|
||||||
|
onCancel={() => setStopTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* F1: 강제 종료 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={abandonTarget !== null}
|
||||||
|
title="강제 종료"
|
||||||
|
message={
|
||||||
|
abandonTarget
|
||||||
|
? `실행 #${abandonTarget.executionId} (${abandonTarget.jobName})을 강제 종료하시겠습니까?\n\n강제 종료는 실행 상태를 ABANDONED로 변경합니다.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="강제 종료"
|
||||||
|
confirmColor="bg-amber-600 hover:bg-amber-700"
|
||||||
|
onConfirm={handleAbandon}
|
||||||
|
onCancel={() => setAbandonTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* F9: 실패 로그 뷰어 모달 */}
|
||||||
|
<InfoModal
|
||||||
|
open={failLogTarget !== null}
|
||||||
|
title={
|
||||||
|
failLogTarget
|
||||||
|
? `실패 로그 - #${failLogTarget.executionId} (${failLogTarget.jobName})`
|
||||||
|
: '실패 로그'
|
||||||
|
}
|
||||||
|
onClose={() => setFailLogTarget(null)}
|
||||||
|
>
|
||||||
|
{failLogTarget && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||||
|
Exit Code
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
|
||||||
|
{failLogTarget.exitCode || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
|
||||||
|
Exit Message
|
||||||
|
</h4>
|
||||||
|
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
|
||||||
|
{failLogTarget.exitMessage || '메시지 없음'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
frontend/src/pages/Jobs.tsx
Normal file
273
frontend/src/pages/Jobs.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { batchApi } from '../api/batchApi';
|
||||||
|
import type { JobDetailDto } from '../api/batchApi';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
export default function Jobs() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Execute modal
|
||||||
|
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||||
|
const [targetJob, setTargetJob] = useState('');
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [stopDate, setStopDate] = useState('');
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getJobsDetail();
|
||||||
|
setJobs(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Jobs load failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePoller(loadJobs, POLLING_INTERVAL);
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
if (!searchTerm.trim()) return jobs;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return jobs.filter((job) => job.jobName.toLowerCase().includes(term));
|
||||||
|
}, [jobs, searchTerm]);
|
||||||
|
|
||||||
|
const handleExecuteClick = (jobName: string) => {
|
||||||
|
setTargetJob(jobName);
|
||||||
|
setStartDate('');
|
||||||
|
setStopDate('');
|
||||||
|
setExecuteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmExecute = async () => {
|
||||||
|
if (!targetJob) return;
|
||||||
|
setExecuting(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.startDate = startDate;
|
||||||
|
if (stopDate) params.stopDate = stopDate;
|
||||||
|
|
||||||
|
const result = await batchApi.executeJob(
|
||||||
|
targetJob,
|
||||||
|
Object.keys(params).length > 0 ? params : undefined,
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
result.message || `${targetJob} 실행 요청 완료`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
setExecuteModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewHistory = (jobName: string) => {
|
||||||
|
navigate(`/executions?job=${encodeURIComponent(jobName)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">배치 작업 목록</h1>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
총 {jobs.length}개 작업
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Filter */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="작업명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filteredJobs.length}개 작업 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Cards Grid */}
|
||||||
|
{filteredJobs.length === 0 ? (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon="🔍"
|
||||||
|
message={searchTerm ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
|
||||||
|
sub={searchTerm ? '다른 검색어를 입력해 보세요.' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredJobs.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.jobName}
|
||||||
|
className="bg-wing-surface rounded-xl shadow-md p-6
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||||
|
{job.jobName}
|
||||||
|
</h3>
|
||||||
|
{job.lastExecution && (
|
||||||
|
<StatusBadge status={job.lastExecution.status} className="ml-2 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job detail info */}
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
{job.lastExecution ? (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
마지막 실행: {formatDateTime(job.lastExecution.startTime)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-wing-muted text-xs">실행 이력 없음</p>
|
||||||
|
)}
|
||||||
|
{job.scheduleCron && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
스케줄:{' '}
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">
|
||||||
|
{job.scheduleCron}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecuteClick(job.jobName)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
실행
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewHistory(job.jobName)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
||||||
|
hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
이력 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute Modal (custom with date params) */}
|
||||||
|
{executeModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setExecuteModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">작업 실행 확인</h3>
|
||||||
|
<p className="text-wing-muted text-sm mb-4">
|
||||||
|
"{targetJob}" 작업을 실행하시겠습니까?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Date parameters */}
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||||
|
시작일시 (선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||||
|
종료일시 (선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={stopDate}
|
||||||
|
onChange={(e) => setStopDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(startDate || stopDate) && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
날짜를 지정하면 해당 범위의 데이터를 수집합니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setExecuteModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||||
|
hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmExecute}
|
||||||
|
disabled={executing}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{executing ? '실행 중...' : '실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
490
frontend/src/pages/Schedules.tsx
Normal file
490
frontend/src/pages/Schedules.tsx
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { batchApi, type ScheduleResponse } from '../api/batchApi';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import { getNextExecutions } from '../utils/cronPreview';
|
||||||
|
|
||||||
|
type ScheduleMode = 'new' | 'existing';
|
||||||
|
|
||||||
|
interface ConfirmAction {
|
||||||
|
type: 'toggle' | 'delete';
|
||||||
|
schedule: ScheduleResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRON_PRESETS = [
|
||||||
|
{ label: '매 분', cron: '0 * * * * ?' },
|
||||||
|
{ label: '매시 정각', cron: '0 0 * * * ?' },
|
||||||
|
{ label: '매 15분', cron: '0 0/15 * * * ?' },
|
||||||
|
{ label: '매일 00:00', cron: '0 0 0 * * ?' },
|
||||||
|
{ label: '매일 12:00', cron: '0 0 12 * * ?' },
|
||||||
|
{ label: '매주 월 00:00', cron: '0 0 0 ? * MON' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CronPreview({ cron }: { cron: string }) {
|
||||||
|
const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]);
|
||||||
|
|
||||||
|
if (nextDates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<p className="text-xs text-wing-muted">미리보기 불가 (복잡한 표현식)</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
다음 5회 실행 예정
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{nextDates.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-block bg-wing-accent/10 text-wing-accent text-xs font-mono px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
{fmt.format(d)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTriggerStateStyle(state: string | null): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'NORMAL':
|
||||||
|
return 'bg-emerald-100 text-emerald-700';
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'bg-amber-100 text-amber-700';
|
||||||
|
case 'BLOCKED':
|
||||||
|
return 'bg-red-100 text-red-700';
|
||||||
|
case 'ERROR':
|
||||||
|
return 'bg-red-100 text-red-700';
|
||||||
|
default:
|
||||||
|
return 'bg-wing-card text-wing-muted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Schedules() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [selectedJob, setSelectedJob] = useState('');
|
||||||
|
const [cronExpression, setCronExpression] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('new');
|
||||||
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Schedule list state
|
||||||
|
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
|
||||||
|
const [listLoading, setListLoading] = useState(true);
|
||||||
|
|
||||||
|
// Confirm modal state
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
|
||||||
|
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
|
||||||
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const loadSchedules = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await batchApi.getSchedules();
|
||||||
|
setSchedules(result.schedules);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('스케줄 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await batchApi.getJobs();
|
||||||
|
setJobs(result);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('작업 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJobs();
|
||||||
|
loadSchedules();
|
||||||
|
}, [loadJobs, loadSchedules]);
|
||||||
|
|
||||||
|
const handleJobSelect = async (jobName: string) => {
|
||||||
|
setSelectedJob(jobName);
|
||||||
|
setCronExpression('');
|
||||||
|
setDescription('');
|
||||||
|
setScheduleMode('new');
|
||||||
|
|
||||||
|
if (!jobName) return;
|
||||||
|
|
||||||
|
setFormLoading(true);
|
||||||
|
try {
|
||||||
|
const schedule = await batchApi.getSchedule(jobName);
|
||||||
|
setCronExpression(schedule.cronExpression);
|
||||||
|
setDescription(schedule.description ?? '');
|
||||||
|
setScheduleMode('existing');
|
||||||
|
} catch {
|
||||||
|
// 404 = new schedule
|
||||||
|
setScheduleMode('new');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedJob) {
|
||||||
|
showToast('작업을 선택해주세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cronExpression.trim()) {
|
||||||
|
showToast('Cron 표현식을 입력해주세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (scheduleMode === 'existing') {
|
||||||
|
await batchApi.updateSchedule(selectedJob, {
|
||||||
|
cronExpression: cronExpression.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
showToast('스케줄이 수정되었습니다', 'success');
|
||||||
|
} else {
|
||||||
|
await batchApi.createSchedule({
|
||||||
|
jobName: selectedJob,
|
||||||
|
cronExpression: cronExpression.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
showToast('스케줄이 등록되었습니다', 'success');
|
||||||
|
}
|
||||||
|
await loadSchedules();
|
||||||
|
// Reload schedule info for current job
|
||||||
|
await handleJobSelect(selectedJob);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '저장 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (schedule: ScheduleResponse) => {
|
||||||
|
try {
|
||||||
|
await batchApi.toggleSchedule(schedule.jobName, !schedule.active);
|
||||||
|
showToast(
|
||||||
|
`${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
await loadSchedules();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '토글 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (schedule: ScheduleResponse) => {
|
||||||
|
try {
|
||||||
|
await batchApi.deleteSchedule(schedule.jobName);
|
||||||
|
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
|
||||||
|
await loadSchedules();
|
||||||
|
// Clear form if deleted schedule was selected
|
||||||
|
if (selectedJob === schedule.jobName) {
|
||||||
|
setSelectedJob('');
|
||||||
|
setCronExpression('');
|
||||||
|
setDescription('');
|
||||||
|
setScheduleMode('new');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '삭제 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditFromCard = (schedule: ScheduleResponse) => {
|
||||||
|
setSelectedJob(schedule.jobName);
|
||||||
|
setCronExpression(schedule.cronExpression);
|
||||||
|
setDescription(schedule.description ?? '');
|
||||||
|
setScheduleMode('existing');
|
||||||
|
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Form Section */}
|
||||||
|
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||||
|
<h2 className="text-lg font-bold text-wing-text mb-4">스케줄 등록 / 수정</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Job Select */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
작업 선택
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedJob}
|
||||||
|
onChange={(e) => handleJobSelect(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||||
|
disabled={formLoading}
|
||||||
|
>
|
||||||
|
<option value="">-- 작업을 선택하세요 --</option>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<option key={job} value={job}>
|
||||||
|
{job}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedJob && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||||
|
scheduleMode === 'existing'
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{formLoading && (
|
||||||
|
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Expression */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
Cron 표현식
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cronExpression}
|
||||||
|
onChange={(e) => setCronExpression(e.target.value)}
|
||||||
|
placeholder="0 0/15 * * * ?"
|
||||||
|
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||||
|
disabled={!selectedJob || formLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Presets */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
프리셋
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CRON_PRESETS.map(({ label, cron }) => (
|
||||||
|
<button
|
||||||
|
key={cron}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCronExpression(cron)}
|
||||||
|
disabled={!selectedJob || formLoading}
|
||||||
|
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Preview */}
|
||||||
|
{cronExpression.trim() && (
|
||||||
|
<CronPreview cron={cronExpression.trim()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="스케줄 설명 (선택)"
|
||||||
|
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||||
|
disabled={!selectedJob || formLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule List */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-wing-text">
|
||||||
|
등록된 스케줄
|
||||||
|
{schedules.length > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-wing-muted">
|
||||||
|
({schedules.length}개)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={loadSchedules}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listLoading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
|
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className="border border-wing-border rounded-xl p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-wing-text truncate">
|
||||||
|
{schedule.jobName}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
|
schedule.active
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
{schedule.triggerState && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
|
||||||
|
>
|
||||||
|
{schedule.triggerState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Expression */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="inline-block bg-wing-card text-wing-text font-mono text-xs px-2 py-1 rounded">
|
||||||
|
{schedule.cronExpression}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{schedule.description && (
|
||||||
|
<p className="text-sm text-wing-muted mb-3">{schedule.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs text-wing-muted mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">다음 실행:</span>{' '}
|
||||||
|
{formatDateTime(schedule.nextFireTime)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">이전 실행:</span>{' '}
|
||||||
|
{formatDateTime(schedule.previousFireTime)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">등록일:</span>{' '}
|
||||||
|
{formatDateTime(schedule.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-wing-muted">수정일:</span>{' '}
|
||||||
|
{formatDateTime(schedule.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-wing-border/50">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditFromCard(schedule)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmAction({ type: 'toggle', schedule })
|
||||||
|
}
|
||||||
|
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
schedule.active
|
||||||
|
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
|
||||||
|
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmAction({ type: 'delete', schedule })
|
||||||
|
}
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
{confirmAction?.type === 'toggle' && (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title="스케줄 상태 변경"
|
||||||
|
message={`${confirmAction.schedule.jobName} 스케줄을 ${
|
||||||
|
confirmAction.schedule.active ? '비활성화' : '활성화'
|
||||||
|
}하시겠습니까?`}
|
||||||
|
confirmLabel={confirmAction.schedule.active ? '비활성화' : '활성화'}
|
||||||
|
onConfirm={() => handleToggle(confirmAction.schedule)}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirmAction?.type === 'delete' && (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title="스케줄 삭제"
|
||||||
|
message={`${confirmAction.schedule.jobName} 스케줄을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`}
|
||||||
|
confirmLabel="삭제"
|
||||||
|
confirmColor="bg-red-600 hover:bg-red-700"
|
||||||
|
onConfirm={() => handleDelete(confirmAction.schedule)}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
466
frontend/src/pages/Timeline.tsx
Normal file
466
frontend/src/pages/Timeline.tsx
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
|
||||||
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
import { usePoller } from '../hooks/usePoller';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import { getStatusColor } from '../components/StatusBadge';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
|
||||||
|
type ViewType = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
interface TooltipData {
|
||||||
|
jobName: string;
|
||||||
|
period: PeriodInfo;
|
||||||
|
execution: ExecutionInfo;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedCell {
|
||||||
|
jobName: string;
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_OPTIONS: { value: ViewType; label: string }[] = [
|
||||||
|
{ value: 'day', label: 'Day' },
|
||||||
|
{ value: 'week', label: 'Week' },
|
||||||
|
{ value: 'month', label: 'Month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGEND_ITEMS = [
|
||||||
|
{ status: 'COMPLETED', color: '#10b981', label: '완료' },
|
||||||
|
{ status: 'FAILED', color: '#ef4444', label: '실패' },
|
||||||
|
{ status: 'STARTED', color: '#3b82f6', label: '실행중' },
|
||||||
|
{ status: 'SCHEDULED', color: '#8b5cf6', label: '예정' },
|
||||||
|
{ status: 'NONE', color: '#e5e7eb', label: '없음' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const JOB_COL_WIDTH = 200;
|
||||||
|
const CELL_MIN_WIDTH = 80;
|
||||||
|
const POLLING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
function formatDateStr(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDate(date: Date, view: ViewType, delta: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
switch (view) {
|
||||||
|
case 'day':
|
||||||
|
next.setDate(next.getDate() + delta);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
next.setDate(next.getDate() + delta * 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
next.setMonth(next.getMonth() + delta);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(status: string): boolean {
|
||||||
|
return status === 'STARTED' || status === 'STARTING';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [view, setView] = useState<ViewType>('day');
|
||||||
|
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||||
|
const [periodLabel, setPeriodLabel] = useState('');
|
||||||
|
const [periods, setPeriods] = useState<PeriodInfo[]>([]);
|
||||||
|
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||||
|
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Selected cell & detail panel
|
||||||
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
||||||
|
const [detailExecutions, setDetailExecutions] = useState<JobExecutionDto[]>([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadTimeline = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const dateStr = formatDateStr(currentDate);
|
||||||
|
const result = await batchApi.getTimeline(view, dateStr);
|
||||||
|
setPeriodLabel(result.periodLabel);
|
||||||
|
setPeriods(result.periods);
|
||||||
|
setSchedules(result.schedules);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('타임라인 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [view, currentDate, showToast]);
|
||||||
|
|
||||||
|
usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]);
|
||||||
|
|
||||||
|
const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1));
|
||||||
|
const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1));
|
||||||
|
const handleToday = () => setCurrentDate(new Date());
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await loadTimeline();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tooltip handlers
|
||||||
|
const handleCellMouseEnter = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
jobName: string,
|
||||||
|
period: PeriodInfo,
|
||||||
|
execution: ExecutionInfo,
|
||||||
|
) => {
|
||||||
|
if (tooltipTimeoutRef.current) {
|
||||||
|
clearTimeout(tooltipTimeoutRef.current);
|
||||||
|
}
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setTooltip({
|
||||||
|
jobName,
|
||||||
|
period,
|
||||||
|
execution,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellMouseLeave = () => {
|
||||||
|
tooltipTimeoutRef.current = setTimeout(() => {
|
||||||
|
setTooltip(null);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up tooltip timeout
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (tooltipTimeoutRef.current) {
|
||||||
|
clearTimeout(tooltipTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cell click -> detail panel
|
||||||
|
const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => {
|
||||||
|
// Toggle off if clicking same cell
|
||||||
|
if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) {
|
||||||
|
setSelectedCell(null);
|
||||||
|
setDetailExecutions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCell({ jobName, periodKey, periodLabel });
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailExecutions([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey);
|
||||||
|
setDetailExecutions(executions);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('구간 실행 이력 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDetail = () => {
|
||||||
|
setSelectedCell(null);
|
||||||
|
setDetailExecutions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
{VIEW_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setView(opt.value);
|
||||||
|
setLoading(true);
|
||||||
|
}}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
view === opt.value
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:bg-wing-accent/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToday}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
오늘
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period Label */}
|
||||||
|
<span className="text-sm font-semibold text-wing-text">
|
||||||
|
{periodLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Refresh */}
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="ml-auto px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 px-2">
|
||||||
|
{LEGEND_ITEMS.map((item) => (
|
||||||
|
<div key={item.status} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-wing-muted">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Grid */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
|
<EmptyState message="타임라인 데이터가 없습니다" sub="등록된 스케줄이 없거나 해당 기간에 실행 이력이 없습니다" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div
|
||||||
|
className="grid min-w-max"
|
||||||
|
style={{ gridTemplateColumns }}
|
||||||
|
>
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="sticky left-0 z-20 bg-wing-card border-b border-r border-wing-border px-3 py-2 text-xs font-semibold text-wing-muted">
|
||||||
|
작업명
|
||||||
|
</div>
|
||||||
|
{periods.map((period) => (
|
||||||
|
<div
|
||||||
|
key={period.key}
|
||||||
|
className="bg-wing-card border-b border-r border-wing-border px-2 py-2 text-xs font-medium text-wing-muted text-center whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Data Rows */}
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<>
|
||||||
|
{/* Job Name (sticky) */}
|
||||||
|
<div
|
||||||
|
key={`name-${schedule.jobName}`}
|
||||||
|
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
|
||||||
|
title={schedule.jobName}
|
||||||
|
>
|
||||||
|
{schedule.jobName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution Cells */}
|
||||||
|
{periods.map((period) => {
|
||||||
|
const exec = schedule.executions[period.key];
|
||||||
|
const hasExec = exec !== null && exec !== undefined;
|
||||||
|
const isSelected =
|
||||||
|
selectedCell?.jobName === schedule.jobName &&
|
||||||
|
selectedCell?.periodKey === period.key;
|
||||||
|
const running = hasExec && isRunning(exec.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`cell-${schedule.jobName}-${period.key}`}
|
||||||
|
className={`border-b border-r border-wing-border/50 p-1 cursor-pointer transition-all hover:opacity-80 ${
|
||||||
|
isSelected ? 'ring-2 ring-yellow-400 ring-inset' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
handleCellClick(schedule.jobName, period.key, period.label)
|
||||||
|
}
|
||||||
|
onMouseEnter={
|
||||||
|
hasExec
|
||||||
|
? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseLeave={hasExec ? handleCellMouseLeave : undefined}
|
||||||
|
>
|
||||||
|
{hasExec && (
|
||||||
|
<div
|
||||||
|
className={`w-full h-6 rounded ${running ? 'animate-pulse' : ''}`}
|
||||||
|
style={{ backgroundColor: getStatusColor(exec.status) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: tooltip.x,
|
||||||
|
top: tooltip.y - 8,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
|
||||||
|
<div className="font-semibold mb-1">{tooltip.jobName}</div>
|
||||||
|
<div className="space-y-0.5 text-gray-300">
|
||||||
|
<div>기간: {tooltip.period.label}</div>
|
||||||
|
<div>
|
||||||
|
상태:{' '}
|
||||||
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
style={{ color: getStatusColor(tooltip.execution.status) }}
|
||||||
|
>
|
||||||
|
{tooltip.execution.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tooltip.execution.startTime && (
|
||||||
|
<div>시작: {formatDateTime(tooltip.execution.startTime)}</div>
|
||||||
|
)}
|
||||||
|
{tooltip.execution.endTime && (
|
||||||
|
<div>종료: {formatDateTime(tooltip.execution.endTime)}</div>
|
||||||
|
)}
|
||||||
|
{tooltip.execution.executionId && (
|
||||||
|
<div>실행 ID: {tooltip.execution.executionId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-gray-900" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail Panel */}
|
||||||
|
{selectedCell && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-wing-text">
|
||||||
|
{selectedCell.jobName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-wing-muted mt-0.5">
|
||||||
|
구간: {selectedCell.periodLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeDetail}
|
||||||
|
className="px-3 py-1.5 text-xs text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<LoadingSpinner className="py-6" />
|
||||||
|
) : detailExecutions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message="해당 구간에 실행 이력이 없습니다"
|
||||||
|
icon="📭"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border">
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
실행 ID
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
시작 시간
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
종료 시간
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
소요 시간
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 px-3 text-xs font-semibold text-wing-muted">
|
||||||
|
상세
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detailExecutions.map((exec) => (
|
||||||
|
<tr
|
||||||
|
key={exec.executionId}
|
||||||
|
className="border-b border-wing-border/50 hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3 text-xs font-mono text-wing-text">
|
||||||
|
#{exec.executionId}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||||
|
{formatDateTime(exec.startTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||||
|
{formatDateTime(exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
||||||
|
{calculateDuration(exec.startTime, exec.endTime)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right">
|
||||||
|
<a
|
||||||
|
href={`/executions/${exec.executionId}`}
|
||||||
|
className="text-xs text-wing-accent hover:text-wing-accent font-medium"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/theme/base.css
Normal file
25
frontend/src/theme/base.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
background: var(--wing-bg);
|
||||||
|
color: var(--wing-text);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for dark mode */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--wing-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--wing-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--wing-accent);
|
||||||
|
}
|
||||||
66
frontend/src/theme/tokens.css
Normal file
66
frontend/src/theme/tokens.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/* Dark theme (default) */
|
||||||
|
:root,
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--wing-bg: #020617;
|
||||||
|
--wing-surface: #0f172a;
|
||||||
|
--wing-card: #1e293b;
|
||||||
|
--wing-border: #1e3a5f;
|
||||||
|
--wing-text: #e2e8f0;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #3b82f6;
|
||||||
|
--wing-danger: #ef4444;
|
||||||
|
--wing-warning: #f59e0b;
|
||||||
|
--wing-success: #22c55e;
|
||||||
|
--wing-glass: rgba(15, 23, 42, 0.92);
|
||||||
|
--wing-glass-dense: rgba(15, 23, 42, 0.95);
|
||||||
|
--wing-overlay: rgba(2, 6, 23, 0.42);
|
||||||
|
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
||||||
|
--wing-subtle: rgba(255, 255, 255, 0.03);
|
||||||
|
--wing-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--wing-input-bg: #0f172a;
|
||||||
|
--wing-input-border: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
[data-theme='light'] {
|
||||||
|
--wing-bg: #e2e8f0;
|
||||||
|
--wing-surface: #ffffff;
|
||||||
|
--wing-card: #f1f5f9;
|
||||||
|
--wing-border: #94a3b8;
|
||||||
|
--wing-text: #0f172a;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #2563eb;
|
||||||
|
--wing-danger: #dc2626;
|
||||||
|
--wing-warning: #d97706;
|
||||||
|
--wing-success: #16a34a;
|
||||||
|
--wing-glass: rgba(255, 255, 255, 0.92);
|
||||||
|
--wing-glass-dense: rgba(255, 255, 255, 0.95);
|
||||||
|
--wing-overlay: rgba(0, 0, 0, 0.25);
|
||||||
|
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
||||||
|
--wing-subtle: rgba(0, 0, 0, 0.03);
|
||||||
|
--wing-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
--wing-input-bg: #ffffff;
|
||||||
|
--wing-input-border: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-wing-bg: var(--wing-bg);
|
||||||
|
--color-wing-surface: var(--wing-surface);
|
||||||
|
--color-wing-card: var(--wing-card);
|
||||||
|
--color-wing-border: var(--wing-border);
|
||||||
|
--color-wing-text: var(--wing-text);
|
||||||
|
--color-wing-muted: var(--wing-muted);
|
||||||
|
--color-wing-accent: var(--wing-accent);
|
||||||
|
--color-wing-danger: var(--wing-danger);
|
||||||
|
--color-wing-warning: var(--wing-warning);
|
||||||
|
--color-wing-success: var(--wing-success);
|
||||||
|
--color-wing-glass: var(--wing-glass);
|
||||||
|
--color-wing-glass-dense: var(--wing-glass-dense);
|
||||||
|
--color-wing-overlay: var(--wing-overlay);
|
||||||
|
--color-wing-card-alpha: var(--wing-card-alpha);
|
||||||
|
--color-wing-subtle: var(--wing-subtle);
|
||||||
|
--color-wing-hover: var(--wing-hover);
|
||||||
|
--color-wing-input-bg: var(--wing-input-bg);
|
||||||
|
--color-wing-input-border: var(--wing-input-border);
|
||||||
|
--font-sans: 'Noto Sans KR', sans-serif;
|
||||||
|
}
|
||||||
153
frontend/src/utils/cronPreview.ts
Normal file
153
frontend/src/utils/cronPreview.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Quartz 형식 Cron 표현식의 다음 실행 시간을 계산한다.
|
||||||
|
* 형식: 초 분 시 일 월 요일
|
||||||
|
*/
|
||||||
|
export function getNextExecutions(cron: string, count: number): Date[] {
|
||||||
|
const parts = cron.trim().split(/\s+/);
|
||||||
|
if (parts.length < 6) return [];
|
||||||
|
|
||||||
|
const [secField, minField, hourField, dayField, monthField, dowField] = parts;
|
||||||
|
|
||||||
|
if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = parseField(secField, 0, 59);
|
||||||
|
const minutes = parseField(minField, 0, 59);
|
||||||
|
const hours = parseField(hourField, 0, 23);
|
||||||
|
const daysOfMonth = parseField(dayField, 1, 31);
|
||||||
|
const months = parseField(monthField, 1, 12);
|
||||||
|
const daysOfWeek = parseDowField(dowField);
|
||||||
|
|
||||||
|
if (!seconds || !minutes || !hours || !months) return [];
|
||||||
|
|
||||||
|
const results: Date[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1);
|
||||||
|
cursor.setMilliseconds(0);
|
||||||
|
|
||||||
|
const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
while (results.length < count && cursor.getTime() <= limit.getTime()) {
|
||||||
|
const month = cursor.getMonth() + 1;
|
||||||
|
if (!months.includes(month)) {
|
||||||
|
cursor.setMonth(cursor.getMonth() + 1, 1);
|
||||||
|
cursor.setHours(0, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = cursor.getDate();
|
||||||
|
const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true;
|
||||||
|
const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true;
|
||||||
|
|
||||||
|
const needDayCheck = dayField !== '?' && dowField !== '?';
|
||||||
|
const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches;
|
||||||
|
|
||||||
|
if (!dayOk) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
cursor.setHours(0, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = cursor.getHours();
|
||||||
|
if (!hours.includes(hour)) {
|
||||||
|
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minute = cursor.getMinutes();
|
||||||
|
if (!minutes.includes(minute)) {
|
||||||
|
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const second = cursor.getSeconds();
|
||||||
|
if (!seconds.includes(second)) {
|
||||||
|
cursor.setSeconds(cursor.getSeconds() + 1, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(new Date(cursor));
|
||||||
|
cursor.setSeconds(cursor.getSeconds() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnsupportedToken(field: string): boolean {
|
||||||
|
return /[LW#]/.test(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseField(field: string, min: number, max: number): number[] | null {
|
||||||
|
if (field === '?') return null;
|
||||||
|
if (field === '*') return range(min, max);
|
||||||
|
|
||||||
|
const values = new Set<number>();
|
||||||
|
|
||||||
|
for (const part of field.split(',')) {
|
||||||
|
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
||||||
|
if (stepMatch) {
|
||||||
|
const [, base, stepStr] = stepMatch;
|
||||||
|
const step = parseInt(stepStr, 10);
|
||||||
|
let start = min;
|
||||||
|
let end = max;
|
||||||
|
|
||||||
|
if (base === '*') {
|
||||||
|
start = min;
|
||||||
|
} else if (base.includes('-')) {
|
||||||
|
const [lo, hi] = base.split('-').map(Number);
|
||||||
|
start = lo;
|
||||||
|
end = hi;
|
||||||
|
} else {
|
||||||
|
start = parseInt(base, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let v = start; v <= end; v += step) {
|
||||||
|
if (v >= min && v <= max) values.add(v);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
||||||
|
if (rangeMatch) {
|
||||||
|
const lo = parseInt(rangeMatch[1], 10);
|
||||||
|
const hi = parseInt(rangeMatch[2], 10);
|
||||||
|
for (let v = lo; v <= hi; v++) {
|
||||||
|
if (v >= min && v <= max) values.add(v);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
if (!isNaN(num) && num >= min && num <= max) {
|
||||||
|
values.add(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDowField(field: string): number[] | null {
|
||||||
|
if (field === '?' || field === '*') return null;
|
||||||
|
|
||||||
|
const dayMap: Record<string, string> = {
|
||||||
|
SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6',
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized = field.toUpperCase();
|
||||||
|
for (const [name, num] of Object.entries(dayMap)) {
|
||||||
|
normalized = normalized.replace(new RegExp(name, 'g'), num);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT
|
||||||
|
const parsed = parseField(normalized, 1, 7);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
return parsed.map((v) => v - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function range(min: number, max: number): number[] {
|
||||||
|
const result: number[] = [];
|
||||||
|
for (let i = min; i <= max; i++) result.push(i);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
58
frontend/src/utils/formatters.ts
Normal file
58
frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export function formatDateTime(dateTimeStr: string | null | undefined): string {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeShort(dateTimeStr: string | null | undefined): string {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${m}/${d} ${h}:${min}`;
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number | null | undefined): string {
|
||||||
|
if (ms == null || ms < 0) return '-';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||||
|
if (minutes > 0) return `${minutes}분 ${seconds}초`;
|
||||||
|
return `${seconds}초`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDuration(
|
||||||
|
startTime: string | null | undefined,
|
||||||
|
endTime: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!startTime) return '-';
|
||||||
|
const start = new Date(startTime).getTime();
|
||||||
|
if (isNaN(start)) return '-';
|
||||||
|
|
||||||
|
if (!endTime) return '실행 중...';
|
||||||
|
const end = new Date(endTime).getTime();
|
||||||
|
if (isNaN(end)) return '-';
|
||||||
|
|
||||||
|
return formatDuration(end - start);
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/snp-api/api': {
|
||||||
|
target: 'http://localhost:8041',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: '/snp-api/',
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
35
pom.xml
35
pom.xml
@ -111,6 +111,12 @@
|
|||||||
<version>2.3.0</version>
|
<version>2.3.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Kafka -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.kafka</groupId>
|
||||||
|
<artifactId>spring-kafka</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine Cache -->
|
<!-- Caffeine Cache -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
@ -154,6 +160,35 @@
|
|||||||
</excludes>
|
</excludes>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>1.15.1</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<nodeVersion>v20.19.0</nodeVersion>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<goals><goal>install-node-and-npm</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
|||||||
149
sql/chnprmship-cache-diag.sql
Normal file
149
sql/chnprmship-cache-diag.sql
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- ChnPrmShip 캐시 검증 진단 쿼리
|
||||||
|
-- 대상: t_std_snp_data.ais_target (일별 파티션)
|
||||||
|
-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 0. 대상 MMSI 임시 테이블 생성
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
|
||||||
|
|
||||||
|
-- psql에서 실행:
|
||||||
|
-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
|
||||||
|
COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
|
||||||
|
(SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
|
||||||
|
ROUND(COUNT(DISTINCT a.mmsi) * 100.0
|
||||||
|
/ NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
|
||||||
|
-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT COUNT(*) AS cached_count,
|
||||||
|
MIN(message_timestamp) AS oldest_cached,
|
||||||
|
MAX(message_timestamp) AS newest_cached,
|
||||||
|
NOW() - MAX(message_timestamp) AS newest_age
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||||
|
) latest;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT DISTINCT ON (a.mmsi)
|
||||||
|
a.mmsi,
|
||||||
|
a.message_timestamp,
|
||||||
|
a.name,
|
||||||
|
a.vessel_type,
|
||||||
|
a.lat,
|
||||||
|
a.lon,
|
||||||
|
a.sog,
|
||||||
|
a.cog,
|
||||||
|
a.heading,
|
||||||
|
NOW() - a.message_timestamp AS data_age
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT t.mmsi AS missing_mmsi
|
||||||
|
FROM tmp_chn_mmsi t
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT mmsi
|
||||||
|
FROM t_std_snp_data.ais_target
|
||||||
|
WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
|
||||||
|
AND message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
) a ON t.mmsi = a.mmsi
|
||||||
|
WHERE a.mmsi IS NULL
|
||||||
|
ORDER BY t.mmsi;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. 시간대별 분포 (2일 기준 세부 확인)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
'6시간 이내' AS time_range,
|
||||||
|
COUNT(DISTINCT mmsi) AS distinct_mmsi
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '12시간 이내', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '1일 이내', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '2일 이내', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 6. 파티션별 대상 데이터 분포
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
tableoid::regclass AS partition_name,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
COUNT(DISTINCT mmsi) AS distinct_mmsi,
|
||||||
|
MIN(message_timestamp) AS min_ts,
|
||||||
|
MAX(message_timestamp) AS max_ts
|
||||||
|
FROM t_std_snp_data.ais_target a
|
||||||
|
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||||
|
GROUP BY tableoid::regclass
|
||||||
|
ORDER BY max_ts DESC;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 7. 전체 ais_target 파티션 현황
|
||||||
|
-- ============================================================
|
||||||
|
SELECT
|
||||||
|
c.relname AS partition_name,
|
||||||
|
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
|
||||||
|
s.n_live_tup AS estimated_rows
|
||||||
|
FROM pg_inherits i
|
||||||
|
JOIN pg_class c ON c.oid = i.inhrelid
|
||||||
|
JOIN pg_stat_user_tables s ON s.relid = c.oid
|
||||||
|
WHERE i.inhparent = 't_std_snp_data.ais_target'::regclass
|
||||||
|
ORDER BY c.relname DESC;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 정리
|
||||||
|
-- ============================================================
|
||||||
|
DROP TABLE IF EXISTS tmp_chn_mmsi;
|
||||||
@ -2,10 +2,11 @@ package com.snp.batch;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@ConfigurationPropertiesScan
|
@ConfigurationPropertiesScan
|
||||||
public class SnpBatchApplication {
|
public class SnpBatchApplication {
|
||||||
|
|||||||
@ -29,9 +29,23 @@ public abstract class BaseJdbcRepository<T, ID> {
|
|||||||
protected final JdbcTemplate jdbcTemplate;
|
protected final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블명 반환 (하위 클래스에서 구현)
|
* 대상 스키마 이름 반환 (하위 클래스에서 구현)
|
||||||
|
* application.yml의 app.batch.target-schema.name 값을 @Value로 주입받아 반환
|
||||||
*/
|
*/
|
||||||
protected abstract String getTableName();
|
protected abstract String getTargetSchema();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블명만 반환 (스키마 제외, 하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract String getSimpleTableName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 테이블명 반환 (스키마.테이블)
|
||||||
|
* 하위 클래스에서는 getSimpleTableName()만 구현하면 됨
|
||||||
|
*/
|
||||||
|
protected String getTableName() {
|
||||||
|
return getTargetSchema() + "." + getSimpleTableName();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID 컬럼명 반환 (기본값: "id")
|
* ID 컬럼명 반환 (기본값: "id")
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
package com.snp.batch.common.util;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
public class JsonChangeDetector {
|
|
||||||
|
|
||||||
// Map으로 변환 시 사용할 ObjectMapper (표준 Mapper 사용)
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
||||||
|
|
||||||
// 해시 비교에서 제외할 필드 목록 (DataSetVersion 등)
|
|
||||||
// 이 목록은 모든 JSON 계층에 걸쳐 적용됩니다.
|
|
||||||
private static final java.util.Set<String> EXCLUDE_KEYS =
|
|
||||||
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
|
|
||||||
// =========================================================================
|
|
||||||
private static final Map<String, String> LIST_SORT_KEYS;
|
|
||||||
static {
|
|
||||||
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
|
|
||||||
Map<String, String> map = new HashMap<>();
|
|
||||||
// List 필드명 // 정렬 기준 복합 키 (JSON 필드명, 쉼표로 구분)
|
|
||||||
map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence");
|
|
||||||
map.put("CrewList", "LRNO,Shipname,Nationality");
|
|
||||||
map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode");
|
|
||||||
map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence");
|
|
||||||
map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence");
|
|
||||||
map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence");
|
|
||||||
map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode");
|
|
||||||
map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode");
|
|
||||||
map.put("NameHistory", "Sequence,EffectiveDate");
|
|
||||||
map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence");
|
|
||||||
map.put("PandIHistory", "PandIClubCode,EffectiveDate");
|
|
||||||
map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo");
|
|
||||||
map.put("IceClass", "IceClassCode");
|
|
||||||
map.put("SafetyManagementCertificateHistory", "Sequence");
|
|
||||||
map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence");
|
|
||||||
map.put("SurveyDatesHistory", "ClassSocietyCode");
|
|
||||||
map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType");
|
|
||||||
map.put("SisterShipLinks", "LinkedLRNO");
|
|
||||||
map.put("StatusHistory", "Sequence,StatusCode,StatusDate");
|
|
||||||
map.put("SpecialFeature", "Sequence,SpecialFeatureCode");
|
|
||||||
map.put("Thrusters", "Sequence");
|
|
||||||
map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status");
|
|
||||||
map.put("CompanyComplianceDetails", "OwCode");
|
|
||||||
map.put("CompanyVesselRelationships", "LRNO");
|
|
||||||
map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate");
|
|
||||||
|
|
||||||
LIST_SORT_KEYS = Collections.unmodifiableMap(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직
|
|
||||||
// =========================================================================
|
|
||||||
/**
|
|
||||||
* JSON 문자열을 Map으로 변환하고, 특정 키를 제거하며, 키 순서가 정렬된 상태로 만듭니다.
|
|
||||||
* @param jsonString API 응답 또는 DB에서 읽은 JSON 문자열
|
|
||||||
* @return 필터링되고 정렬된 Map 객체
|
|
||||||
*/
|
|
||||||
public static Map<String, Object> jsonToSortedFilteredMap(String jsonString) {
|
|
||||||
if (jsonString == null || jsonString.trim().isEmpty()) {
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Map<String, Object>으로 1차 변환합니다. (순서 보장 안됨)
|
|
||||||
Map<String, Object> rawMap = MAPPER.readValue(jsonString,
|
|
||||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
|
|
||||||
|
|
||||||
// 2. 재귀 함수를 호출하여 키를 제거하고 TreeMap(키 순서 정렬)으로 깊은 복사합니다.
|
|
||||||
return deepFilterAndSort(rawMap);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error converting JSON to filtered Map: " + e.getMessage());
|
|
||||||
// 예외 발생 시 빈 Map 반환
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map을 재귀적으로 탐색하며 제외 키를 제거하고 TreeMap(알파벳 순서)으로 변환합니다.
|
|
||||||
*/
|
|
||||||
private static Map<String, Object> deepFilterAndSort(Map<String, Object> rawMap) {
|
|
||||||
// Map을 TreeMap으로 생성하여 키 순서를 알파벳 순으로 강제 정렬합니다.
|
|
||||||
Map<String, Object> sortedMap = new TreeMap<>();
|
|
||||||
|
|
||||||
for (Map.Entry<String, Object> entry : rawMap.entrySet()) {
|
|
||||||
String key = entry.getKey();
|
|
||||||
Object value = entry.getValue();
|
|
||||||
|
|
||||||
// 🔑 1. 제외할 키 값인지 확인
|
|
||||||
if (EXCLUDE_KEYS.contains(key)) {
|
|
||||||
continue; // 제외
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 값의 타입에 따라 재귀 처리
|
|
||||||
if (value instanceof Map) {
|
|
||||||
// 재귀 호출: 하위 Map을 필터링하고 정렬
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> subMap = (Map<String, Object>) value;
|
|
||||||
sortedMap.put(key, deepFilterAndSort(subMap));
|
|
||||||
} else if (value instanceof List) {
|
|
||||||
// List 처리: List 내부의 Map 요소만 재귀 호출
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Object> rawList = (List<Object>) value;
|
|
||||||
List<Object> filteredList = new ArrayList<>();
|
|
||||||
|
|
||||||
// 1. List 내부의 Map 요소들을 재귀적으로 필터링/정렬하여 filteredList에 추가
|
|
||||||
for (Object item : rawList) {
|
|
||||||
if (item instanceof Map) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> itemMap = (Map<String, Object>) item;
|
|
||||||
// List의 요소인 Map도 필터링하고 정렬 (Map의 필드 순서 정렬)
|
|
||||||
filteredList.add(deepFilterAndSort(itemMap));
|
|
||||||
} else {
|
|
||||||
filteredList.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직)
|
|
||||||
String listFieldName = entry.getKey();
|
|
||||||
String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 키 문자열
|
|
||||||
|
|
||||||
if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
|
|
||||||
// 복합 키 문자열을 개별 키 배열로 분리
|
|
||||||
final String[] sortKeys = sortKeysString.split(",");
|
|
||||||
|
|
||||||
// Map 요소를 가진 리스트인 경우에만 정렬 실행
|
|
||||||
try {
|
|
||||||
Collections.sort(filteredList, new Comparator<Object>() {
|
|
||||||
@Override
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public int compare(Object o1, Object o2) {
|
|
||||||
Map<String, Object> map1 = (Map<String, Object>) o1;
|
|
||||||
Map<String, Object> map2 = (Map<String, Object>) o2;
|
|
||||||
|
|
||||||
// 복합 키(sortKeys)를 순서대로 순회하며 비교
|
|
||||||
for (String rawSortKey : sortKeys) {
|
|
||||||
// 키의 공백 제거
|
|
||||||
String sortKey = rawSortKey.trim();
|
|
||||||
|
|
||||||
Object key1 = map1.get(sortKey);
|
|
||||||
Object key2 = map2.get(sortKey);
|
|
||||||
|
|
||||||
// null 값 처리 로직
|
|
||||||
if (key1 == null && key2 == null) {
|
|
||||||
continue; // 두 값이 동일하므로 다음 키로 이동
|
|
||||||
}
|
|
||||||
if (key1 == null) {
|
|
||||||
// key1이 null이고 key2는 null이 아니면, key2가 더 크다고 (뒤 순서) 간주하고 1 반환
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (key2 == null) {
|
|
||||||
// key2가 null이고 key1은 null이 아니면, key1이 더 크다고 (뒤 순서) 간주하고 -1 반환
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
|
|
||||||
int comparisonResult = key1.toString().compareTo(key2.toString());
|
|
||||||
|
|
||||||
// 현재 키에서 순서가 결정되면 즉시 반환
|
|
||||||
if (comparisonResult != 0) {
|
|
||||||
return comparisonResult;
|
|
||||||
}
|
|
||||||
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 키를 비교해도 동일한 경우
|
|
||||||
// 이 경우 두 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다.
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
|
|
||||||
// 정렬 실패 시 원래 순서 유지 (filteredList 상태 유지)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortedMap.put(key, filteredList);
|
|
||||||
} else {
|
|
||||||
// String, Number 등 기본 타입은 그대로 추가
|
|
||||||
sortedMap.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sortedMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 2. 해시 생성 로직
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터링되고 정렬된 Map의 문자열 표현을 기반으로 SHA-256 해시를 생성합니다.
|
|
||||||
*/
|
|
||||||
public static String getSha256HashFromMap(Map<String, Object> sortedMap) {
|
|
||||||
// 1. Map을 String으로 변환: TreeMap 덕분에 toString() 결과가 항상 동일한 순서를 가집니다.
|
|
||||||
String mapString = sortedMap.toString();
|
|
||||||
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] hash = digest.digest(mapString.getBytes("UTF-8"));
|
|
||||||
|
|
||||||
// 바이트 배열을 16진수 문자열로 변환
|
|
||||||
StringBuilder hexString = new StringBuilder();
|
|
||||||
for (byte b : hash) {
|
|
||||||
String hex = Integer.toHexString(0xff & b);
|
|
||||||
if (hex.length() == 1) hexString.append('0');
|
|
||||||
hexString.append(hex);
|
|
||||||
}
|
|
||||||
return hexString.toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error generating hash: " + e.getMessage());
|
|
||||||
return "HASH_ERROR";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 3. 해시값 비교 로직
|
|
||||||
// =========================================================================
|
|
||||||
public static boolean isChanged(String previousHash, String currentHash) {
|
|
||||||
// DB 해시가 null인 경우 (첫 Insert)는 변경된 것으로 간주
|
|
||||||
if (previousHash == null || previousHash.isEmpty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 해시값이 다르면 변경된 것으로 간주
|
|
||||||
return !Objects.equals(previousHash, currentHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package com.snp.batch.common.util;
|
|
||||||
|
|
||||||
public class SafeGetDataUtil {
|
|
||||||
private String safeGetString(String value) {
|
|
||||||
if (value == null || value.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Double safeGetDouble(String value) {
|
|
||||||
if (value == null || value.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Double.parseDouble(value);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long safeGetLong(String value) {
|
|
||||||
if (value == null || value.trim().isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Long.parseLong(value.trim());
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.snp.batch.common.web;
|
package com.snp.batch.common.web;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@ -14,26 +15,19 @@ import lombok.NoArgsConstructor;
|
|||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "공통 API 응답 래퍼")
|
||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
/**
|
@Schema(description = "성공 여부", example = "true")
|
||||||
* 성공 여부
|
|
||||||
*/
|
|
||||||
private boolean success;
|
private boolean success;
|
||||||
|
|
||||||
/**
|
@Schema(description = "응답 메시지", example = "Success")
|
||||||
* 메시지
|
|
||||||
*/
|
|
||||||
private String message;
|
private String message;
|
||||||
|
|
||||||
/**
|
@Schema(description = "응답 데이터")
|
||||||
* 응답 데이터
|
|
||||||
*/
|
|
||||||
private T data;
|
private T data;
|
||||||
|
|
||||||
/**
|
@Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true)
|
||||||
* 에러 코드 (실패 시)
|
|
||||||
*/
|
|
||||||
private String errorCode;
|
private String errorCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,300 +0,0 @@
|
|||||||
package com.snp.batch.common.web.controller;
|
|
||||||
|
|
||||||
import com.snp.batch.common.web.ApiResponse;
|
|
||||||
import com.snp.batch.common.web.service.BaseService;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 REST Controller의 공통 베이스 클래스
|
|
||||||
* CRUD API의 일관된 구조 제공
|
|
||||||
*
|
|
||||||
* 이 클래스는 추상 클래스이므로 @Tag를 붙이지 않습니다.
|
|
||||||
* 하위 클래스에서 @Tag를 정의하면 모든 엔드포인트가 해당 태그로 그룹화됩니다.
|
|
||||||
*
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public abstract class BaseController<D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service 반환 (하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract BaseService<?, D, ID> getService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리소스 이름 반환 (로깅용)
|
|
||||||
*/
|
|
||||||
protected abstract String getResourceName();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 생성
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 생성",
|
|
||||||
description = "새로운 리소스를 생성합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "생성 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PostMapping
|
|
||||||
public ResponseEntity<ApiResponse<D>> create(
|
|
||||||
@Parameter(description = "생성할 리소스 데이터", required = true)
|
|
||||||
@RequestBody D dto) {
|
|
||||||
log.info("{} 생성 요청", getResourceName());
|
|
||||||
try {
|
|
||||||
D created = getService().create(dto);
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(getResourceName() + " created successfully", created)
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 생성 실패", getResourceName(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to create " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 조회
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 조회",
|
|
||||||
description = "ID로 특정 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "리소스 없음"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public ResponseEntity<ApiResponse<D>> getById(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id) {
|
|
||||||
log.info("{} 조회 요청: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
return getService().findById(id)
|
|
||||||
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
|
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 조회 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to get " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 조회
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "전체 리소스 조회",
|
|
||||||
description = "모든 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping
|
|
||||||
public ResponseEntity<ApiResponse<List<D>>> getAll() {
|
|
||||||
log.info("{} 전체 조회 요청", getResourceName());
|
|
||||||
try {
|
|
||||||
List<D> list = getService().findAll();
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(list));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 전체 조회 실패", getResourceName(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to get all " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이징 조회 (JDBC 기반)
|
|
||||||
*
|
|
||||||
* @param offset 시작 위치 (기본값: 0)
|
|
||||||
* @param limit 조회 개수 (기본값: 20)
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "페이징 조회",
|
|
||||||
description = "페이지 단위로 리소스를 조회합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/page")
|
|
||||||
public ResponseEntity<ApiResponse<List<D>>> getPage(
|
|
||||||
@Parameter(description = "시작 위치 (0부터 시작)", example = "0")
|
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
|
||||||
@Parameter(description = "조회 개수", example = "20")
|
|
||||||
@RequestParam(defaultValue = "20") int limit) {
|
|
||||||
log.info("{} 페이징 조회 요청: offset={}, limit={}",
|
|
||||||
getResourceName(), offset, limit);
|
|
||||||
try {
|
|
||||||
List<D> list = getService().findAll(offset, limit);
|
|
||||||
long total = getService().count();
|
|
||||||
|
|
||||||
// 페이징 정보를 포함한 응답
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success("Retrieved " + list.size() + " items (total: " + total + ")", list)
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 페이징 조회 실패", getResourceName(), e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to get page of " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 수정
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 수정",
|
|
||||||
description = "기존 리소스를 수정합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "수정 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "리소스 없음"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
public ResponseEntity<ApiResponse<D>> update(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id,
|
|
||||||
@Parameter(description = "수정할 리소스 데이터", required = true)
|
|
||||||
@RequestBody D dto) {
|
|
||||||
log.info("{} 수정 요청: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
D updated = getService().update(id, dto);
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(getResourceName() + " updated successfully", updated)
|
|
||||||
);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 수정 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to update " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 삭제
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 삭제",
|
|
||||||
description = "기존 리소스를 삭제합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "삭제 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "리소스 없음"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
public ResponseEntity<ApiResponse<Void>> delete(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id) {
|
|
||||||
log.info("{} 삭제 요청: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
getService().deleteById(id);
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(getResourceName() + " deleted successfully", null)
|
|
||||||
);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 삭제 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to delete " + getResourceName() + ": " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 존재 여부 확인
|
|
||||||
*/
|
|
||||||
@Operation(
|
|
||||||
summary = "리소스 존재 확인",
|
|
||||||
description = "특정 ID의 리소스가 존재하는지 확인합니다",
|
|
||||||
responses = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "확인 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "500",
|
|
||||||
description = "서버 오류"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@GetMapping("/{id}/exists")
|
|
||||||
public ResponseEntity<ApiResponse<Boolean>> exists(
|
|
||||||
@Parameter(description = "리소스 ID", required = true)
|
|
||||||
@PathVariable ID id) {
|
|
||||||
log.debug("{} 존재 여부 확인: ID={}", getResourceName(), id);
|
|
||||||
try {
|
|
||||||
boolean exists = getService().existsById(id);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(exists));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("{} 존재 여부 확인 실패: ID={}", getResourceName(), id, e);
|
|
||||||
return ResponseEntity.internalServerError().body(
|
|
||||||
ApiResponse.error("Failed to check existence: " + e.getMessage())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package com.snp.batch.common.web.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 DTO의 공통 베이스 클래스
|
|
||||||
* 생성/수정 정보 등 공통 필드
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public abstract class BaseDto {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 생성 일시
|
|
||||||
*/
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수정 일시
|
|
||||||
*/
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 생성자
|
|
||||||
*/
|
|
||||||
private String createdBy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수정자
|
|
||||||
*/
|
|
||||||
private String updatedBy;
|
|
||||||
}
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 하이브리드 서비스 Base 클래스 (DB 캐시 + 외부 API 프록시)
|
|
||||||
*
|
|
||||||
* 사용 시나리오:
|
|
||||||
* 1. 클라이언트 요청 → DB 조회 (캐시 Hit)
|
|
||||||
* - 캐시 데이터 유효 시 즉시 반환
|
|
||||||
* 2. 캐시 Miss 또는 만료 시
|
|
||||||
* - 외부 서비스 API 호출
|
|
||||||
* - DB에 저장 (캐시 갱신)
|
|
||||||
* - 클라이언트에게 반환
|
|
||||||
*
|
|
||||||
* 장점:
|
|
||||||
* - 빠른 응답 (DB 캐시)
|
|
||||||
* - 외부 서비스 장애 시에도 캐시 데이터 제공 가능
|
|
||||||
* - 외부 API 호출 횟수 감소 (비용 절감)
|
|
||||||
*
|
|
||||||
* @param <T> Entity 타입
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public abstract class BaseHybridService<T, D, ID> extends BaseServiceImpl<T, D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebClient 반환 (하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract WebClient getWebClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 서비스 이름 반환
|
|
||||||
*/
|
|
||||||
protected abstract String getExternalServiceName();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 유효 시간 (초)
|
|
||||||
* 기본값: 300초 (5분)
|
|
||||||
*/
|
|
||||||
protected long getCacheTtlSeconds() {
|
|
||||||
return 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청 타임아웃
|
|
||||||
*/
|
|
||||||
protected Duration getTimeout() {
|
|
||||||
return Duration.ofSeconds(30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 하이브리드 조회: DB 캐시 우선, 없으면 외부 API 호출
|
|
||||||
*
|
|
||||||
* @param id 조회 키
|
|
||||||
* @return DTO
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public D findByIdHybrid(ID id) {
|
|
||||||
log.info("[하이브리드] ID로 조회: {}", id);
|
|
||||||
|
|
||||||
// 1. DB 캐시 조회
|
|
||||||
Optional<D> cached = findById(id);
|
|
||||||
|
|
||||||
if (cached.isPresent()) {
|
|
||||||
// 캐시 유효성 검증
|
|
||||||
if (isCacheValid(cached.get())) {
|
|
||||||
log.info("[하이브리드] 캐시 Hit - DB에서 반환");
|
|
||||||
return cached.get();
|
|
||||||
} else {
|
|
||||||
log.info("[하이브리드] 캐시 만료 - 외부 API 호출");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info("[하이브리드] 캐시 Miss - 외부 API 호출");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 외부 API 호출
|
|
||||||
try {
|
|
||||||
D externalData = fetchFromExternalApi(id);
|
|
||||||
|
|
||||||
// 3. DB 저장 (캐시 갱신)
|
|
||||||
T entity = toEntity(externalData);
|
|
||||||
T saved = getRepository().save(entity);
|
|
||||||
|
|
||||||
log.info("[하이브리드] 외부 데이터 DB 저장 완료");
|
|
||||||
return toDto(saved);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[하이브리드] 외부 API 호출 실패: {}", e.getMessage());
|
|
||||||
|
|
||||||
// 4. 외부 API 실패 시 만료된 캐시라도 반환 (Fallback)
|
|
||||||
if (cached.isPresent()) {
|
|
||||||
log.warn("[하이브리드] Fallback - 만료된 캐시 반환");
|
|
||||||
return cached.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException("데이터 조회 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API에서 데이터 조회 (하위 클래스에서 구현)
|
|
||||||
*
|
|
||||||
* @param id 조회 키
|
|
||||||
* @return DTO
|
|
||||||
*/
|
|
||||||
protected abstract D fetchFromExternalApi(ID id) throws Exception;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 유효성 검증
|
|
||||||
* 기본 구현: updated_at 기준으로 TTL 체크
|
|
||||||
*
|
|
||||||
* @param dto 캐시 데이터
|
|
||||||
* @return 유효 여부
|
|
||||||
*/
|
|
||||||
protected boolean isCacheValid(D dto) {
|
|
||||||
// BaseDto를 상속한 경우 updatedAt 체크
|
|
||||||
try {
|
|
||||||
LocalDateTime updatedAt = extractUpdatedAt(dto);
|
|
||||||
if (updatedAt == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
long elapsedSeconds = Duration.between(updatedAt, now).getSeconds();
|
|
||||||
|
|
||||||
return elapsedSeconds < getCacheTtlSeconds();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("캐시 유효성 검증 실패 - 항상 최신 데이터 조회: {}", e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO에서 updatedAt 추출 (하위 클래스에서 오버라이드 가능)
|
|
||||||
*/
|
|
||||||
protected LocalDateTime extractUpdatedAt(D dto) {
|
|
||||||
// 기본 구현: 항상 캐시 무효 (외부 API 호출)
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 강제 캐시 갱신 (외부 API 호출 강제)
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public D refreshCache(ID id) throws Exception {
|
|
||||||
log.info("[하이브리드] 캐시 강제 갱신: {}", id);
|
|
||||||
|
|
||||||
D externalData = fetchFromExternalApi(id);
|
|
||||||
T entity = toEntity(externalData);
|
|
||||||
T saved = getRepository().save(entity);
|
|
||||||
|
|
||||||
return toDto(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API GET 요청
|
|
||||||
*/
|
|
||||||
protected <RES> RES callExternalGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
|
||||||
log.info("[{}] GET 요청: endpoint={}", getExternalServiceName(), endpoint);
|
|
||||||
|
|
||||||
return getWebClient()
|
|
||||||
.get()
|
|
||||||
.uri(uriBuilder -> {
|
|
||||||
uriBuilder.path(endpoint);
|
|
||||||
if (params != null) {
|
|
||||||
params.forEach(uriBuilder::queryParam);
|
|
||||||
}
|
|
||||||
return uriBuilder.build();
|
|
||||||
})
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API POST 요청
|
|
||||||
*/
|
|
||||||
protected <REQ, RES> RES callExternalPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
|
||||||
log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint);
|
|
||||||
|
|
||||||
return getWebClient()
|
|
||||||
.post()
|
|
||||||
.uri(endpoint)
|
|
||||||
.bodyValue(requestBody)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 프록시 서비스 Base 클래스
|
|
||||||
*
|
|
||||||
* 목적: 해외 외부 서비스를 국내에서 우회 접근할 수 있도록 프록시 역할 수행
|
|
||||||
*
|
|
||||||
* 사용 시나리오:
|
|
||||||
* - 외부 서비스가 해외에 있고 국내 IP에서만 접근 가능
|
|
||||||
* - 클라이언트 A → 우리 서버 (국내) → 외부 서비스 (해외) → 응답 전달
|
|
||||||
*
|
|
||||||
* 장점:
|
|
||||||
* - 실시간 데이터 제공 (DB 캐시 없이)
|
|
||||||
* - 외부 서비스의 최신 데이터 보장
|
|
||||||
* - DB 저장 부담 없음
|
|
||||||
*
|
|
||||||
* @param <REQ> 요청 DTO 타입
|
|
||||||
* @param <RES> 응답 DTO 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public abstract class BaseProxyService<REQ, RES> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebClient 반환 (하위 클래스에서 구현)
|
|
||||||
* 외부 서비스별로 인증, Base URL 등 설정
|
|
||||||
*/
|
|
||||||
protected abstract WebClient getWebClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 서비스 이름 반환 (로깅용)
|
|
||||||
*/
|
|
||||||
protected abstract String getServiceName();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청 타임아웃 (밀리초)
|
|
||||||
* 기본값: 30초
|
|
||||||
*/
|
|
||||||
protected Duration getTimeout() {
|
|
||||||
return Duration.ofSeconds(30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET 요청 프록시
|
|
||||||
*
|
|
||||||
* @param endpoint 엔드포인트 경로 (예: "/api/ships")
|
|
||||||
* @param params 쿼리 파라미터
|
|
||||||
* @param responseType 응답 클래스 타입
|
|
||||||
* @return 외부 서비스 응답
|
|
||||||
*/
|
|
||||||
public RES proxyGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
|
||||||
log.info("[{}] GET 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
|
||||||
|
|
||||||
try {
|
|
||||||
WebClient.RequestHeadersSpec<?> spec = getWebClient()
|
|
||||||
.get()
|
|
||||||
.uri(uriBuilder -> {
|
|
||||||
uriBuilder.path(endpoint);
|
|
||||||
if (params != null) {
|
|
||||||
params.forEach(uriBuilder::queryParam);
|
|
||||||
}
|
|
||||||
return uriBuilder.build();
|
|
||||||
});
|
|
||||||
|
|
||||||
RES response = spec.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] 응답 성공", getServiceName());
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST 요청 프록시
|
|
||||||
*
|
|
||||||
* @param endpoint 엔드포인트 경로
|
|
||||||
* @param requestBody 요청 본문
|
|
||||||
* @param responseType 응답 클래스 타입
|
|
||||||
* @return 외부 서비스 응답
|
|
||||||
*/
|
|
||||||
public RES proxyPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
|
||||||
log.info("[{}] POST 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
|
||||||
|
|
||||||
try {
|
|
||||||
RES response = getWebClient()
|
|
||||||
.post()
|
|
||||||
.uri(endpoint)
|
|
||||||
.bodyValue(requestBody)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] 응답 성공", getServiceName());
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT 요청 프록시
|
|
||||||
*/
|
|
||||||
public RES proxyPut(String endpoint, REQ requestBody, Class<RES> responseType) {
|
|
||||||
log.info("[{}] PUT 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
|
||||||
|
|
||||||
try {
|
|
||||||
RES response = getWebClient()
|
|
||||||
.put()
|
|
||||||
.uri(endpoint)
|
|
||||||
.bodyValue(requestBody)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(responseType)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] 응답 성공", getServiceName());
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE 요청 프록시
|
|
||||||
*/
|
|
||||||
public void proxyDelete(String endpoint, Map<String, String> params) {
|
|
||||||
log.info("[{}] DELETE 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
|
||||||
|
|
||||||
try {
|
|
||||||
getWebClient()
|
|
||||||
.delete()
|
|
||||||
.uri(uriBuilder -> {
|
|
||||||
uriBuilder.path(endpoint);
|
|
||||||
if (params != null) {
|
|
||||||
params.forEach(uriBuilder::queryParam);
|
|
||||||
}
|
|
||||||
return uriBuilder.build();
|
|
||||||
})
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(Void.class)
|
|
||||||
.timeout(getTimeout())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
log.info("[{}] DELETE 성공", getServiceName());
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[{}] 프록시 DELETE 실패: {}", getServiceName(), e.getMessage(), e);
|
|
||||||
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 커스텀 요청 처리 (하위 클래스에서 오버라이드)
|
|
||||||
* 복잡한 로직이 필요한 경우 사용
|
|
||||||
*/
|
|
||||||
protected RES customRequest(REQ request) {
|
|
||||||
throw new UnsupportedOperationException("커스텀 요청이 구현되지 않았습니다");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 서비스의 공통 인터페이스 (JDBC 기반)
|
|
||||||
* CRUD 기본 메서드 정의
|
|
||||||
*
|
|
||||||
* @param <T> Entity 타입
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
public interface BaseService<T, D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 생성
|
|
||||||
*
|
|
||||||
* @param dto 생성할 데이터 DTO
|
|
||||||
* @return 생성된 데이터 DTO
|
|
||||||
*/
|
|
||||||
D create(D dto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 조회
|
|
||||||
*
|
|
||||||
* @param id 조회할 ID
|
|
||||||
* @return 조회된 데이터 DTO (Optional)
|
|
||||||
*/
|
|
||||||
Optional<D> findById(ID id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 조회
|
|
||||||
*
|
|
||||||
* @return 전체 데이터 DTO 리스트
|
|
||||||
*/
|
|
||||||
List<D> findAll();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이징 조회
|
|
||||||
*
|
|
||||||
* @param offset 시작 위치 (0부터 시작)
|
|
||||||
* @param limit 조회 개수
|
|
||||||
* @return 페이징된 데이터 리스트
|
|
||||||
*/
|
|
||||||
List<D> findAll(int offset, int limit);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 개수 조회
|
|
||||||
*
|
|
||||||
* @return 전체 데이터 개수
|
|
||||||
*/
|
|
||||||
long count();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 수정
|
|
||||||
*
|
|
||||||
* @param id 수정할 ID
|
|
||||||
* @param dto 수정할 데이터 DTO
|
|
||||||
* @return 수정된 데이터 DTO
|
|
||||||
*/
|
|
||||||
D update(ID id, D dto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단건 삭제
|
|
||||||
*
|
|
||||||
* @param id 삭제할 ID
|
|
||||||
*/
|
|
||||||
void deleteById(ID id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 존재 여부 확인
|
|
||||||
*
|
|
||||||
* @param id 확인할 ID
|
|
||||||
* @return 존재 여부
|
|
||||||
*/
|
|
||||||
boolean existsById(ID id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity를 DTO로 변환
|
|
||||||
*
|
|
||||||
* @param entity 엔티티
|
|
||||||
* @return DTO
|
|
||||||
*/
|
|
||||||
D toDto(T entity);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO를 Entity로 변환
|
|
||||||
*
|
|
||||||
* @param dto DTO
|
|
||||||
* @return 엔티티
|
|
||||||
*/
|
|
||||||
T toEntity(D dto);
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
package com.snp.batch.common.web.service;
|
|
||||||
|
|
||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BaseService의 기본 구현 (JDBC 기반)
|
|
||||||
* 공통 CRUD 로직 구현
|
|
||||||
*
|
|
||||||
* @param <T> Entity 타입
|
|
||||||
* @param <D> DTO 타입
|
|
||||||
* @param <ID> ID 타입
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public abstract class BaseServiceImpl<T, D, ID> implements BaseService<T, D, ID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repository 반환 (하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract BaseJdbcRepository<T, ID> getRepository();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엔티티 이름 반환 (로깅용)
|
|
||||||
*/
|
|
||||||
protected abstract String getEntityName();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public D create(D dto) {
|
|
||||||
log.info("{} 생성 시작", getEntityName());
|
|
||||||
T entity = toEntity(dto);
|
|
||||||
T saved = getRepository().save(entity);
|
|
||||||
log.info("{} 생성 완료: ID={}", getEntityName(), extractId(saved));
|
|
||||||
return toDto(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<D> findById(ID id) {
|
|
||||||
log.debug("{} 조회: ID={}", getEntityName(), id);
|
|
||||||
return getRepository().findById(id).map(this::toDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<D> findAll() {
|
|
||||||
log.debug("{} 전체 조회", getEntityName());
|
|
||||||
return getRepository().findAll().stream()
|
|
||||||
.map(this::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<D> findAll(int offset, int limit) {
|
|
||||||
log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit);
|
|
||||||
|
|
||||||
// 하위 클래스에서 제공하는 페이징 쿼리 실행
|
|
||||||
List<T> entities = executePagingQuery(offset, limit);
|
|
||||||
|
|
||||||
return entities.stream()
|
|
||||||
.map(this::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이징 쿼리 실행 (하위 클래스에서 구현)
|
|
||||||
*
|
|
||||||
* @param offset 시작 위치
|
|
||||||
* @param limit 조회 개수
|
|
||||||
* @return Entity 리스트
|
|
||||||
*/
|
|
||||||
protected abstract List<T> executePagingQuery(int offset, int limit);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public long count() {
|
|
||||||
log.debug("{} 개수 조회", getEntityName());
|
|
||||||
return getRepository().count();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public D update(ID id, D dto) {
|
|
||||||
log.info("{} 수정 시작: ID={}", getEntityName(), id);
|
|
||||||
|
|
||||||
T entity = getRepository().findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException(
|
|
||||||
getEntityName() + " not found with id: " + id));
|
|
||||||
|
|
||||||
updateEntity(entity, dto);
|
|
||||||
T updated = getRepository().save(entity);
|
|
||||||
|
|
||||||
log.info("{} 수정 완료: ID={}", getEntityName(), id);
|
|
||||||
return toDto(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional
|
|
||||||
public void deleteById(ID id) {
|
|
||||||
log.info("{} 삭제: ID={}", getEntityName(), id);
|
|
||||||
|
|
||||||
if (!getRepository().existsById(id)) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
getEntityName() + " not found with id: " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
getRepository().deleteById(id);
|
|
||||||
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean existsById(ID id) {
|
|
||||||
return getRepository().existsById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity 업데이트 (하위 클래스에서 구현)
|
|
||||||
*
|
|
||||||
* @param entity 업데이트할 엔티티
|
|
||||||
* @param dto 업데이트 데이터
|
|
||||||
*/
|
|
||||||
protected abstract void updateEntity(T entity, D dto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity에서 ID 추출 (로깅용, 하위 클래스에서 구현)
|
|
||||||
*/
|
|
||||||
protected abstract ID extractId(T entity);
|
|
||||||
}
|
|
||||||
@ -15,9 +15,9 @@ import java.util.List;
|
|||||||
* Swagger/OpenAPI 3.0 설정
|
* Swagger/OpenAPI 3.0 설정
|
||||||
*
|
*
|
||||||
* Swagger UI 접속 URL:
|
* Swagger UI 접속 URL:
|
||||||
* - Swagger UI: http://localhost:8081/swagger-ui/index.html
|
* - Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
|
||||||
* - API 문서 (JSON): http://localhost:8081/v3/api-docs
|
* - API 문서 (JSON): http://localhost:8041/snp-api/v3/api-docs
|
||||||
* - API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
|
* - API 문서 (YAML): http://localhost:8041/snp-api/v3/api-docs.yaml
|
||||||
*
|
*
|
||||||
* 주요 기능:
|
* 주요 기능:
|
||||||
* - REST API 자동 문서화
|
* - REST API 자동 문서화
|
||||||
@ -62,17 +62,19 @@ public class SwaggerConfig {
|
|||||||
.description("""
|
.description("""
|
||||||
## SNP Batch 시스템 REST API 문서
|
## SNP Batch 시스템 REST API 문서
|
||||||
|
|
||||||
Spring Batch 기반 데이터 통합 시스템의 REST API 문서입니다.
|
해양 데이터 통합 배치 시스템의 REST API 문서입니다.
|
||||||
|
|
||||||
### 제공 API
|
### 제공 API
|
||||||
- **Batch API**: 배치 Job 실행 및 관리
|
- **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리
|
||||||
- **Product API**: 샘플 제품 데이터 CRUD (샘플용)
|
- **AIS Target API**: AIS 선박 위치 정보 조회 (캐시 기반, 공간/조건 검색)
|
||||||
|
|
||||||
### 주요 기능
|
### 주요 기능
|
||||||
- 배치 Job 실행 및 중지
|
- 배치 Job 실행 및 중지
|
||||||
- Job 실행 이력 조회
|
- Job 실행 이력 조회
|
||||||
- 스케줄 관리 (Quartz)
|
- 스케줄 관리 (Quartz)
|
||||||
- 제품 데이터 CRUD (샘플)
|
- AIS 선박 실시간 위치 조회 (MMSI 단건/다건, 시간/공간 범위 검색)
|
||||||
|
- 항해 조건 필터 검색 (SOG, COG, Heading, 목적지, 항행상태)
|
||||||
|
- 폴리곤/WKT 범위 검색, 거리 포함 검색, 항적 조회
|
||||||
|
|
||||||
### 버전 정보
|
### 버전 정보
|
||||||
- API Version: v1.0.0
|
- API Version: v1.0.0
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
package com.snp.batch.global.controller;
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
import com.snp.batch.global.dto.JobExecutionDto;
|
import com.snp.batch.global.dto.*;
|
||||||
import com.snp.batch.global.dto.JobLaunchRequest;
|
|
||||||
import com.snp.batch.global.dto.ScheduleRequest;
|
|
||||||
import com.snp.batch.global.dto.ScheduleResponse;
|
|
||||||
import com.snp.batch.service.BatchService;
|
import com.snp.batch.service.BatchService;
|
||||||
import com.snp.batch.service.ScheduleService;
|
import com.snp.batch.service.ScheduleService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@ -20,6 +17,8 @@ import org.springdoc.core.annotations.ParameterObject;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -101,7 +100,7 @@ public class BatchController {
|
|||||||
})
|
})
|
||||||
@GetMapping("/jobs")
|
@GetMapping("/jobs")
|
||||||
public ResponseEntity<List<String>> listJobs() {
|
public ResponseEntity<List<String>> listJobs() {
|
||||||
log.info("Received request to list all jobs");
|
log.debug("Received request to list all jobs");
|
||||||
List<String> jobs = batchService.listAllJobs();
|
List<String> jobs = batchService.listAllJobs();
|
||||||
return ResponseEntity.ok(jobs);
|
return ResponseEntity.ok(jobs);
|
||||||
}
|
}
|
||||||
@ -119,6 +118,16 @@ public class BatchController {
|
|||||||
return ResponseEntity.ok(executions);
|
return ResponseEntity.ok(executions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "최근 전체 실행 이력 조회", description = "Job 구분 없이 최근 실행 이력을 조회합니다")
|
||||||
|
@GetMapping("/executions/recent")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getRecentExecutions(
|
||||||
|
@Parameter(description = "조회 건수", example = "50")
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
log.debug("Received request to get recent executions: limit={}", limit);
|
||||||
|
List<JobExecutionDto> executions = batchService.getRecentExecutions(limit);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/executions/{executionId}")
|
@GetMapping("/executions/{executionId}")
|
||||||
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
||||||
log.info("Received request to get execution details for: {}", executionId);
|
log.info("Received request to get execution details for: {}", executionId);
|
||||||
@ -291,7 +300,7 @@ public class BatchController {
|
|||||||
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
||||||
@RequestParam String view,
|
@RequestParam String view,
|
||||||
@RequestParam String date) {
|
@RequestParam String date) {
|
||||||
log.info("Received request to get timeline: view={}, date={}", view, date);
|
log.debug("Received request to get timeline: view={}, date={}", view, date);
|
||||||
try {
|
try {
|
||||||
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
||||||
return ResponseEntity.ok(timeline);
|
return ResponseEntity.ok(timeline);
|
||||||
@ -303,7 +312,7 @@ public class BatchController {
|
|||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
||||||
log.info("Received request to get dashboard data");
|
log.debug("Received request to get dashboard data");
|
||||||
try {
|
try {
|
||||||
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
||||||
return ResponseEntity.ok(dashboard);
|
return ResponseEntity.ok(dashboard);
|
||||||
@ -327,4 +336,121 @@ public class BatchController {
|
|||||||
return ResponseEntity.internalServerError().build();
|
return ResponseEntity.internalServerError().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: 강제 종료(Abandon) API ─────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")
|
||||||
|
@GetMapping("/executions/stale")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getStaleExecutions(
|
||||||
|
@Parameter(description = "임계 시간(분)", example = "60")
|
||||||
|
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||||
|
log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||||
|
List<JobExecutionDto> executions = batchService.getStaleExecutions(thresholdMinutes);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다")
|
||||||
|
@PostMapping("/executions/{executionId}/abandon")
|
||||||
|
public ResponseEntity<Map<String, Object>> abandonExecution(@PathVariable Long executionId) {
|
||||||
|
log.info("Received request to abandon execution: {}", executionId);
|
||||||
|
try {
|
||||||
|
batchService.abandonExecution(executionId);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Execution abandoned successfully"
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", e.getMessage()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error abandoning execution: {}", executionId, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to abandon execution: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다")
|
||||||
|
@PostMapping("/executions/stale/abandon-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> abandonAllStaleExecutions(
|
||||||
|
@Parameter(description = "임계 시간(분)", example = "60")
|
||||||
|
@RequestParam(defaultValue = "60") int thresholdMinutes) {
|
||||||
|
log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes);
|
||||||
|
try {
|
||||||
|
int count = batchService.abandonAllStaleExecutions(thresholdMinutes);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", count + "건의 실행이 강제 종료되었습니다",
|
||||||
|
"abandonedCount", count
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error abandoning all stale executions", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to abandon stale executions: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: 실행 이력 검색 API ─────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)")
|
||||||
|
@GetMapping("/executions/search")
|
||||||
|
public ResponseEntity<ExecutionSearchResponse> searchExecutions(
|
||||||
|
@Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames,
|
||||||
|
@Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status,
|
||||||
|
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate,
|
||||||
|
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate,
|
||||||
|
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||||
|
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) {
|
||||||
|
log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}",
|
||||||
|
jobNames, status, startDate, endDate, page, size);
|
||||||
|
|
||||||
|
List<String> jobNameList = (jobNames != null && !jobNames.isBlank())
|
||||||
|
? java.util.Arrays.stream(jobNames.split(","))
|
||||||
|
.map(String::trim).filter(s -> !s.isEmpty()).toList()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null;
|
||||||
|
LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null;
|
||||||
|
|
||||||
|
ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job 상세 목록 API ──────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다")
|
||||||
|
@GetMapping("/jobs/detail")
|
||||||
|
public ResponseEntity<List<JobDetailDto>> getJobsDetail() {
|
||||||
|
log.debug("Received request to get jobs with detail");
|
||||||
|
List<JobDetailDto> jobs = batchService.getJobsWithDetail();
|
||||||
|
return ResponseEntity.ok(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 API ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<ExecutionStatisticsDto> getStatistics(
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
log.debug("Received request to get statistics: days={}", days);
|
||||||
|
ExecutionStatisticsDto stats = batchService.getStatistics(days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics/{jobName}")
|
||||||
|
public ResponseEntity<ExecutionStatisticsDto> getJobStatistics(
|
||||||
|
@Parameter(description = "Job 이름", required = true) @PathVariable String jobName,
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
log.debug("Received request to get statistics for job: {}, days={}", jobName, days);
|
||||||
|
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,41 +3,18 @@ package com.snp.batch.global.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA(React) fallback 라우터
|
||||||
|
*
|
||||||
|
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
|
||||||
|
* 모든 프론트 경로를 index.html로 포워딩한다.
|
||||||
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
public class WebViewController {
|
public class WebViewController {
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||||
public String index() {
|
"/execution-detail", "/schedules", "/schedule-timeline"})
|
||||||
return "index";
|
public String forward() {
|
||||||
}
|
return "forward:/index.html";
|
||||||
|
|
||||||
@GetMapping("/jobs")
|
|
||||||
public String jobs() {
|
|
||||||
return "jobs";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/executions")
|
|
||||||
public String executions() {
|
|
||||||
return "executions";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedules")
|
|
||||||
public String schedules() {
|
|
||||||
return "schedules";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/execution-detail")
|
|
||||||
public String executionDetail() {
|
|
||||||
return "execution-detail";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/executions/{id}")
|
|
||||||
public String executionDetailById() {
|
|
||||||
return "execution-detail";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedule-timeline")
|
|
||||||
public String scheduleTimeline() {
|
|
||||||
return "schedule-timeline";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@ public class DashboardResponse {
|
|||||||
private Stats stats;
|
private Stats stats;
|
||||||
private List<RunningJob> runningJobs;
|
private List<RunningJob> runningJobs;
|
||||||
private List<RecentExecution> recentExecutions;
|
private List<RecentExecution> recentExecutions;
|
||||||
|
private List<RecentFailure> recentFailures;
|
||||||
|
private int staleExecutionCount;
|
||||||
|
private FailureStats failureStats;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@ -50,4 +53,26 @@ public class DashboardResponse {
|
|||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class RecentFailure {
|
||||||
|
private Long executionId;
|
||||||
|
private String jobName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private String exitMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class FailureStats {
|
||||||
|
private int last24h;
|
||||||
|
private int last7d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExecutionSearchResponse {
|
||||||
|
|
||||||
|
private List<JobExecutionDto> executions;
|
||||||
|
private int totalCount;
|
||||||
|
private int page;
|
||||||
|
private int size;
|
||||||
|
private int totalPages;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExecutionStatisticsDto {
|
||||||
|
|
||||||
|
private List<DailyStat> dailyStats;
|
||||||
|
private int totalExecutions;
|
||||||
|
private int totalSuccess;
|
||||||
|
private int totalFailed;
|
||||||
|
private double avgDurationMs;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class DailyStat {
|
||||||
|
private String date;
|
||||||
|
private int successCount;
|
||||||
|
private int failedCount;
|
||||||
|
private int otherCount;
|
||||||
|
private double avgDurationMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
30
src/main/java/com/snp/batch/global/dto/JobDetailDto.java
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class JobDetailDto {
|
||||||
|
|
||||||
|
private String jobName;
|
||||||
|
private LastExecution lastExecution;
|
||||||
|
private String scheduleCron;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class LastExecution {
|
||||||
|
private Long executionId;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "batch_api_log", schema = "t_snp_data")
|
@Table(name = "batch_api_log", schema = "t_std_snp_data")
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
|||||||
@ -3,10 +3,13 @@ package com.snp.batch.global.repository;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 타임라인 조회를 위한 경량 Repository
|
* 타임라인 조회를 위한 경량 Repository
|
||||||
@ -33,6 +36,10 @@ public class TimelineRepository {
|
|||||||
return tablePrefix + "JOB_INSTANCE";
|
return tablePrefix + "JOB_INSTANCE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getStepExecutionTable() {
|
||||||
|
return tablePrefix + "STEP_EXECUTION";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
||||||
* Step Context를 조회하지 않아 성능이 매우 빠름
|
* Step Context를 조회하지 않아 성능이 매우 빠름
|
||||||
@ -112,7 +119,9 @@ public class TimelineRepository {
|
|||||||
je.JOB_EXECUTION_ID as executionId,
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
je.STATUS as status,
|
je.STATUS as status,
|
||||||
je.START_TIME as startTime,
|
je.START_TIME as startTime,
|
||||||
je.END_TIME as endTime
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_CODE as exitCode,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
FROM %s je
|
FROM %s je
|
||||||
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
ORDER BY je.START_TIME DESC
|
ORDER BY je.START_TIME DESC
|
||||||
@ -121,4 +130,263 @@ public class TimelineRepository {
|
|||||||
|
|
||||||
return jdbcTemplate.queryForList(sql, limit);
|
return jdbcTemplate.queryForList(sql, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F1: 강제 종료(Abandon) 관련 ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 실행 중 Job 조회 (threshold 분 이상 STARTED/STARTING)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findStaleExecutions(int thresholdMinutes) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||||
|
ORDER BY je.START_TIME ASC
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), thresholdMinutes);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job Execution 상태를 ABANDONED로 변경
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int abandonJobExecution(long executionId) {
|
||||||
|
String sql = String.format("""
|
||||||
|
UPDATE %s
|
||||||
|
SET STATUS = 'ABANDONED',
|
||||||
|
EXIT_CODE = 'ABANDONED',
|
||||||
|
END_TIME = NOW(),
|
||||||
|
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||||
|
WHERE JOB_EXECUTION_ID = ?
|
||||||
|
AND STATUS IN ('STARTED', 'STARTING')
|
||||||
|
""", getJobExecutionTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.update(sql, executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 Job Execution의 Step Execution들도 ABANDONED로 변경
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int abandonStepExecutions(long jobExecutionId) {
|
||||||
|
String sql = String.format("""
|
||||||
|
UPDATE %s
|
||||||
|
SET STATUS = 'ABANDONED',
|
||||||
|
EXIT_CODE = 'ABANDONED',
|
||||||
|
END_TIME = NOW(),
|
||||||
|
EXIT_MESSAGE = 'Force abandoned by admin'
|
||||||
|
WHERE JOB_EXECUTION_ID = ?
|
||||||
|
AND STATUS IN ('STARTED', 'STARTING')
|
||||||
|
""", getStepExecutionTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.update(sql, jobExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 실행 중 건수 조회
|
||||||
|
*/
|
||||||
|
public int countStaleExecutions(int thresholdMinutes) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
|
||||||
|
""", getJobExecutionTable(), thresholdMinutes);
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F4: 실행 이력 검색 (페이지네이션) ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실행 이력 검색 (동적 조건 + 페이지네이션)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> searchExecutions(
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate,
|
||||||
|
int offset, int limit) {
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_CODE as exitCode,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE 1=1
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||||
|
|
||||||
|
sql.append(" ORDER BY je.START_TIME DESC LIMIT ? OFFSET ?");
|
||||||
|
params.add(limit);
|
||||||
|
params.add(offset);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실행 이력 검색 건수
|
||||||
|
*/
|
||||||
|
public int countExecutions(
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE 1=1
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable()));
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray());
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendSearchConditions(
|
||||||
|
StringBuilder sql, List<Object> params,
|
||||||
|
List<String> jobNames, String status,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
|
||||||
|
if (jobNames != null && !jobNames.isEmpty()) {
|
||||||
|
String placeholders = jobNames.stream().map(n -> "?").collect(Collectors.joining(", "));
|
||||||
|
sql.append(" AND ji.JOB_NAME IN (").append(placeholders).append(")");
|
||||||
|
params.addAll(jobNames);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND je.STATUS = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
if (startDate != null) {
|
||||||
|
sql.append(" AND je.START_TIME >= ?");
|
||||||
|
params.add(startDate);
|
||||||
|
}
|
||||||
|
if (endDate != null) {
|
||||||
|
sql.append(" AND je.START_TIME < ?");
|
||||||
|
params.add(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F6: 대시보드 실패 통계 ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 실패 이력 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findRecentFailures(int hours) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime,
|
||||||
|
je.EXIT_MESSAGE as exitMessage
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS = 'FAILED'
|
||||||
|
AND je.START_TIME >= NOW() - INTERVAL '%d hours'
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), hours);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 시점 이후 실패 건수
|
||||||
|
*/
|
||||||
|
public int countFailuresSince(LocalDateTime since) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.STATUS = 'FAILED'
|
||||||
|
AND je.START_TIME >= ?
|
||||||
|
""", getJobExecutionTable());
|
||||||
|
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, since);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F7: Job별 최근 실행 정보 ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job별 가장 최근 실행 정보 조회 (DISTINCT ON 활용)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findLastExecutionPerJob() {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT DISTINCT ON (ji.JOB_NAME)
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable());
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (전체)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findDailyStatistics(int days) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
CAST(je.START_TIME AS DATE) as execDate,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||||
|
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||||
|
FROM %s je
|
||||||
|
WHERE je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||||
|
AND je.START_TIME IS NOT NULL
|
||||||
|
GROUP BY CAST(je.START_TIME AS DATE)
|
||||||
|
ORDER BY execDate
|
||||||
|
""", getJobExecutionTable(), days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (특정 Job)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findDailyStatisticsForJob(String jobName, int days) {
|
||||||
|
String sql = String.format("""
|
||||||
|
SELECT
|
||||||
|
CAST(je.START_TIME AS DATE) as execDate,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
|
||||||
|
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
|
||||||
|
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
|
||||||
|
FROM %s je
|
||||||
|
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE ji.JOB_NAME = ?
|
||||||
|
AND je.START_TIME >= NOW() - INTERVAL '%d days'
|
||||||
|
AND je.START_TIME IS NOT NULL
|
||||||
|
GROUP BY CAST(je.START_TIME AS DATE)
|
||||||
|
ORDER BY execDate
|
||||||
|
""", getJobExecutionTable(), getJobInstanceTable(), days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, jobName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,14 @@ public class AisTargetEntity extends BaseEntity {
|
|||||||
private OffsetDateTime receivedDate;
|
private OffsetDateTime receivedDate;
|
||||||
private OffsetDateTime collectedAt; // 배치 수집 시점
|
private OffsetDateTime collectedAt; // 배치 수집 시점
|
||||||
|
|
||||||
|
// ========== 선종 분류 정보 ==========
|
||||||
|
/**
|
||||||
|
* MDA 범례코드 (signalKindCode)
|
||||||
|
* - vesselType + extraInfo 기반으로 치환
|
||||||
|
* - 예: "000020"(어선), "000023"(카고), "000027"(일반/기타)
|
||||||
|
*/
|
||||||
|
private String signalKindCode;
|
||||||
|
|
||||||
// ========== ClassType 분류 정보 ==========
|
// ========== ClassType 분류 정보 ==========
|
||||||
/**
|
/**
|
||||||
* 선박 클래스 타입
|
* 선박 클래스 타입
|
||||||
|
|||||||
@ -26,6 +26,14 @@ public interface AisTargetRepository {
|
|||||||
*/
|
*/
|
||||||
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
|
||||||
|
*
|
||||||
|
* @param mmsiList 대상 MMSI 목록
|
||||||
|
* @param since 이 시점 이후 데이터만 조회
|
||||||
|
*/
|
||||||
|
List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 시간 범위 내 특정 MMSI의 항적 조회
|
* 시간 범위 내 특정 MMSI의 항적 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package com.snp.batch.jobs.aistarget.batch.repository;
|
package com.snp.batch.jobs.aistarget.batch.repository;
|
||||||
|
|
||||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@ -19,22 +19,27 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* AIS Target Repository 구현체
|
* AIS Target Repository 구현체
|
||||||
*
|
*
|
||||||
* 테이블: snp_data.ais_target
|
* 테이블: {targetSchema}.ais_target
|
||||||
* PK: mmsi + message_timestamp (복합키)
|
* PK: mmsi + message_timestamp (복합키)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AisTargetRepositoryImpl implements AisTargetRepository {
|
public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final String tableName;
|
||||||
|
private final String upsertSql;
|
||||||
|
|
||||||
private static final String TABLE_NAME = "snp_data.ais_target";
|
public AisTargetRepositoryImpl(JdbcTemplate jdbcTemplate,
|
||||||
|
@Value("${app.batch.target-schema.name}") String targetSchema) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.tableName = targetSchema + ".ais_target";
|
||||||
|
this.upsertSql = buildUpsertSql(targetSchema);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== UPSERT SQL ====================
|
private String buildUpsertSql(String schema) {
|
||||||
|
return """
|
||||||
private static final String UPSERT_SQL = """
|
INSERT INTO %s.ais_target (
|
||||||
INSERT INTO snp_data.ais_target (
|
|
||||||
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
|
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
|
||||||
lat, lon, geom,
|
lat, lon, geom,
|
||||||
heading, sog, cog, rot,
|
heading, sog, cog, rot,
|
||||||
@ -116,14 +121,16 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
"source" = EXCLUDED."source",
|
"source" = EXCLUDED."source",
|
||||||
station_id = EXCLUDED.station_id,
|
station_id = EXCLUDED.station_id,
|
||||||
zone_id = EXCLUDED.zone_id
|
zone_id = EXCLUDED.zone_id
|
||||||
""";
|
""".formatted(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== RowMapper ====================
|
// ==================== RowMapper ====================
|
||||||
|
|
||||||
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
||||||
.mmsi(rs.getLong("mmsi"))
|
.mmsi(rs.getLong("mmsi"))
|
||||||
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
||||||
.imo(rs.getObject("imo", Long.class))
|
.imo(toLong(rs, "imo"))
|
||||||
.name(rs.getString("name"))
|
.name(rs.getString("name"))
|
||||||
.callsign(rs.getString("callsign"))
|
.callsign(rs.getString("callsign"))
|
||||||
.vesselType(rs.getString("vessel_type"))
|
.vesselType(rs.getString("vessel_type"))
|
||||||
@ -133,45 +140,45 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
.heading(rs.getObject("heading", Double.class))
|
.heading(rs.getObject("heading", Double.class))
|
||||||
.sog(rs.getObject("sog", Double.class))
|
.sog(rs.getObject("sog", Double.class))
|
||||||
.cog(rs.getObject("cog", Double.class))
|
.cog(rs.getObject("cog", Double.class))
|
||||||
.rot(rs.getObject("rot", Integer.class))
|
.rot(toInt(rs, "rot"))
|
||||||
.length(rs.getObject("length", Integer.class))
|
.length(toInt(rs, "length"))
|
||||||
.width(rs.getObject("width", Integer.class))
|
.width(toInt(rs, "width"))
|
||||||
.draught(rs.getObject("draught", Double.class))
|
.draught(rs.getObject("draught", Double.class))
|
||||||
.lengthBow(rs.getObject("length_bow", Integer.class))
|
.lengthBow(toInt(rs, "length_bow"))
|
||||||
.lengthStern(rs.getObject("length_stern", Integer.class))
|
.lengthStern(toInt(rs, "length_stern"))
|
||||||
.widthPort(rs.getObject("width_port", Integer.class))
|
.widthPort(toInt(rs, "width_port"))
|
||||||
.widthStarboard(rs.getObject("width_starboard", Integer.class))
|
.widthStarboard(toInt(rs, "width_starboard"))
|
||||||
.destination(rs.getString("destination"))
|
.destination(rs.getString("destination"))
|
||||||
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
||||||
.status(rs.getString("status"))
|
.status(rs.getString("status"))
|
||||||
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
||||||
.positionAccuracy(rs.getObject("position_accuracy", Integer.class))
|
.positionAccuracy(toInt(rs, "position_accuracy"))
|
||||||
.timestampUtc(rs.getObject("timestamp_utc", Integer.class))
|
.timestampUtc(toInt(rs, "timestamp_utc"))
|
||||||
.repeatIndicator(rs.getObject("repeat_indicator", Integer.class))
|
.repeatIndicator(toInt(rs, "repeat_indicator"))
|
||||||
.raimFlag(rs.getObject("raim_flag", Integer.class))
|
.raimFlag(toInt(rs, "raim_flag"))
|
||||||
.radioStatus(rs.getObject("radio_status", Integer.class))
|
.radioStatus(toInt(rs, "radio_status"))
|
||||||
.regional(rs.getObject("regional", Integer.class))
|
.regional(toInt(rs, "regional"))
|
||||||
.regional2(rs.getObject("regional2", Integer.class))
|
.regional2(toInt(rs, "regional2"))
|
||||||
.spare(rs.getObject("spare", Integer.class))
|
.spare(toInt(rs, "spare"))
|
||||||
.spare2(rs.getObject("spare2", Integer.class))
|
.spare2(toInt(rs, "spare2"))
|
||||||
.aisVersion(rs.getObject("ais_version", Integer.class))
|
.aisVersion(toInt(rs, "ais_version"))
|
||||||
.positionFixType(rs.getObject("position_fix_type", Integer.class))
|
.positionFixType(toInt(rs, "position_fix_type"))
|
||||||
.dte(rs.getObject("dte", Integer.class))
|
.dte(toInt(rs, "dte"))
|
||||||
.bandFlag(rs.getObject("band_flag", Integer.class))
|
.bandFlag(toInt(rs, "band_flag"))
|
||||||
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
||||||
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
||||||
.tonnesCargo(rs.getObject("tonnes_cargo", Integer.class))
|
.tonnesCargo(toInt(rs, "tonnes_cargo"))
|
||||||
.inSTS(rs.getObject("in_sts", Integer.class))
|
.inSTS(toInt(rs, "in_sts"))
|
||||||
.onBerth(rs.getObject("on_berth", Boolean.class))
|
.onBerth(rs.getObject("on_berth", Boolean.class))
|
||||||
.dwt(rs.getObject("dwt", Integer.class))
|
.dwt(toInt(rs, "dwt"))
|
||||||
.anomalous(rs.getString("anomalous"))
|
.anomalous(rs.getString("anomalous"))
|
||||||
.destinationPortID(rs.getObject("destination_port_id", Integer.class))
|
.destinationPortID(toInt(rs, "destination_port_id"))
|
||||||
.destinationTidied(rs.getString("destination_tidied"))
|
.destinationTidied(rs.getString("destination_tidied"))
|
||||||
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
||||||
.imoVerified(rs.getString("imo_verified"))
|
.imoVerified(rs.getString("imo_verified"))
|
||||||
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
||||||
.lpcCode(rs.getObject("lpc_code", Integer.class))
|
.lpcCode(toInt(rs, "lpc_code"))
|
||||||
.messageType(rs.getObject("message_type", Integer.class))
|
.messageType(toInt(rs, "message_type"))
|
||||||
.source(rs.getString("source"))
|
.source(rs.getString("source"))
|
||||||
.stationId(rs.getString("station_id"))
|
.stationId(rs.getString("station_id"))
|
||||||
.zoneId(rs.getObject("zone_id", Double.class))
|
.zoneId(rs.getObject("zone_id", Double.class))
|
||||||
@ -181,7 +188,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
|
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
|
||||||
String sql = "SELECT * FROM " + TABLE_NAME + " WHERE mmsi = ? AND message_timestamp = ?";
|
String sql = "SELECT * FROM " + tableName + " WHERE mmsi = ? AND message_timestamp = ?";
|
||||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
|
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@ -193,7 +200,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
WHERE mmsi = ?
|
WHERE mmsi = ?
|
||||||
ORDER BY message_timestamp DESC
|
ORDER BY message_timestamp DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
|
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@ -210,12 +217,30 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
FROM %s
|
FROM %s
|
||||||
WHERE mmsi = ANY(?)
|
WHERE mmsi = ANY(?)
|
||||||
ORDER BY mmsi, message_timestamp DESC
|
ORDER BY mmsi, message_timestamp DESC
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
|
|
||||||
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since) {
|
||||||
|
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT DISTINCT ON (mmsi) *
|
||||||
|
FROM %s
|
||||||
|
WHERE mmsi = ANY(?)
|
||||||
|
AND message_timestamp >= ?
|
||||||
|
ORDER BY mmsi, message_timestamp DESC
|
||||||
|
""".formatted(tableName);
|
||||||
|
|
||||||
|
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||||
|
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
||||||
String sql = """
|
String sql = """
|
||||||
@ -223,7 +248,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
WHERE mmsi = ?
|
WHERE mmsi = ?
|
||||||
AND message_timestamp BETWEEN ? AND ?
|
AND message_timestamp BETWEEN ? AND ?
|
||||||
ORDER BY message_timestamp ASC
|
ORDER BY message_timestamp ASC
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
|
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +270,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
?
|
?
|
||||||
)
|
)
|
||||||
ORDER BY mmsi, message_timestamp DESC
|
ORDER BY mmsi, message_timestamp DESC
|
||||||
""".formatted(TABLE_NAME);
|
""".formatted(tableName);
|
||||||
|
|
||||||
return jdbcTemplate.query(sql, rowMapper,
|
return jdbcTemplate.query(sql, rowMapper,
|
||||||
toTimestamp(start), toTimestamp(end),
|
toTimestamp(start), toTimestamp(end),
|
||||||
@ -261,7 +286,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
|
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
|
||||||
|
|
||||||
jdbcTemplate.batchUpdate(UPSERT_SQL, entities, 1000, (ps, entity) -> {
|
jdbcTemplate.batchUpdate(upsertSql, entities, 1000, (ps, entity) -> {
|
||||||
int idx = 1;
|
int idx = 1;
|
||||||
// PK
|
// PK
|
||||||
ps.setLong(idx++, entity.getMmsi());
|
ps.setLong(idx++, entity.getMmsi());
|
||||||
@ -336,7 +361,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long count() {
|
public long count() {
|
||||||
String sql = "SELECT COUNT(*) FROM " + TABLE_NAME;
|
String sql = "SELECT COUNT(*) FROM " + tableName;
|
||||||
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
||||||
return count != null ? count : 0L;
|
return count != null ? count : 0L;
|
||||||
}
|
}
|
||||||
@ -344,7 +369,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int deleteOlderThan(OffsetDateTime threshold) {
|
public int deleteOlderThan(OffsetDateTime threshold) {
|
||||||
String sql = "DELETE FROM " + TABLE_NAME + " WHERE message_timestamp < ?";
|
String sql = "DELETE FROM " + tableName + " WHERE message_timestamp < ?";
|
||||||
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
|
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
|
||||||
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
|
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
|
||||||
return deleted;
|
return deleted;
|
||||||
@ -352,6 +377,23 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
|
|||||||
|
|
||||||
// ==================== Helper Methods ====================
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* int8(bigint) → Integer 안전 변환
|
||||||
|
* PostgreSQL JDBC 드라이버는 int8 → Integer 자동 변환을 지원하지 않아
|
||||||
|
* getObject("col", Integer.class) 사용 시 오류 발생. Number로 읽어서 변환.
|
||||||
|
*/
|
||||||
|
private Integer toInt(ResultSet rs, String column) throws SQLException {
|
||||||
|
Object val = rs.getObject(column);
|
||||||
|
if (val == null) return null;
|
||||||
|
return ((Number) val).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(ResultSet rs, String column) throws SQLException {
|
||||||
|
Object val = rs.getObject(column);
|
||||||
|
if (val == null) return null;
|
||||||
|
return ((Number) val).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
private Timestamp toTimestamp(OffsetDateTime odt) {
|
private Timestamp toTimestamp(OffsetDateTime odt) {
|
||||||
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,12 @@ package com.snp.batch.jobs.aistarget.batch.writer;
|
|||||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||||
|
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||||
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
||||||
|
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||||
|
import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -14,11 +18,15 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* 동작:
|
* 동작:
|
||||||
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
||||||
* 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
|
* 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||||
|
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||||
|
* 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||||
|
* 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
|
||||||
*
|
*
|
||||||
* 참고:
|
* 참고:
|
||||||
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
||||||
* - 이 Writer는 캐시 업데이트만 담당
|
* - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
|
||||||
|
* - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@ -26,13 +34,20 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
|||||||
|
|
||||||
private final AisTargetCacheManager cacheManager;
|
private final AisTargetCacheManager cacheManager;
|
||||||
private final AisClassTypeClassifier classTypeClassifier;
|
private final AisClassTypeClassifier classTypeClassifier;
|
||||||
|
@Nullable
|
||||||
|
private final AisTargetKafkaProducer kafkaProducer;
|
||||||
|
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||||
|
|
||||||
public AisTargetDataWriter(
|
public AisTargetDataWriter(
|
||||||
AisTargetCacheManager cacheManager,
|
AisTargetCacheManager cacheManager,
|
||||||
AisClassTypeClassifier classTypeClassifier) {
|
AisClassTypeClassifier classTypeClassifier,
|
||||||
|
@Nullable AisTargetKafkaProducer kafkaProducer,
|
||||||
|
ChnPrmShipCacheManager chnPrmShipCacheManager) {
|
||||||
super("AisTarget");
|
super("AisTarget");
|
||||||
this.cacheManager = cacheManager;
|
this.cacheManager = cacheManager;
|
||||||
this.classTypeClassifier = classTypeClassifier;
|
this.classTypeClassifier = classTypeClassifier;
|
||||||
|
this.kafkaProducer = kafkaProducer;
|
||||||
|
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -43,10 +58,33 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
|||||||
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
||||||
classTypeClassifier.classifyAll(items);
|
classTypeClassifier.classifyAll(items);
|
||||||
|
|
||||||
// 2. 캐시 업데이트 (classType, core20Mmsi 포함)
|
// 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||||
|
items.forEach(item -> {
|
||||||
|
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
|
||||||
|
item.setSignalKindCode(kindCode.getCode());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||||
cacheManager.putAll(items);
|
cacheManager.putAll(items);
|
||||||
|
|
||||||
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
||||||
items.size(), cacheManager.size());
|
items.size(), cacheManager.size());
|
||||||
|
|
||||||
|
// 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||||
|
chnPrmShipCacheManager.putIfTarget(items);
|
||||||
|
|
||||||
|
// 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
|
||||||
|
if (kafkaProducer == null) {
|
||||||
|
log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AisTargetKafkaProducer.PublishSummary summary = kafkaProducer.publish(items);
|
||||||
|
log.info("AIS Kafka 전송 완료 - topic: {}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
|
||||||
|
kafkaProducer.getTopic(),
|
||||||
|
summary.getRequestedCount(),
|
||||||
|
summary.getSuccessCount(),
|
||||||
|
summary.getFailedCount(),
|
||||||
|
summary.getSkippedCount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중국 허가선박 전용 캐시
|
||||||
|
*
|
||||||
|
* - 대상 MMSI(~1,400척)만 별도 관리
|
||||||
|
* - TTL: expireAfterWrite (마지막 put 이후 N일 경과 시 만료)
|
||||||
|
* - 순수 캐시 조회 전용 (DB fallback 없음)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChnPrmShipCacheManager {
|
||||||
|
|
||||||
|
private final ChnPrmShipProperties properties;
|
||||||
|
private Cache<Long, AisTargetEntity> cache;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
this.cache = Caffeine.newBuilder()
|
||||||
|
.maximumSize(properties.getMaxSize())
|
||||||
|
.expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
|
||||||
|
.recordStats()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
|
||||||
|
properties.getTtlDays(), properties.getMaxSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
|
||||||
|
*
|
||||||
|
* @param items 전체 AIS Target 데이터 (배치 수집 결과)
|
||||||
|
* @return 저장된 건수
|
||||||
|
*/
|
||||||
|
public int putIfTarget(List<AisTargetEntity> items) {
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = 0;
|
||||||
|
for (AisTargetEntity item : items) {
|
||||||
|
if (!properties.isTarget(item.getMmsi())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
|
||||||
|
if (existing == null || isNewerOrEqual(item, existing)) {
|
||||||
|
cache.put(item.getMmsi(), item);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
|
||||||
|
items.size(), updated, cache.estimatedSize());
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 범위 내 캐시 데이터 조회
|
||||||
|
*
|
||||||
|
* @param minutes 조회 범위 (분)
|
||||||
|
* @return 시간 범위 내 데이터 목록
|
||||||
|
*/
|
||||||
|
public List<AisTargetEntity> getByTimeRange(int minutes) {
|
||||||
|
OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
|
||||||
|
|
||||||
|
return cache.asMap().values().stream()
|
||||||
|
.filter(entity -> entity.getMessageTimestamp() != null)
|
||||||
|
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 워밍업용 직접 저장 (시간 비교 없이 저장)
|
||||||
|
*/
|
||||||
|
public void putAll(List<AisTargetEntity> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (AisTargetEntity entity : entities) {
|
||||||
|
if (entity != null && entity.getMmsi() != null) {
|
||||||
|
cache.put(entity.getMmsi(), entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long size() {
|
||||||
|
return cache.estimatedSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getStats() {
|
||||||
|
var stats = cache.stats();
|
||||||
|
return Map.of(
|
||||||
|
"estimatedSize", cache.estimatedSize(),
|
||||||
|
"maxSize", properties.getMaxSize(),
|
||||||
|
"ttlDays", properties.getTtlDays(),
|
||||||
|
"targetMmsiCount", properties.getMmsiSet().size(),
|
||||||
|
"hitCount", stats.hitCount(),
|
||||||
|
"missCount", stats.missCount(),
|
||||||
|
"hitRate", String.format("%.2f%%", stats.hitRate() * 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
|
||||||
|
if (candidate.getMessageTimestamp() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (existing.getMessageTimestamp() == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||||
|
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||||
|
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동 시 ChnPrmShip 캐시 워밍업
|
||||||
|
*
|
||||||
|
* DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
|
||||||
|
* 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChnPrmShipCacheWarmer implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final int DB_QUERY_CHUNK_SIZE = 500;
|
||||||
|
|
||||||
|
private final ChnPrmShipProperties properties;
|
||||||
|
private final ChnPrmShipCacheManager cacheManager;
|
||||||
|
private final AisTargetRepository aisTargetRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (!properties.isWarmupEnabled()) {
|
||||||
|
log.info("ChnPrmShip 캐시 워밍업 비활성화");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.getMmsiSet().isEmpty()) {
|
||||||
|
log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
|
||||||
|
.minusDays(properties.getWarmupDays());
|
||||||
|
|
||||||
|
log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
|
||||||
|
properties.getMmsiSet().size(), properties.getWarmupDays(), since);
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
List<Long> mmsiList = new ArrayList<>(properties.getMmsiSet());
|
||||||
|
int totalLoaded = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
|
||||||
|
List<Long> chunk = mmsiList.subList(i,
|
||||||
|
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
|
||||||
|
|
||||||
|
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
|
||||||
|
|
||||||
|
// signalKindCode 치환 (DB 데이터는 치환이 안 되어 있을 수 있음)
|
||||||
|
fromDb.forEach(entity -> {
|
||||||
|
if (entity.getSignalKindCode() == null) {
|
||||||
|
SignalKindCode kindCode = SignalKindCode.resolve(
|
||||||
|
entity.getVesselType(), entity.getExtraInfo());
|
||||||
|
entity.setSignalKindCode(kindCode.getCode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cacheManager.putAll(fromDb);
|
||||||
|
totalLoaded += fromDb.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
long elapsed = System.currentTimeMillis() - startTime;
|
||||||
|
log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
|
||||||
|
properties.getMmsiSet().size(), totalLoaded, elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중국 허가선박(ChnPrmShip) 설정
|
||||||
|
*
|
||||||
|
* 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ConfigurationProperties(prefix = "app.batch.chnprmship")
|
||||||
|
public class ChnPrmShipProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMSI 목록 리소스 경로
|
||||||
|
*/
|
||||||
|
private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 TTL (일)
|
||||||
|
* - 마지막 put() 이후 이 기간이 지나면 만료
|
||||||
|
*/
|
||||||
|
private int ttlDays = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 캐시 크기
|
||||||
|
*/
|
||||||
|
private int maxSize = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동 시 DB 워밍업 활성화 여부
|
||||||
|
*/
|
||||||
|
private boolean warmupEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 워밍업 조회 범위 (일)
|
||||||
|
*/
|
||||||
|
private int warmupDays = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로딩된 대상 MMSI 집합
|
||||||
|
*/
|
||||||
|
private Set<Long> mmsiSet = Collections.emptySet();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
mmsiSet = reader.lines()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
|
.map(Long::parseLong)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
|
||||||
|
mmsiSet = Collections.emptySet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTarget(Long mmsi) {
|
||||||
|
return mmsi != null && mmsiSet.contains(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.classifier;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MDA 선종 범례코드
|
||||||
|
*
|
||||||
|
* GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
|
||||||
|
* MDA 범례코드(signalKindCode)로 치환한다.
|
||||||
|
*
|
||||||
|
* @see <a href="GLOBALAIS - MDA 선종 범례 치환표.pdf">치환 규칙표</a>
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public enum SignalKindCode {
|
||||||
|
|
||||||
|
FISHING("000020", "어선"),
|
||||||
|
KCGV("000021", "함정"),
|
||||||
|
FERRY("000022", "여객선"),
|
||||||
|
CARGO("000023", "카고"),
|
||||||
|
TANKER("000024", "탱커"),
|
||||||
|
GOV("000025", "관공선"),
|
||||||
|
DEFAULT("000027", "일반/기타선박"),
|
||||||
|
BUOY("000028", "부이/항로표지");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String koreanName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalAIS vesselType + extraInfo → MDA 범례코드 치환
|
||||||
|
*
|
||||||
|
* 치환 우선순위:
|
||||||
|
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN 등)
|
||||||
|
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등)
|
||||||
|
* 3. fallback → DEFAULT (000027)
|
||||||
|
*/
|
||||||
|
public static SignalKindCode resolve(String vesselType, String extraInfo) {
|
||||||
|
String vt = normalizeOrEmpty(vesselType);
|
||||||
|
String ei = normalizeOrEmpty(extraInfo);
|
||||||
|
|
||||||
|
// 1. vesselType 단독 매칭 (extraInfo 무관)
|
||||||
|
switch (vt) {
|
||||||
|
case "cargo":
|
||||||
|
return CARGO;
|
||||||
|
case "tanker":
|
||||||
|
return TANKER;
|
||||||
|
case "passenger":
|
||||||
|
return FERRY;
|
||||||
|
case "aton":
|
||||||
|
return BUOY;
|
||||||
|
case "law enforcement":
|
||||||
|
return GOV;
|
||||||
|
case "search and rescue":
|
||||||
|
return KCGV;
|
||||||
|
case "local vessel":
|
||||||
|
return FISHING;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vesselType 그룹 매칭 (복합 선종명)
|
||||||
|
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
|
||||||
|
return GOV;
|
||||||
|
}
|
||||||
|
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
|
||||||
|
return FERRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. "Vessel" + extraInfo 조합
|
||||||
|
if ("vessel".equals(vt)) {
|
||||||
|
return resolveVesselExtraInfo(ei);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. "N/A" + extraInfo 조합
|
||||||
|
if ("n/a".equals(vt)) {
|
||||||
|
if (ei.startsWith("hazardous cat")) {
|
||||||
|
return CARGO;
|
||||||
|
}
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. fallback
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignalKindCode resolveVesselExtraInfo(String extraInfo) {
|
||||||
|
if ("fishing".equals(extraInfo)) {
|
||||||
|
return FISHING;
|
||||||
|
}
|
||||||
|
if ("military operations".equals(extraInfo)) {
|
||||||
|
return GOV;
|
||||||
|
}
|
||||||
|
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
|
||||||
|
return GOV;
|
||||||
|
}
|
||||||
|
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
|
||||||
|
return FISHING;
|
||||||
|
}
|
||||||
|
if (extraInfo.startsWith("hazardous cat")) {
|
||||||
|
return CARGO;
|
||||||
|
}
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesAny(String value, String... candidates) {
|
||||||
|
for (String candidate : candidates) {
|
||||||
|
if (candidate.equals(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeOrEmpty(String value) {
|
||||||
|
return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.kafka;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 조건부 활성화 설정
|
||||||
|
*
|
||||||
|
* SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 뒤,
|
||||||
|
* app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다.
|
||||||
|
*
|
||||||
|
* enabled=false(기본값)이면 KafkaTemplate 등 Kafka 관련 빈이 전혀 생성되지 않는다.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
name = "app.batch.ais-target.kafka.enabled",
|
||||||
|
havingValue = "true"
|
||||||
|
)
|
||||||
|
@Import(KafkaAutoConfiguration.class)
|
||||||
|
public class AisTargetKafkaConfig {
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.kafka;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIS Target Kafka 메시지 스키마
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class AisTargetKafkaMessage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 고유 식별자
|
||||||
|
* - 형식: {mmsi}_{messageTimestamp}
|
||||||
|
*/
|
||||||
|
private String eventId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka key와 동일한 선박 식별자
|
||||||
|
*/
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 발행 시각(UTC)
|
||||||
|
*/
|
||||||
|
private OffsetDateTime publishedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIS 원본/가공 데이터 전체 필드
|
||||||
|
*/
|
||||||
|
private AisTargetEntity payload;
|
||||||
|
|
||||||
|
public static AisTargetKafkaMessage from(AisTargetEntity entity) {
|
||||||
|
String key = entity.getMmsi() != null ? String.valueOf(entity.getMmsi()) : null;
|
||||||
|
String messageTs = entity.getMessageTimestamp() != null ? entity.getMessageTimestamp().toString() : "null";
|
||||||
|
|
||||||
|
return AisTargetKafkaMessage.builder()
|
||||||
|
.eventId(key + "_" + messageTs)
|
||||||
|
.key(key)
|
||||||
|
.publishedAt(OffsetDateTime.now(ZoneOffset.UTC))
|
||||||
|
.payload(entity)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.kafka;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIS Target Kafka Producer
|
||||||
|
*
|
||||||
|
* 정책:
|
||||||
|
* - key: MMSI
|
||||||
|
* - value: AisTargetKafkaMessage(JSON)
|
||||||
|
* - 실패 시 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false)
|
||||||
|
*
|
||||||
|
* app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true")
|
||||||
|
public class AisTargetKafkaProducer {
|
||||||
|
|
||||||
|
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AisTargetKafkaProperties kafkaProperties;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return kafkaProperties.isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTopic() {
|
||||||
|
return kafkaProperties.getTopic();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수집 청크 데이터를 Kafka 전송용 서브청크로 분할해 전송
|
||||||
|
*/
|
||||||
|
public PublishSummary publish(List<AisTargetEntity> entities) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return PublishSummary.disabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return PublishSummary.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
int subChunkSize = Math.max(1, kafkaProperties.getSendChunkSize());
|
||||||
|
PublishSummary totalSummary = PublishSummary.empty();
|
||||||
|
|
||||||
|
for (int from = 0; from < entities.size(); from += subChunkSize) {
|
||||||
|
int to = Math.min(from + subChunkSize, entities.size());
|
||||||
|
List<AisTargetEntity> subChunk = entities.subList(from, to);
|
||||||
|
|
||||||
|
PublishSummary chunkSummary = publishSubChunk(subChunk);
|
||||||
|
totalSummary.merge(chunkSummary);
|
||||||
|
|
||||||
|
log.info("AIS Kafka 서브청크 전송 완료 - topic: {}, 범위: {}~{}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
|
||||||
|
getTopic(), from, to - 1,
|
||||||
|
chunkSummary.getRequestedCount(),
|
||||||
|
chunkSummary.getSuccessCount(),
|
||||||
|
chunkSummary.getFailedCount(),
|
||||||
|
chunkSummary.getSkippedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kafkaProperties.isFailOnSendError() && totalSummary.getFailedCount() > 0) {
|
||||||
|
throw new IllegalStateException("AIS Kafka 전송 실패 건수: " + totalSummary.getFailedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublishSummary publishSubChunk(List<AisTargetEntity> subChunk) {
|
||||||
|
AtomicInteger successCount = new AtomicInteger(0);
|
||||||
|
AtomicInteger failedCount = new AtomicInteger(0);
|
||||||
|
AtomicInteger skippedCount = new AtomicInteger(0);
|
||||||
|
AtomicInteger sampledErrorLogs = new AtomicInteger(0);
|
||||||
|
List<CompletableFuture<Void>> futures = new ArrayList<>(subChunk.size());
|
||||||
|
|
||||||
|
for (AisTargetEntity entity : subChunk) {
|
||||||
|
if (!isValid(entity)) {
|
||||||
|
skippedCount.incrementAndGet();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String key = String.valueOf(entity.getMmsi());
|
||||||
|
String payload = objectMapper.writeValueAsString(AisTargetKafkaMessage.from(entity));
|
||||||
|
|
||||||
|
CompletableFuture<Void> trackedFuture = kafkaTemplate.send(getTopic(), key, payload)
|
||||||
|
.handle((result, ex) -> {
|
||||||
|
if (ex != null) {
|
||||||
|
failedCount.incrementAndGet();
|
||||||
|
logSendError(sampledErrorLogs,
|
||||||
|
"AIS Kafka 전송 실패 - topic: " + getTopic()
|
||||||
|
+ ", key: " + key
|
||||||
|
+ ", messageTimestamp: " + entity.getMessageTimestamp()
|
||||||
|
+ ", error: " + ex.getMessage());
|
||||||
|
} else {
|
||||||
|
successCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
futures.add(trackedFuture);
|
||||||
|
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
failedCount.incrementAndGet();
|
||||||
|
logSendError(sampledErrorLogs,
|
||||||
|
"AIS Kafka 메시지 직렬화 실패 - mmsi: " + entity.getMmsi()
|
||||||
|
+ ", messageTimestamp: " + entity.getMessageTimestamp()
|
||||||
|
+ ", error: " + e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
failedCount.incrementAndGet();
|
||||||
|
logSendError(sampledErrorLogs,
|
||||||
|
"AIS Kafka 전송 요청 실패 - mmsi: " + entity.getMmsi()
|
||||||
|
+ ", messageTimestamp: " + entity.getMessageTimestamp()
|
||||||
|
+ ", error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!futures.isEmpty()) {
|
||||||
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||||
|
kafkaTemplate.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return PublishSummary.of(
|
||||||
|
false,
|
||||||
|
subChunk.size(),
|
||||||
|
successCount.get(),
|
||||||
|
failedCount.get(),
|
||||||
|
skippedCount.get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValid(AisTargetEntity entity) {
|
||||||
|
return entity != null
|
||||||
|
&& entity.getMmsi() != null
|
||||||
|
&& entity.getMessageTimestamp() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logSendError(AtomicInteger sampledErrorLogs, String message) {
|
||||||
|
int current = sampledErrorLogs.incrementAndGet();
|
||||||
|
if (current <= 5) {
|
||||||
|
log.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current == 6) {
|
||||||
|
log.error("AIS Kafka 전송 오류 로그가 많아 이후 상세 로그는 생략합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class PublishSummary {
|
||||||
|
private final boolean disabled;
|
||||||
|
private int requestedCount;
|
||||||
|
private int successCount;
|
||||||
|
private int failedCount;
|
||||||
|
private int skippedCount;
|
||||||
|
|
||||||
|
private PublishSummary(
|
||||||
|
boolean disabled,
|
||||||
|
int requestedCount,
|
||||||
|
int successCount,
|
||||||
|
int failedCount,
|
||||||
|
int skippedCount
|
||||||
|
) {
|
||||||
|
this.disabled = disabled;
|
||||||
|
this.requestedCount = requestedCount;
|
||||||
|
this.successCount = successCount;
|
||||||
|
this.failedCount = failedCount;
|
||||||
|
this.skippedCount = skippedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublishSummary disabled() {
|
||||||
|
return of(true, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublishSummary empty() {
|
||||||
|
return of(false, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublishSummary of(
|
||||||
|
boolean disabled,
|
||||||
|
int requestedCount,
|
||||||
|
int successCount,
|
||||||
|
int failedCount,
|
||||||
|
int skippedCount
|
||||||
|
) {
|
||||||
|
return new PublishSummary(disabled, requestedCount, successCount, failedCount, skippedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void merge(PublishSummary other) {
|
||||||
|
this.requestedCount += other.requestedCount;
|
||||||
|
this.successCount += other.successCount;
|
||||||
|
this.failedCount += other.failedCount;
|
||||||
|
this.skippedCount += other.skippedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.snp.batch.jobs.aistarget.kafka;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIS Target Kafka 전송 설정
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ConfigurationProperties(prefix = "app.batch.ais-target.kafka")
|
||||||
|
public class AisTargetKafkaProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 전송 활성화 여부
|
||||||
|
*/
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전송 대상 토픽
|
||||||
|
*/
|
||||||
|
private String topic = "tp_SNP_AIS_Signal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 전송 서브청크 크기
|
||||||
|
* 수집 청크(예: 5만)와 별도로 전송 배치를 분할한다.
|
||||||
|
*/
|
||||||
|
private int sendChunkSize = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전송 실패 시 Step 실패 여부
|
||||||
|
* false면 실패 로그만 남기고 다음 처리를 계속한다.
|
||||||
|
*/
|
||||||
|
private boolean failOnSendError = false;
|
||||||
|
}
|
||||||
@ -7,13 +7,17 @@ import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
|||||||
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
|
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -25,6 +29,7 @@ import java.util.Map;
|
|||||||
* - 캐시 미스 시 DB 조회 후 캐시 업데이트
|
* - 캐시 미스 시 DB 조회 후 캐시 업데이트
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@Validated
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/ais-target")
|
@RequestMapping("/api/ais-target")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -33,11 +38,51 @@ public class AisTargetController {
|
|||||||
|
|
||||||
private final AisTargetService aisTargetService;
|
private final AisTargetService aisTargetService;
|
||||||
|
|
||||||
|
// ==================== 중국 허가선박 전용 ====================
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "중국 허가선박 위치 조회",
|
||||||
|
description = """
|
||||||
|
중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다.
|
||||||
|
|
||||||
|
- 순수 캐시 조회 (DB fallback 없음)
|
||||||
|
- 캐시에 없으면 빈 배열 반환
|
||||||
|
- 응답 구조는 /search와 동일
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@GetMapping("/chnprmship")
|
||||||
|
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getChnPrmShip(
|
||||||
|
@Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880")
|
||||||
|
@RequestParam(defaultValue = "2880") Integer minutes) {
|
||||||
|
|
||||||
|
log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes);
|
||||||
|
|
||||||
|
List<AisTargetResponseDto> result = aisTargetService.findChnPrmShip(minutes);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
"ChnPrmShip 조회 완료: " + result.size() + " 건",
|
||||||
|
result
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "중국 허가선박 캐시 통계",
|
||||||
|
description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다"
|
||||||
|
)
|
||||||
|
@GetMapping("/chnprmship/stats")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getChnPrmShipStats() {
|
||||||
|
Map<String, Object> stats = aisTargetService.getChnPrmShipCacheStats();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 단건 조회 ====================
|
// ==================== 단건 조회 ====================
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "MMSI로 최신 위치 조회",
|
summary = "MMSI로 최신 위치 조회",
|
||||||
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)"
|
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음")
|
||||||
|
}
|
||||||
)
|
)
|
||||||
@GetMapping("/{mmsi}")
|
@GetMapping("/{mmsi}")
|
||||||
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
|
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
|
||||||
@ -98,7 +143,7 @@ public class AisTargetController {
|
|||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
|
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
|
||||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||||
@RequestParam Integer minutes,
|
@RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") Integer minutes,
|
||||||
@Parameter(description = "중심 경도", example = "129.0")
|
@Parameter(description = "중심 경도", example = "129.0")
|
||||||
@RequestParam(required = false) Double centerLon,
|
@RequestParam(required = false) Double centerLon,
|
||||||
@Parameter(description = "중심 위도", example = "35.0")
|
@Parameter(description = "중심 위도", example = "35.0")
|
||||||
@ -128,6 +173,10 @@ public class AisTargetController {
|
|||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "시간/공간 범위로 선박 검색 (POST)",
|
summary = "시간/공간 범위로 선박 검색 (POST)",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)")
|
||||||
|
},
|
||||||
description = """
|
description = """
|
||||||
POST 방식으로 검색 조건을 전달합니다.
|
POST 방식으로 검색 조건을 전달합니다.
|
||||||
|
|
||||||
@ -167,6 +216,10 @@ public class AisTargetController {
|
|||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "항해 조건 필터 검색",
|
summary = "항해 조건 필터 검색",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패")
|
||||||
|
},
|
||||||
description = """
|
description = """
|
||||||
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
|
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
|
||||||
|
|
||||||
@ -218,30 +271,30 @@ public class AisTargetController {
|
|||||||
"headingCondition": "LT",
|
"headingCondition": "LT",
|
||||||
"headingValue": 180.0,
|
"headingValue": 180.0,
|
||||||
"destination": "BUSAN",
|
"destination": "BUSAN",
|
||||||
"statusList": ["0", "1", "5"]
|
"statusList": ["Under way using engine", "At anchor", "Moored"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
## 항행상태 코드 (statusList)
|
## 항행상태 값 (statusList)
|
||||||
|
|
||||||
| 코드 | 상태 |
|
statusList에는 **텍스트 문자열**을 전달해야 합니다 (대소문자 무시).
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 0 | Under way using engine (기관 사용 항해 중) |
|
| Under way using engine | 기관 사용 항해 중 |
|
||||||
| 1 | At anchor (정박 중) |
|
| At anchor | 정박 중 |
|
||||||
| 2 | Not under command (조종불능) |
|
| Not under command | 조종불능 |
|
||||||
| 3 | Restricted manoeuverability (조종제한) |
|
| Restricted manoeuverability | 조종제한 |
|
||||||
| 4 | Constrained by her draught (흘수제약) |
|
| Constrained by her draught | 흘수제약 |
|
||||||
| 5 | Moored (계류 중) |
|
| Moored | 계류 중 |
|
||||||
| 6 | Aground (좌초) |
|
| Aground | 좌초 |
|
||||||
| 7 | Engaged in Fishing (어로 중) |
|
| Engaged in Fishing | 어로 중 |
|
||||||
| 8 | Under way sailing (돛 항해 중) |
|
| Under way sailing | 돛 항해 중 |
|
||||||
| 9-10 | Reserved for future use |
|
| Power Driven Towing Astern | 예인선 (후방) |
|
||||||
| 11 | Power-driven vessel towing astern |
|
| Power Driven Towing Alongside | 예인선 (측방) |
|
||||||
| 12 | Power-driven vessel pushing ahead |
|
| AIS Sart | 비상위치지시기 |
|
||||||
| 13 | Reserved for future use |
|
| N/A | 정보없음 |
|
||||||
| 14 | AIS-SART, MOB-AIS, EPIRB-AIS |
|
|
||||||
| 15 | Undefined (default) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
**참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함).
|
**참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함).
|
||||||
@ -269,6 +322,10 @@ public class AisTargetController {
|
|||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "폴리곤 범위 내 선박 검색",
|
summary = "폴리곤 범위 내 선박 검색",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)")
|
||||||
|
},
|
||||||
description = """
|
description = """
|
||||||
폴리곤 범위 내 선박을 검색합니다.
|
폴리곤 범위 내 선박을 검색합니다.
|
||||||
|
|
||||||
@ -283,7 +340,7 @@ public class AisTargetController {
|
|||||||
)
|
)
|
||||||
@PostMapping("/search/polygon")
|
@PostMapping("/search/polygon")
|
||||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
|
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
|
||||||
@RequestBody PolygonSearchRequest request) {
|
@Valid @RequestBody PolygonSearchRequest request) {
|
||||||
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
|
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
|
||||||
request.getMinutes(), request.getCoordinates().length);
|
request.getMinutes(), request.getCoordinates().length);
|
||||||
|
|
||||||
@ -299,6 +356,10 @@ public class AisTargetController {
|
|||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "WKT 범위 내 선박 검색",
|
summary = "WKT 범위 내 선박 검색",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)")
|
||||||
|
},
|
||||||
description = """
|
description = """
|
||||||
WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다.
|
WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다.
|
||||||
|
|
||||||
@ -313,7 +374,7 @@ public class AisTargetController {
|
|||||||
)
|
)
|
||||||
@PostMapping("/search/wkt")
|
@PostMapping("/search/wkt")
|
||||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
|
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
|
||||||
@RequestBody WktSearchRequest request) {
|
@Valid @RequestBody WktSearchRequest request) {
|
||||||
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
|
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
|
||||||
|
|
||||||
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
|
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
|
||||||
@ -405,11 +466,17 @@ public class AisTargetController {
|
|||||||
* 폴리곤 검색 요청 DTO
|
* 폴리곤 검색 요청 DTO
|
||||||
*/
|
*/
|
||||||
@lombok.Data
|
@lombok.Data
|
||||||
|
@Schema(description = "폴리곤 범위 검색 요청")
|
||||||
public static class PolygonSearchRequest {
|
public static class PolygonSearchRequest {
|
||||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
@NotNull(message = "minutes는 필수입니다")
|
||||||
private int minutes;
|
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||||
|
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private Integer minutes;
|
||||||
|
|
||||||
@Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true)
|
@NotNull(message = "coordinates는 필수입니다")
|
||||||
|
@Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)",
|
||||||
|
example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]",
|
||||||
|
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private double[][] coordinates;
|
private double[][] coordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,12 +484,17 @@ public class AisTargetController {
|
|||||||
* WKT 검색 요청 DTO
|
* WKT 검색 요청 DTO
|
||||||
*/
|
*/
|
||||||
@lombok.Data
|
@lombok.Data
|
||||||
|
@Schema(description = "WKT 범위 검색 요청")
|
||||||
public static class WktSearchRequest {
|
public static class WktSearchRequest {
|
||||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
@NotNull(message = "minutes는 필수입니다")
|
||||||
private int minutes;
|
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||||
|
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private Integer minutes;
|
||||||
|
|
||||||
@Parameter(description = "WKT 문자열", required = true,
|
@NotNull(message = "wkt는 필수입니다")
|
||||||
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
@Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)",
|
||||||
|
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))",
|
||||||
|
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String wkt;
|
private String wkt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,40 +22,87 @@ import java.time.OffsetDateTime;
|
|||||||
public class AisTargetResponseDto {
|
public class AisTargetResponseDto {
|
||||||
|
|
||||||
// 선박 식별 정보
|
// 선박 식별 정보
|
||||||
|
@Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456")
|
||||||
private Long mmsi;
|
private Long mmsi;
|
||||||
|
|
||||||
|
@Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960")
|
||||||
private Long imo;
|
private Long imo;
|
||||||
|
|
||||||
|
@Schema(description = "선박명", example = "ROYAUME DES OCEANS")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "호출 부호", example = "4SFTEST")
|
||||||
private String callsign;
|
private String callsign;
|
||||||
|
|
||||||
|
@Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel")
|
||||||
private String vesselType;
|
private String vesselType;
|
||||||
|
|
||||||
// 위치 정보
|
// 위치 정보
|
||||||
|
@Schema(description = "위도 (WGS84)", example = "35.0796")
|
||||||
private Double lat;
|
private Double lat;
|
||||||
|
|
||||||
|
@Schema(description = "경도 (WGS84)", example = "129.0756")
|
||||||
private Double lon;
|
private Double lon;
|
||||||
|
|
||||||
// 항해 정보
|
// 항해 정보
|
||||||
|
@Schema(description = "선수방위 (degrees, 0-360)", example = "36.0")
|
||||||
private Double heading;
|
private Double heading;
|
||||||
private Double sog; // Speed over Ground
|
|
||||||
private Double cog; // Course over Ground
|
@Schema(description = "대지속력 (knots)", example = "12.5")
|
||||||
private Integer rot; // Rate of Turn
|
private Double sog;
|
||||||
|
|
||||||
|
@Schema(description = "대지침로 (degrees, 0-360)", example = "36.2")
|
||||||
|
private Double cog;
|
||||||
|
|
||||||
|
@Schema(description = "회전율 (Rate of Turn)", example = "0")
|
||||||
|
private Integer rot;
|
||||||
|
|
||||||
// 선박 제원
|
// 선박 제원
|
||||||
|
@Schema(description = "선박 길이 (미터)", example = "19")
|
||||||
private Integer length;
|
private Integer length;
|
||||||
|
|
||||||
|
@Schema(description = "선박 폭 (미터)", example = "15")
|
||||||
private Integer width;
|
private Integer width;
|
||||||
|
|
||||||
|
@Schema(description = "흘수 (미터)", example = "5.5")
|
||||||
private Double draught;
|
private Double draught;
|
||||||
|
|
||||||
// 목적지 정보
|
// 목적지 정보
|
||||||
|
@Schema(description = "목적지", example = "BUSAN")
|
||||||
private String destination;
|
private String destination;
|
||||||
|
|
||||||
|
@Schema(description = "예정 도착 시간 (UTC)")
|
||||||
private OffsetDateTime eta;
|
private OffsetDateTime eta;
|
||||||
|
|
||||||
|
@Schema(description = "항행상태 (텍스트)", example = "Under way using engine")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
// 타임스탬프
|
// 타임스탬프
|
||||||
|
@Schema(description = "AIS 메시지 발생 시각 (UTC)")
|
||||||
private OffsetDateTime messageTimestamp;
|
private OffsetDateTime messageTimestamp;
|
||||||
|
|
||||||
|
@Schema(description = "데이터 수신 시각 (UTC)")
|
||||||
private OffsetDateTime receivedDate;
|
private OffsetDateTime receivedDate;
|
||||||
|
|
||||||
// 데이터 소스 (캐시/DB)
|
// 데이터 소스 (캐시/DB)
|
||||||
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
|
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
|
||||||
private String source;
|
private String source;
|
||||||
|
|
||||||
|
// 선종 분류 정보
|
||||||
|
@Schema(description = """
|
||||||
|
MDA 범례코드 (선종 분류)
|
||||||
|
- 000020: 어선 (FISHING)
|
||||||
|
- 000021: 함정 (KCGV)
|
||||||
|
- 000022: 여객선 (FERRY)
|
||||||
|
- 000023: 카고 (CARGO)
|
||||||
|
- 000024: 탱커 (TANKER)
|
||||||
|
- 000025: 관공선 (GOV)
|
||||||
|
- 000027: 일반/기타선박 (DEFAULT)
|
||||||
|
- 000028: 부이/항로표지 (BUOY)
|
||||||
|
""",
|
||||||
|
example = "000023")
|
||||||
|
private String signalKindCode;
|
||||||
|
|
||||||
// ClassType 분류 정보
|
// ClassType 분류 정보
|
||||||
@Schema(description = """
|
@Schema(description = """
|
||||||
선박 클래스 타입
|
선박 클래스 타입
|
||||||
@ -102,6 +149,7 @@ public class AisTargetResponseDto {
|
|||||||
.messageTimestamp(entity.getMessageTimestamp())
|
.messageTimestamp(entity.getMessageTimestamp())
|
||||||
.receivedDate(entity.getReceivedDate())
|
.receivedDate(entity.getReceivedDate())
|
||||||
.source(source)
|
.source(source)
|
||||||
|
.signalKindCode(entity.getSignalKindCode())
|
||||||
.classType(entity.getClassType())
|
.classType(entity.getClassType())
|
||||||
.core20Mmsi(entity.getCore20Mmsi())
|
.core20Mmsi(entity.getCore20Mmsi())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
|||||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||||
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
|
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
|
||||||
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
|
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
|
||||||
|
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
||||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||||
@ -38,6 +39,7 @@ public class AisTargetService {
|
|||||||
private final AisTargetCacheManager cacheManager;
|
private final AisTargetCacheManager cacheManager;
|
||||||
private final SpatialFilterUtil spatialFilterUtil;
|
private final SpatialFilterUtil spatialFilterUtil;
|
||||||
private final AisTargetFilterUtil filterUtil;
|
private final AisTargetFilterUtil filterUtil;
|
||||||
|
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||||
|
|
||||||
private static final String SOURCE_CACHE = "cache";
|
private static final String SOURCE_CACHE = "cache";
|
||||||
private static final String SOURCE_DB = "db";
|
private static final String SOURCE_DB = "db";
|
||||||
@ -360,6 +362,36 @@ public class AisTargetService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 중국 허가선박 전용 조회 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중국 허가선박 전용 캐시 조회 (DB fallback 없음)
|
||||||
|
*
|
||||||
|
* @param minutes 조회 범위 (분)
|
||||||
|
* @return 시간 범위 내 대상 선박 목록
|
||||||
|
*/
|
||||||
|
public List<AisTargetResponseDto> findChnPrmShip(int minutes) {
|
||||||
|
log.debug("ChnPrmShip 조회 - minutes: {}", minutes);
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
List<AisTargetEntity> entities = chnPrmShipCacheManager.getByTimeRange(minutes);
|
||||||
|
|
||||||
|
long elapsed = System.currentTimeMillis() - startTime;
|
||||||
|
log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||||
|
|
||||||
|
return entities.stream()
|
||||||
|
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChnPrmShip 캐시 통계 조회
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getChnPrmShipCacheStats() {
|
||||||
|
return chnPrmShipCacheManager.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 캐시 관리 ====================
|
// ==================== 캐시 관리 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
|
|||||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.StepContribution;
|
import org.springframework.batch.core.StepContribution;
|
||||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
@ -12,53 +11,69 @@ import org.springframework.batch.repeat.RepeatStatus;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AIS Target DB Sync Tasklet
|
* AIS Target DB Sync Tasklet
|
||||||
*
|
*
|
||||||
* 스케줄: 매 15분 (0 0/15 * * * ?)
|
|
||||||
*
|
|
||||||
* 동작:
|
* 동작:
|
||||||
* - Caffeine 캐시에서 최근 N분 이내 데이터 조회
|
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회
|
||||||
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
|
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
|
||||||
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
|
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
|
||||||
*
|
*
|
||||||
|
* 시간 범위 결정 전략:
|
||||||
|
* - 첫 실행 또는 마지막 실행 정보 없음 → fallback(time-range-minutes) 사용
|
||||||
|
* - 이후 실행 → 마지막 성공 시각 기준으로 경과 시간 자동 계산
|
||||||
|
* - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화)
|
||||||
|
*
|
||||||
* 참고:
|
* 참고:
|
||||||
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
|
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
|
||||||
* - DB 저장은 15분 주기로 수행하여 볼륨 절감
|
|
||||||
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
|
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AisTargetDbSyncTasklet implements Tasklet {
|
public class AisTargetDbSyncTasklet implements Tasklet {
|
||||||
|
|
||||||
private final AisTargetCacheManager cacheManager;
|
private final AisTargetCacheManager cacheManager;
|
||||||
private final AisTargetRepository aisTargetRepository;
|
private final AisTargetRepository aisTargetRepository;
|
||||||
|
private final int fallbackMinutes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB 동기화 시 조회할 캐시 데이터 시간 범위 (분)
|
* 마지막 성공 시각 (JVM 내 유지, 재기동 시 fallback 사용)
|
||||||
* 기본값: 15분 (스케줄 주기와 동일)
|
|
||||||
*/
|
*/
|
||||||
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}")
|
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
|
||||||
private int timeRangeMinutes;
|
|
||||||
|
public AisTargetDbSyncTasklet(
|
||||||
|
AisTargetCacheManager cacheManager,
|
||||||
|
AisTargetRepository aisTargetRepository,
|
||||||
|
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
this.aisTargetRepository = aisTargetRepository;
|
||||||
|
this.fallbackMinutes = fallbackMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
int rangeMinutes = resolveRangeMinutes(now);
|
||||||
|
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
log.info("AIS Target DB Sync 시작");
|
log.info("AIS Target DB Sync 시작");
|
||||||
log.info("조회 범위: 최근 {}분", timeRangeMinutes);
|
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
|
||||||
|
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
|
||||||
log.info("현재 캐시 크기: {}", cacheManager.size());
|
log.info("현재 캐시 크기: {}", cacheManager.size());
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
|
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
// 1. 캐시에서 최근 N분 이내 데이터 조회
|
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(timeRangeMinutes);
|
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
|
||||||
|
|
||||||
if (entities.isEmpty()) {
|
if (entities.isEmpty()) {
|
||||||
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes);
|
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
|
||||||
|
lastSuccessTime.set(now);
|
||||||
return RepeatStatus.FINISHED;
|
return RepeatStatus.FINISHED;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,6 +84,9 @@ public class AisTargetDbSyncTasklet implements Tasklet {
|
|||||||
|
|
||||||
long elapsed = System.currentTimeMillis() - startTime;
|
long elapsed = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
// 성공 시각 기록
|
||||||
|
lastSuccessTime.set(now);
|
||||||
|
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
log.info("AIS Target DB Sync 완료");
|
log.info("AIS Target DB Sync 완료");
|
||||||
log.info("저장 건수: {} 건", entities.size());
|
log.info("저장 건수: {} 건", entities.size());
|
||||||
@ -80,4 +98,24 @@ public class AisTargetDbSyncTasklet implements Tasklet {
|
|||||||
|
|
||||||
return RepeatStatus.FINISHED;
|
return RepeatStatus.FINISHED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final int MAX_RANGE_MINUTES = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 범위(분) 결정
|
||||||
|
* - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분)
|
||||||
|
* - 없으면: fallback 값 사용
|
||||||
|
* - 오래 중단 후 재가동 시에도 최대 60분으로 제한하여 과부하 방지
|
||||||
|
*/
|
||||||
|
private int resolveRangeMinutes(Instant now) {
|
||||||
|
Instant last = lastSuccessTime.get();
|
||||||
|
if (last == null) {
|
||||||
|
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
long elapsedMinutes = java.time.Duration.between(last, now).toMinutes();
|
||||||
|
// 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분
|
||||||
|
int range = (int) Math.max(elapsedMinutes + 1, 1);
|
||||||
|
return Math.min(range, MAX_RANGE_MINUTES);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.snp.batch.jobs.common.batch.repository;
|
|||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@ -15,18 +16,29 @@ import java.util.List;
|
|||||||
@Repository("FlagCodeRepository")
|
@Repository("FlagCodeRepository")
|
||||||
public class FlagCodeRepositoryImpl extends BaseJdbcRepository<FlagCodeEntity, String> implements FlagCodeRepository {
|
public class FlagCodeRepositoryImpl extends BaseJdbcRepository<FlagCodeEntity, String> implements FlagCodeRepository {
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.name}")
|
||||||
|
private String targetSchema;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.code-002}")
|
||||||
|
private String tableName;
|
||||||
|
|
||||||
public FlagCodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
public FlagCodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||||
super(jdbcTemplate);
|
super(jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getTargetSchema() {
|
||||||
|
return targetSchema;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getEntityName() {
|
protected String getEntityName() {
|
||||||
return "FlagCodeEntity";
|
return "FlagCodeEntity";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getTableName() {
|
protected String getSimpleTableName() {
|
||||||
return "t_snp_data.flagcode";
|
return tableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -39,8 +51,8 @@ public class FlagCodeRepositoryImpl extends BaseJdbcRepository<FlagCodeEntity, S
|
|||||||
protected String getUpdateSql() {
|
protected String getUpdateSql() {
|
||||||
return """
|
return """
|
||||||
INSERT INTO %s(
|
INSERT INTO %s(
|
||||||
datasetversion, code, decode, iso2, iso3,
|
dataset_ver, ship_country_cd, cd_nm, iso_two_cd, iso_thr_cd,
|
||||||
job_execution_id, created_by
|
job_execution_id, creatr_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?);
|
) VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||||
""".formatted(getTableName());
|
""".formatted(getTableName());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.snp.batch.jobs.common.batch.repository;
|
|||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@ -14,18 +15,30 @@ import java.util.List;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Repository("Stat5CodeRepository")
|
@Repository("Stat5CodeRepository")
|
||||||
public class Stat5CodeRepositoryImpl extends BaseJdbcRepository<Stat5CodeEntity, String> implements Stat5CodeRepository{
|
public class Stat5CodeRepositoryImpl extends BaseJdbcRepository<Stat5CodeEntity, String> implements Stat5CodeRepository{
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.name}")
|
||||||
|
private String targetSchema;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.code-001}")
|
||||||
|
private String tableName;
|
||||||
|
|
||||||
public Stat5CodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
public Stat5CodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||||
super(jdbcTemplate);
|
super(jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getTargetSchema() {
|
||||||
|
return targetSchema;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getEntityName() {
|
protected String getEntityName() {
|
||||||
return "Stat5CodeEntity";
|
return "Stat5CodeEntity";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getTableName() {
|
protected String getSimpleTableName() {
|
||||||
return "t_snp_data.stat5code";
|
return tableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -47,8 +60,8 @@ public class Stat5CodeRepositoryImpl extends BaseJdbcRepository<Stat5CodeEntity,
|
|||||||
protected String getUpdateSql() {
|
protected String getUpdateSql() {
|
||||||
return """
|
return """
|
||||||
INSERT INTO %s(
|
INSERT INTO %s(
|
||||||
level1, level1decode, level2, level2decode, level3, level3decode, level4, level4decode, level5, level5decode, description, release,
|
lv_one, lv_one_desc, lv_two, lv_two_desc, lv_thr, lv_thr_desc, lv_four, lv_four_desc, lv_five, lv_five_desc, dtl_desc, rls_iem,
|
||||||
job_execution_id, created_by
|
job_execution_id, creatr_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
""".formatted(getTableName());
|
""".formatted(getTableName());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,9 +47,12 @@ public class CompanyComplianceImportRangeJobConfig extends BaseMultiStepJobConfi
|
|||||||
|
|
||||||
@Value("${app.batch.webservice-api.url}")
|
@Value("${app.batch.webservice-api.url}")
|
||||||
private String maritimeServiceApiUrl;
|
private String maritimeServiceApiUrl;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.name}")
|
||||||
|
private String targetSchema;
|
||||||
protected String getApiKey() {return "COMPANY_COMPLIANCE_IMPORT_API";}
|
protected String getApiKey() {return "COMPANY_COMPLIANCE_IMPORT_API";}
|
||||||
protected String getBatchUpdateSql() {
|
protected String getBatchUpdateSql() {
|
||||||
return String.format("UPDATE T_SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
|
return String.format("UPDATE %s.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", targetSchema, getApiKey());}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected int getChunkSize() {
|
protected int getChunkSize() {
|
||||||
@ -157,7 +160,8 @@ public class CompanyComplianceImportRangeJobConfig extends BaseMultiStepJobConfi
|
|||||||
log.info("Company Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt);
|
log.info("Company Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt);
|
||||||
|
|
||||||
// 3. 프로시저 호출 (안전한 파라미터 바인딩 권장)
|
// 3. 프로시저 호출 (안전한 파라미터 바인딩 권장)
|
||||||
jdbcTemplate.update("CALL new_snp.company_compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", startDt, endDt);
|
String procedureCall = String.format("CALL %s.company_compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", targetSchema);
|
||||||
|
jdbcTemplate.update(procedureCall, startDt, endDt);
|
||||||
|
|
||||||
log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 완료");
|
log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 완료");
|
||||||
return RepeatStatus.FINISHED;
|
return RepeatStatus.FINISHED;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user