feat: snp-sync-batch 프로젝트 초기 설정
mda-snp-batch 기반으로 snp-sync-batch 프로젝트 생성 - 프론트엔드: Thymeleaf → React + TypeScript + Vite + Tailwind CSS 전환 - 컨텍스트: /snp-sync, 포트 8051 - 재수집(Recollection) 관련 코드 제거 - displayName → job_schedule.description 기반으로 전환 - 누락 API 추가 (statistics, jobs/detail, executions/recent) - 실행 이력 조회 속도 개선 (JDBC 경량 쿼리) - 스케줄 CRUD API 메서드 매핑 수정 (PUT/DELETE) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
커밋
744cc02f36
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto
|
||||||
109
.gitignore
vendored
Normal file
109
.gitignore
vendored
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# Compiled class file
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Log file
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# BlueJ files
|
||||||
|
*.ctxt
|
||||||
|
|
||||||
|
# Mobile Tools for Java (J2ME)
|
||||||
|
.mtj.tmp/
|
||||||
|
|
||||||
|
# Package Files
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.nar
|
||||||
|
*.ear
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# virtual machine crash logs
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
|
|
||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
release.properties
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
buildNumber.properties
|
||||||
|
.mvn/timing.properties
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
# IntelliJ IDEA
|
||||||
|
.idea/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
# Eclipse
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# NetBeans
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
application-local.yml
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
docs/
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# Session continuity files (for AI assistants)
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node/
|
||||||
|
frontend/node_modules/
|
||||||
|
src/main/resources/static/
|
||||||
|
|
||||||
|
nul
|
||||||
1602
DEVELOPMENT_GUIDE.md
Normal file
1602
DEVELOPMENT_GUIDE.md
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
224
SWAGGER_GUIDE.md
Normal file
224
SWAGGER_GUIDE.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
# Swagger API 문서화 가이드
|
||||||
|
|
||||||
|
**버전**: 1.1.0
|
||||||
|
**프로젝트**: SNP Sync Batch - 해양 데이터 동기화 배치 시스템
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swagger UI 접속 정보
|
||||||
|
|
||||||
|
### 접속 URL
|
||||||
|
|
||||||
|
```
|
||||||
|
Swagger UI: http://localhost:8051/snp-sync/swagger-ui/index.html
|
||||||
|
API 문서 (JSON): http://localhost:8051/snp-sync/v3/api-docs
|
||||||
|
API 문서 (YAML): http://localhost:8051/snp-sync/v3/api-docs.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 환경별 접속 URL
|
||||||
|
|
||||||
|
| 환경 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| 로컬 개발 | `http://localhost:8051/snp-sync/swagger-ui/index.html` |
|
||||||
|
| 개발 서버 | `http://211.208.115.83:8051/snp-sync/swagger-ui/index.html` |
|
||||||
|
| 운영 서버 | `http://211.208.115.83:8051/snp-sync/swagger-ui/index.html` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제공되는 API
|
||||||
|
|
||||||
|
### Batch Management API (`/api/batch`)
|
||||||
|
|
||||||
|
배치 작업 실행, 조회, 스케줄 관리 API
|
||||||
|
|
||||||
|
#### Job 실행 및 조회
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `GET` | `/api/batch/jobs` | 등록된 배치 작업 목록 조회 |
|
||||||
|
| `POST` | `/api/batch/jobs/{jobName}/execute` | 배치 작업 수동 실행 |
|
||||||
|
| `GET` | `/api/batch/jobs/{jobName}/executions` | 작업별 실행 이력 조회 |
|
||||||
|
| `GET` | `/api/batch/executions/{executionId}` | 실행 정보 조회 |
|
||||||
|
| `GET` | `/api/batch/executions/{executionId}/detail` | Step 포함 상세 실행 정보 조회 |
|
||||||
|
| `POST` | `/api/batch/executions/{executionId}/stop` | 실행 중지 |
|
||||||
|
|
||||||
|
#### 스케줄 관리
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `GET` | `/api/batch/schedules` | 스케줄 목록 조회 |
|
||||||
|
| `GET` | `/api/batch/schedules/{jobName}` | 특정 작업 스케줄 조회 |
|
||||||
|
| `POST` | `/api/batch/schedules` | 스케줄 생성 |
|
||||||
|
| `PUT` | `/api/batch/schedules/{jobName}` | 스케줄 수정 |
|
||||||
|
| `DELETE` | `/api/batch/schedules/{jobName}` | 스케줄 삭제 |
|
||||||
|
| `PATCH` | `/api/batch/schedules/{jobName}/toggle` | 스케줄 활성화/비활성화 |
|
||||||
|
|
||||||
|
#### 대시보드 및 타임라인
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `GET` | `/api/batch/dashboard` | 대시보드 데이터 조회 |
|
||||||
|
| `GET` | `/api/batch/timeline` | 타임라인 데이터 조회 |
|
||||||
|
| `GET` | `/api/batch/timeline/period-executions` | 기간별 실행 이력 조회 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 테스트 예시
|
||||||
|
|
||||||
|
### 1. 배치 작업 목록 조회
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET http://localhost:8051/snp-sync/api/batch/jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 응답**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"shipDetailSyncJob",
|
||||||
|
"codeDataSyncJob",
|
||||||
|
"eventDataSyncJob",
|
||||||
|
"facilityDataSyncJob",
|
||||||
|
"pscDataSyncJob",
|
||||||
|
"riskDataSyncJob",
|
||||||
|
"shipComplianceDataSyncJob",
|
||||||
|
"anchorageCallSyncJob",
|
||||||
|
"lastPositionUpdateJob"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 배치 작업 실행
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST http://localhost:8051/snp-sync/api/batch/jobs/shipDetailSyncJob/execute
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 응답**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Job started successfully",
|
||||||
|
"executionId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 실행 이력 조회
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET http://localhost:8051/snp-sync/api/batch/jobs/shipDetailSyncJob/executions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 스케줄 생성
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST http://localhost:8051/snp-sync/api/batch/schedules
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"jobName": "shipDetailSyncJob",
|
||||||
|
"cronExpression": "0 0 * * * ?",
|
||||||
|
"description": "선박 정보 매시간 동기화"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 스케줄 활성화/비활성화
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH http://localhost:8051/snp-sync/api/batch/schedules/shipDetailSyncJob/toggle
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swagger 어노테이션 가이드
|
||||||
|
|
||||||
|
### 주요 어노테이션
|
||||||
|
|
||||||
|
#### 1. `@Tag` - API 그룹화
|
||||||
|
```java
|
||||||
|
@Tag(name = "Batch Management API", description = "배치 작업 실행 및 스케줄 관리 API")
|
||||||
|
public class BatchController { }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `@Operation` - 엔드포인트 문서화
|
||||||
|
```java
|
||||||
|
@Operation(
|
||||||
|
summary = "배치 작업 실행",
|
||||||
|
description = "지정된 배치 작업을 즉시 실행합니다"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `@Parameter` - 파라미터 설명
|
||||||
|
```java
|
||||||
|
@Parameter(description = "실행할 배치 작업 이름", required = true, example = "shipDetailSyncJob")
|
||||||
|
@PathVariable String jobName
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `@ApiResponses` - 응답 정의
|
||||||
|
```java
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "작업 실행 성공"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "작업 실행 실패")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 신규 Controller에 Swagger 적용
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/custom")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Custom API", description = "커스텀 API")
|
||||||
|
public class CustomController {
|
||||||
|
|
||||||
|
@Operation(summary = "커스텀 조회", description = "특정 조건으로 데이터를 조회합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류")
|
||||||
|
})
|
||||||
|
@GetMapping("/data")
|
||||||
|
public ResponseEntity<Map<String, Object>> getData(
|
||||||
|
@Parameter(description = "조회 조건", required = true)
|
||||||
|
@RequestParam String condition) {
|
||||||
|
// 구현...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문제 해결
|
||||||
|
|
||||||
|
### Swagger UI 접속 불가 (404)
|
||||||
|
|
||||||
|
1. 애플리케이션이 실행 중인지 확인
|
||||||
|
2. 포트(8051)와 context-path(`/snp-sync`) 확인
|
||||||
|
3. 다음 URL 시도:
|
||||||
|
- `http://localhost:8051/snp-sync/swagger-ui/index.html`
|
||||||
|
- `http://localhost:8051/snp-sync/swagger-ui.html`
|
||||||
|
|
||||||
|
### 특정 엔드포인트가 보이지 않음
|
||||||
|
|
||||||
|
1. `@RestController` 어노테이션 확인
|
||||||
|
2. `@RequestMapping` 경로 확인
|
||||||
|
3. Controller가 `com.snp.batch` 패키지 하위에 있는지 확인
|
||||||
|
4. 애플리케이션 재시작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/com/snp/batch/
|
||||||
|
├── global/config/SwaggerConfig.java # Swagger 설정
|
||||||
|
├── global/controller/BatchController.java # Batch Management API
|
||||||
|
└── common/web/controller/BaseController.java # 공통 CRUD Base Controller
|
||||||
|
```
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
- [Springdoc OpenAPI](https://springdoc.org/)
|
||||||
|
- [OpenAPI 3.0 Annotations](https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations)
|
||||||
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>S&P 동기화 관리</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-sync">
|
||||||
|
<ToastProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
frontend/src/api/batchApi.ts
Normal file
402
frontend/src/api/batchApi.ts
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
const BASE = import.meta.env.DEV ? '/snp-sync/api/batch' : '/snp-sync/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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, { method: 'DELETE' });
|
||||||
|
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;
|
||||||
|
failedRecordCount: number | 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;
|
||||||
|
apiLogSummary: StepApiLogSummary | null;
|
||||||
|
failedRecords?: FailedRecordDto[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiLogEntryDto {
|
||||||
|
logId: number;
|
||||||
|
requestUri: string;
|
||||||
|
httpMethod: string;
|
||||||
|
statusCode: number | null;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
responseCount: number | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepApiLogSummary {
|
||||||
|
totalCalls: number;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
avgResponseMs: number;
|
||||||
|
maxResponseMs: number;
|
||||||
|
minResponseMs: number;
|
||||||
|
totalResponseMs: number;
|
||||||
|
totalRecordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiLogPageResponse {
|
||||||
|
content: ApiLogEntryDto[];
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiLogStatus = 'ALL' | 'SUCCESS' | 'ERROR';
|
||||||
|
|
||||||
|
export interface FailedRecordDto {
|
||||||
|
id: number;
|
||||||
|
jobName: string;
|
||||||
|
recordKey: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
retryCount: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) => {
|
||||||
|
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||||
|
`${BASE}/jobs/${jobName}/execute${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
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 }) =>
|
||||||
|
putJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
|
||||||
|
`${BASE}/schedules/${jobName}`, data),
|
||||||
|
|
||||||
|
deleteSchedule: (jobName: string) =>
|
||||||
|
deleteJson<{ success: boolean; message: string }>(`${BASE}/schedules/${jobName}`),
|
||||||
|
|
||||||
|
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}`),
|
||||||
|
|
||||||
|
getStepApiLogs: (stepExecutionId: number, params?: {
|
||||||
|
page?: number; size?: number; status?: ApiLogStatus;
|
||||||
|
}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('page', String(params?.page ?? 0));
|
||||||
|
qs.set('size', String(params?.size ?? 50));
|
||||||
|
if (params?.status && params.status !== 'ALL') qs.set('status', params.status);
|
||||||
|
return fetchJson<ApiLogPageResponse>(
|
||||||
|
`${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Failed Records
|
||||||
|
retryFailedRecords: (jobName: string, failedCount: number, jobExecutionId: number) => {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
sourceJobExecutionId: String(jobExecutionId),
|
||||||
|
executionMode: 'RETRY',
|
||||||
|
executor: 'MANUAL_RETRY',
|
||||||
|
reason: `실패 건 수동 재시도 (${failedCount}건)`,
|
||||||
|
});
|
||||||
|
return postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||||
|
`${BASE}/jobs/${jobName}/execute?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveFailedRecords: (ids: number[]) =>
|
||||||
|
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
|
||||||
|
`${BASE}/failed-records/resolve`, { ids }),
|
||||||
|
|
||||||
|
resetRetryCount: (ids: number[]) =>
|
||||||
|
postJson<{ success: boolean; message: string; resetCount?: number }>(
|
||||||
|
`${BASE}/failed-records/reset-retry`, { ids }),
|
||||||
|
};
|
||||||
170
frontend/src/components/ApiLogSection.tsx
Normal file
170
frontend/src/components/ApiLogSection.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { batchApi, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
|
import Pagination from './Pagination';
|
||||||
|
import CopyButton from './CopyButton';
|
||||||
|
|
||||||
|
interface ApiLogSectionProps {
|
||||||
|
stepExecutionId: number;
|
||||||
|
summary: { totalCalls: number; successCount: number; errorCount: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState<ApiLogStatus>('ALL');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [logData, setLogData] = useState<ApiLogPageResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s });
|
||||||
|
setLogData(data);
|
||||||
|
} catch {
|
||||||
|
setLogData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [stepExecutionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchLogs(page, status);
|
||||||
|
}
|
||||||
|
}, [open, page, status, fetchLogs]);
|
||||||
|
|
||||||
|
const handleStatusChange = (s: ApiLogStatus) => {
|
||||||
|
setStatus(s);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters: { key: ApiLogStatus; label: string; count: number }[] = [
|
||||||
|
{ key: 'ALL', label: '전체', count: summary.totalCalls },
|
||||||
|
{ key: 'SUCCESS', label: '성공', count: summary.successCount },
|
||||||
|
{ key: 'ERROR', label: '에러', count: summary.errorCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
개별 호출 로그 ({summary.totalCalls.toLocaleString()}건)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{/* 상태 필터 탭 */}
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{filters.map(({ key, label, count }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleStatusChange(key)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||||
|
status === key
|
||||||
|
? key === 'ERROR'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: key === 'SUCCESS'
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-blue-100 text-blue-700'
|
||||||
|
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} ({count.toLocaleString()})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
<span className="ml-2 text-xs text-blue-500">로딩중...</span>
|
||||||
|
</div>
|
||||||
|
) : logData && logData.content.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-blue-100 text-blue-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 font-medium">#</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">URI</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">Method</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">상태</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">응답(ms)</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-right">건수</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">시간</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">에러</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-blue-100">
|
||||||
|
{logData.content.map((log, idx) => {
|
||||||
|
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={log.logId}
|
||||||
|
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
|
||||||
|
<td className="px-2 py-1.5 max-w-[200px]">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
|
||||||
|
{log.requestUri}
|
||||||
|
</span>
|
||||||
|
<CopyButton text={log.requestUri} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
log.statusCode == null ? 'text-gray-400'
|
||||||
|
: log.statusCode < 300 ? 'text-emerald-600'
|
||||||
|
: log.statusCode < 400 ? 'text-amber-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{log.statusCode ?? '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-blue-900">
|
||||||
|
{log.responseTimeMs?.toLocaleString() ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-blue-900">
|
||||||
|
{log.responseCount?.toLocaleString() ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
|
||||||
|
{formatDateTime(log.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
|
||||||
|
{log.errorMessage || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={logData.totalPages}
|
||||||
|
totalElements={logData.totalElements}
|
||||||
|
pageSize={10}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-wing-muted py-3 text-center">조회된 로그가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/CopyButton.tsx
Normal file
48
frontend/src/components/CopyButton.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CopyButton({ text }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={copied ? '복사됨!' : 'URI 복사'}
|
||||||
|
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/DetailStatCard.tsx
Normal file
22
frontend/src/components/DetailStatCard.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
interface DetailStatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
gradient: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailStatCard({ label, value, gradient, icon }: DetailStatCardProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/GuideModal.tsx
Normal file
92
frontend/src/components/GuideModal.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface GuideSection {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
pageTitle: string;
|
||||||
|
sections: GuideSection[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GuideModal({ open, pageTitle, sections, 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-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text">{pageTitle} 사용 가이드</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<GuideAccordion key={i} title={section.title} content={section.content} defaultOpen={i === 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideAccordion({ title, content, defaultOpen }: { title: string; content: string; defaultOpen: boolean }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-wing-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`w-4 h-4 text-wing-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 py-3 text-sm text-wing-muted leading-relaxed whitespace-pre-line">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title="사용 가이드"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-full border border-wing-border text-wing-muted hover:text-wing-accent hover:border-wing-accent transition-colors text-sm font-semibold"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/InfoItem.tsx
Normal file
15
frontend/src/components/InfoItem.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
interface InfoItemProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoItem({ label, value }: InfoItemProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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: '/executions', label: '실행 이력', icon: '📋' },
|
||||||
|
{ path: '/jobs', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/components/Pagination.tsx
Normal file
145
frontend/src/components/Pagination.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
interface PaginationProps {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalElements: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시할 페이지 번호 목록 생성 (Truncated Page Number)
|
||||||
|
* - 총 7슬롯 이하면 전부 표시
|
||||||
|
* - 7슬롯 초과면 현재 페이지 기준 양쪽 1개 + 처음/끝 + ellipsis
|
||||||
|
*/
|
||||||
|
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
|
||||||
|
if (total <= 7) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
const SIBLING = 1;
|
||||||
|
|
||||||
|
const leftSibling = Math.max(current - SIBLING, 0);
|
||||||
|
const rightSibling = Math.min(current + SIBLING, total - 1);
|
||||||
|
|
||||||
|
const showLeftEllipsis = leftSibling > 1;
|
||||||
|
const showRightEllipsis = rightSibling < total - 2;
|
||||||
|
|
||||||
|
pages.push(0);
|
||||||
|
|
||||||
|
if (showLeftEllipsis) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < leftSibling; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = leftSibling; i <= rightSibling; i++) {
|
||||||
|
if (i !== 0 && i !== total - 1) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRightEllipsis) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
} else {
|
||||||
|
for (let i = rightSibling + 1; i < total - 1; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 1) {
|
||||||
|
pages.push(total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
totalElements,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const start = page * pageSize + 1;
|
||||||
|
const end = Math.min((page + 1) * pageSize, totalElements);
|
||||||
|
const pages = getPageNumbers(page, totalPages);
|
||||||
|
|
||||||
|
const btnBase =
|
||||||
|
'inline-flex items-center justify-center w-7 h-7 text-xs rounded transition-colors';
|
||||||
|
const btnEnabled = 'hover:bg-wing-hover text-wing-muted';
|
||||||
|
const btnDisabled = 'opacity-30 cursor-not-allowed text-wing-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||||
|
<span>
|
||||||
|
{totalElements.toLocaleString()}건 중 {start.toLocaleString()}~
|
||||||
|
{end.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{/* First */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(0)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="처음"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="이전"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span key={`e-${idx}`} className="w-7 h-7 inline-flex items-center justify-center text-wing-muted">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`${btnBase} ${
|
||||||
|
p === page
|
||||||
|
? 'bg-wing-accent text-white font-semibold'
|
||||||
|
: btnEnabled
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="다음"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
{/* Last */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(totalPages - 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="마지막"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>,
|
||||||
|
)
|
||||||
394
frontend/src/pages/Dashboard.tsx
Normal file
394
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
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';
|
||||||
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 5000;
|
||||||
|
|
||||||
|
const DASHBOARD_GUIDE = [
|
||||||
|
{
|
||||||
|
title: '통계 카드',
|
||||||
|
content: '화면 상단에 전체 스케줄, 활성/비활성 스케줄 수, 전체 작업 수, 최근 24시간 실패 건수를 한눈에 보여줍니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '실행 중인 작업',
|
||||||
|
content: '현재 실행 중인 배치 작업 목록을 실시간으로 보여줍니다.\n5초마다 자동으로 갱신됩니다.\n오래 실행 중인 작업이 있으면 상단에 경고 배너가 표시되며, "전체 강제 종료" 버튼으로 일괄 중지할 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '최근 실행 이력',
|
||||||
|
content: '최근 완료된 배치 작업 5건을 보여줍니다.\n각 행의 작업명, 상태, 시작 시간, 소요 시간을 확인할 수 있습니다.\n"전체 보기"를 클릭하면 실행 이력 화면으로 이동합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '최근 실패 이력',
|
||||||
|
content: '최근 24시간 내 실패한 작업이 있을 때만 표시됩니다.\n실패 원인을 빠르게 파악할 수 있도록 종료 코드와 메시지를 함께 보여줍니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '실행 통계 차트',
|
||||||
|
content: '최근 30일간의 배치 실행 통계를 바 차트로 보여줍니다.\n초록색은 성공, 빨간색은 실패, 회색은 기타 상태를 나타냅니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
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 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">대시보드</h1>
|
||||||
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GuideModal
|
||||||
|
open={guideOpen}
|
||||||
|
pageTitle="대시보드"
|
||||||
|
sections={DASHBOARD_GUIDE}
|
||||||
|
onClose={() => setGuideOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
708
frontend/src/pages/ExecutionDetail.tsx
Normal file
708
frontend/src/pages/ExecutionDetail.tsx
Normal file
@ -0,0 +1,708 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } 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';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
import DetailStatCard from '../components/DetailStatCard';
|
||||||
|
import ApiLogSection from '../components/ApiLogSection';
|
||||||
|
import InfoItem from '../components/InfoItem';
|
||||||
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
const EXECUTION_DETAIL_GUIDE = [
|
||||||
|
{
|
||||||
|
title: '실행 기본 정보',
|
||||||
|
content: '실행의 시작/종료 시간, 소요 시간, 종료 코드, 에러 메시지 등 기본 정보를 보여줍니다.\n실행 중인 경우 5초마다 자동으로 갱신됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '처리 통계',
|
||||||
|
content: '4개의 통계 카드로 전체 처리 현황을 요약합니다.\n• 읽기(Read): 외부 API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 처리하지 않은 건수\n• 필터(Filter): 조건에 의해 제외된 건수',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Step 실행 정보',
|
||||||
|
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API 호출 로그',
|
||||||
|
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '실패 건 관리',
|
||||||
|
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface StepCardProps {
|
||||||
|
step: StepExecutionDto;
|
||||||
|
jobName: string;
|
||||||
|
jobExecutionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard({ step, jobName, jobExecutionId }: 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 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
|
||||||
|
{step.apiLogSummary ? (
|
||||||
|
<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-3 sm:grid-cols-6 gap-2">
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-wing-text">{step.apiLogSummary.totalCalls.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">총 호출</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-emerald-600">{step.apiLogSummary.successCount.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">성공</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className={`text-sm font-bold ${step.apiLogSummary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
|
||||||
|
{step.apiLogSummary.errorCount.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">에러</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-blue-600">{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-red-500">{step.apiLogSummary.maxResponseMs.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white px-2 py-1.5 text-center">
|
||||||
|
<p className="text-sm font-bold text-emerald-500">{step.apiLogSummary.minResponseMs.toLocaleString()}</p>
|
||||||
|
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.apiLogSummary.totalCalls > 0 && (
|
||||||
|
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={step.apiLogSummary} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : 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.failedRecords && step.failedRecords.length > 0 && (
|
||||||
|
<FailedRecordsToggle records={step.failedRecords} jobName={jobName} jobExecutionId={jobExecutionId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">
|
||||||
|
실행 #{detail.executionId}
|
||||||
|
</h1>
|
||||||
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<DetailStatCard
|
||||||
|
label="읽기 (Read)"
|
||||||
|
value={detail.readCount}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
icon="📥"
|
||||||
|
/>
|
||||||
|
<DetailStatCard
|
||||||
|
label="쓰기 (Write)"
|
||||||
|
value={detail.writeCount}
|
||||||
|
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||||
|
icon="📤"
|
||||||
|
/>
|
||||||
|
<DetailStatCard
|
||||||
|
label="건너뜀 (Skip)"
|
||||||
|
value={detail.skipCount}
|
||||||
|
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||||
|
icon="⏭"
|
||||||
|
/>
|
||||||
|
<DetailStatCard
|
||||||
|
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}
|
||||||
|
jobName={detail.jobName}
|
||||||
|
jobExecutionId={executionId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GuideModal
|
||||||
|
open={guideOpen}
|
||||||
|
onClose={() => setGuideOpen(false)}
|
||||||
|
pageTitle="실행 상세"
|
||||||
|
sections={EXECUTION_DETAIL_GUIDE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILED_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const failedRecords = records.filter((r) => r.status === 'FAILED');
|
||||||
|
const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE);
|
||||||
|
const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE);
|
||||||
|
|
||||||
|
const statusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'RESOLVED': return 'text-emerald-600 bg-emerald-50';
|
||||||
|
case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50';
|
||||||
|
default: return 'text-red-600 bg-red-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RETRY_COUNT = 3;
|
||||||
|
|
||||||
|
const retryStatusLabel = (record: FailedRecordDto) => {
|
||||||
|
if (record.status !== 'FAILED') return null;
|
||||||
|
if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' };
|
||||||
|
if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' };
|
||||||
|
return { label: '대기', color: 'text-blue-600 bg-blue-100' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT);
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
setRetrying(true);
|
||||||
|
try {
|
||||||
|
const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId);
|
||||||
|
if (result.success) {
|
||||||
|
setShowConfirm(false);
|
||||||
|
if (result.executionId) {
|
||||||
|
navigate(`/executions/${result.executionId}`);
|
||||||
|
} else {
|
||||||
|
alert(result.message || '재수집이 요청되었습니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(result.message || '재수집 실행에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('재수집 실행에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setRetrying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const ids = failedRecords.map((r) => r.id);
|
||||||
|
await batchApi.resolveFailedRecords(ids);
|
||||||
|
setShowResolveConfirm(false);
|
||||||
|
navigate(0);
|
||||||
|
} catch {
|
||||||
|
alert('일괄 RESOLVED 처리에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetRetry = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
const ids = exceededRecords.map((r) => r.id);
|
||||||
|
await batchApi.resetRetryCount(ids);
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
navigate(0);
|
||||||
|
} catch {
|
||||||
|
alert('재시도 초기화에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-red-600 hover:text-red-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
호출 실패 데이터 ({records.length.toLocaleString()}건, FAILED {failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{failedRecords.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{exceededRecords.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
재시도 초기화 ({exceededRecords.length}건)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResolveConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
일괄 RESOLVED ({failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
실패 건 재수집 ({failedRecords.length}건)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-red-100 text-red-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">에러 메시지</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-center">재시도</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium text-center">상태</th>
|
||||||
|
<th className="px-2 py-1.5 font-medium">생성 시간</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-red-100">
|
||||||
|
{pagedRecords.map((record) => (
|
||||||
|
<tr
|
||||||
|
key={record.id}
|
||||||
|
className="bg-white hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-red-900">
|
||||||
|
{record.recordKey}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
|
||||||
|
{record.errorMessage || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
{(() => {
|
||||||
|
const info = retryStatusLabel(record);
|
||||||
|
return info ? (
|
||||||
|
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
|
||||||
|
{info.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-wing-muted">-</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
|
||||||
|
{record.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-red-500 whitespace-nowrap">
|
||||||
|
{formatDateTime(record.createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalElements={records.length}
|
||||||
|
pageSize={FAILED_PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 재수집 확인 다이얼로그 */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
|
실패 건 재수집 확인
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-wing-muted mb-3">
|
||||||
|
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{failedRecords.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.id}
|
||||||
|
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-100 text-red-700 rounded"
|
||||||
|
>
|
||||||
|
{r.recordKey}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
disabled={retrying}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{retrying ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
실행 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'재수집 실행'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
||||||
|
{showResolveConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
|
일괄 RESOLVED 확인
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-wing-muted mb-4">
|
||||||
|
FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다.
|
||||||
|
이 작업은 되돌릴 수 없습니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResolveConfirm(false)}
|
||||||
|
disabled={resolving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{resolving ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'RESOLVED 처리'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 재시도 초기화 확인 다이얼로그 */}
|
||||||
|
{showResetConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
|
재시도 초기화 확인
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-wing-muted mb-4">
|
||||||
|
재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다.
|
||||||
|
초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(false)}
|
||||||
|
disabled={resetting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResetRetry}
|
||||||
|
disabled={resetting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{resetting ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'초기화 실행'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
648
frontend/src/pages/Executions.tsx
Normal file
648
frontend/src/pages/Executions.tsx
Normal file
@ -0,0 +1,648 @@
|
|||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse, type ScheduleResponse } 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';
|
||||||
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const EXECUTIONS_GUIDE = [
|
||||||
|
{
|
||||||
|
title: '작업 필터',
|
||||||
|
content: '상단의 드롭다운에서 조회할 작업을 선택할 수 있습니다.\n여러 작업을 동시에 선택할 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '상태 필터',
|
||||||
|
content: '완료 / 실패 / 실행중 / 중지됨 버튼으로 상태별 필터링이 가능합니다.\n"전체"를 선택하면 모든 상태의 실행 이력을 볼 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '날짜 검색',
|
||||||
|
content: '시작일과 종료일을 지정하여 특정 기간의 실행 이력을 조회할 수 있습니다.\n"검색" 버튼을 클릭하면 조건에 맞는 결과가 표시됩니다.\n"초기화" 버튼으로 검색 조건을 제거하고 최신 이력으로 돌아갑니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '실행 중인 작업 제어',
|
||||||
|
content: '실행 중인 작업의 행에서 "중지" 또는 "강제 종료" 버튼을 사용할 수 있습니다.\n• 중지: 현재 Step 완료 후 안전하게 종료\n• 강제 종료: 즉시 중단 (데이터 정합성 주의)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '실패 로그 확인',
|
||||||
|
content: '상태가 "FAILED"인 행을 클릭하면 실패 상세 정보를 확인할 수 있습니다.\n종료 코드(Exit Code)와 에러 메시지로 실패 원인을 파악하세요.\n상태가 "COMPLETED"이지만 실패 건수가 있으면 경고 아이콘이 표시됩니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Executions() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const jobFromQuery = searchParams.get('job') || '';
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<string[]>([]);
|
||||||
|
const [scheduleList, setScheduleList] = useState<ScheduleResponse[]>([]);
|
||||||
|
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 [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
batchApi.getSchedules().then(res => setScheduleList(res.schedules)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const s of scheduleList) {
|
||||||
|
if (s.description) map[s.jobName] = s.description;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [scheduleList]);
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">실행 이력</h1>
|
||||||
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<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 justify-between w-[27rem] 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-[27rem] 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">{displayNameMap[job] || 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"
|
||||||
|
>
|
||||||
|
{displayNameMap[job] || 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">
|
||||||
|
{displayNameMap[exec.jobName] || exec.jobName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
|
||||||
|
{exec.status === 'FAILED' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setFailLogTarget(exec)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
title="클릭하여 실패 로그 확인"
|
||||||
|
>
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
)}
|
||||||
|
{exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 rounded-full"
|
||||||
|
title={`미해결 실패 레코드 ${exec.failedRecordCount}건`}
|
||||||
|
>
|
||||||
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{exec.failedRecordCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GuideModal
|
||||||
|
open={guideOpen}
|
||||||
|
onClose={() => setGuideOpen(false)}
|
||||||
|
pageTitle="실행 이력"
|
||||||
|
sections={EXECUTIONS_GUIDE}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
576
frontend/src/pages/Jobs.tsx
Normal file
576
frontend/src/pages/Jobs.tsx
Normal file
@ -0,0 +1,576 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { batchApi } from '../api/batchApi';
|
||||||
|
import type { JobDetailDto, ScheduleResponse } 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, calculateDuration } from '../utils/formatters';
|
||||||
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
const JOBS_GUIDE = [
|
||||||
|
{
|
||||||
|
title: '상태 필터',
|
||||||
|
content: '상단의 탭 버튼으로 작업 상태별 필터링이 가능합니다.\n전체 / 실행 중 / 성공 / 실패 / 미실행 중 선택하세요.\n각 탭 옆의 숫자는 해당 상태의 작업 수입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '검색 및 정렬',
|
||||||
|
content: '검색창에 작업명을 입력하면 실시간으로 필터링됩니다.\n정렬 옵션: 작업명순, 최신 실행순(기본), 상태별(실패 우선)\n테이블/카드 뷰 전환 버튼으로 보기 방식을 변경할 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '작업 실행',
|
||||||
|
content: '"실행" 버튼을 클릭하면 확인 팝업이 표시됩니다.\n확인 후 해당 배치 작업이 즉시 실행됩니다.\n실행 중인 작업은 좌측에 초록색 점이 표시됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '이력 보기',
|
||||||
|
content: '"이력 보기" 버튼을 클릭하면 해당 작업의 실행 이력 화면으로 이동합니다.\n과거 실행 결과, 소요 시간 등을 상세히 확인할 수 있습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type StatusFilterKey = 'ALL' | 'STARTED' | 'COMPLETED' | 'FAILED' | 'NONE';
|
||||||
|
type SortKey = 'name' | 'recent' | 'status';
|
||||||
|
type ViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
interface StatusTabConfig {
|
||||||
|
key: StatusFilterKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TABS: StatusTabConfig[] = [
|
||||||
|
{ key: 'ALL', label: '전체' },
|
||||||
|
{ key: 'STARTED', label: '실행 중' },
|
||||||
|
{ key: 'COMPLETED', label: '성공' },
|
||||||
|
{ key: 'FAILED', label: '실패' },
|
||||||
|
{ key: 'NONE', label: '미실행' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_ORDER: Record<string, number> = {
|
||||||
|
FAILED: 0,
|
||||||
|
STARTED: 1,
|
||||||
|
COMPLETED: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusOrder(job: JobDetailDto): number {
|
||||||
|
if (!job.lastExecution) return 3;
|
||||||
|
return STATUS_ORDER[job.lastExecution.status] ?? 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesStatusFilter(job: JobDetailDto, filter: StatusFilterKey): boolean {
|
||||||
|
if (filter === 'ALL') return true;
|
||||||
|
if (filter === 'NONE') return job.lastExecution === null;
|
||||||
|
return job.lastExecution?.status === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Jobs() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>('ALL');
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('recent');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
// Execute modal (individual card)
|
||||||
|
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||||
|
const [targetJob, setTargetJob] = useState('');
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
const [scheduleList, setScheduleList] = useState<ScheduleResponse[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
batchApi.getSchedules().then(res => setScheduleList(res.schedules)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const s of scheduleList) {
|
||||||
|
if (s.description) map[s.jobName] = s.description;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [scheduleList]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/** schedule description 우선, 없으면 jobName */
|
||||||
|
const getJobLabel = useCallback((job: JobDetailDto) => displayNameMap[job.jobName] || job.jobName, [displayNameMap]);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
const searchFiltered = searchTerm.trim()
|
||||||
|
? jobs.filter((job) => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return job.jobName.toLowerCase().includes(term)
|
||||||
|
|| (displayNameMap[job.jobName]?.toLowerCase().includes(term) ?? false);
|
||||||
|
})
|
||||||
|
: jobs;
|
||||||
|
|
||||||
|
return STATUS_TABS.reduce<Record<StatusFilterKey, number>>(
|
||||||
|
(acc, tab) => {
|
||||||
|
acc[tab.key] = searchFiltered.filter((job) => matchesStatusFilter(job, tab.key)).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ ALL: 0, STARTED: 0, COMPLETED: 0, FAILED: 0, NONE: 0 },
|
||||||
|
);
|
||||||
|
}, [jobs, searchTerm]);
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
let result = jobs;
|
||||||
|
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
result = result.filter((job) =>
|
||||||
|
job.jobName.toLowerCase().includes(term)
|
||||||
|
|| (displayNameMap[job.jobName]?.toLowerCase().includes(term) ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.filter((job) => matchesStatusFilter(job, statusFilter));
|
||||||
|
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
if (sortKey === 'name') {
|
||||||
|
return getJobLabel(a).localeCompare(getJobLabel(b));
|
||||||
|
}
|
||||||
|
if (sortKey === 'recent') {
|
||||||
|
const aTime = a.lastExecution?.startTime ? new Date(a.lastExecution.startTime).getTime() : 0;
|
||||||
|
const bTime = b.lastExecution?.startTime ? new Date(b.lastExecution.startTime).getTime() : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
}
|
||||||
|
if (sortKey === 'status') {
|
||||||
|
return getStatusOrder(a) - getStatusOrder(b);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [jobs, searchTerm, statusFilter, sortKey]);
|
||||||
|
|
||||||
|
const handleExecuteClick = (jobName: string) => {
|
||||||
|
setTargetJob(jobName);
|
||||||
|
setExecuteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmExecute = async () => {
|
||||||
|
if (!targetJob) return;
|
||||||
|
setExecuting(true);
|
||||||
|
try {
|
||||||
|
const result = await batchApi.executeJob(targetJob);
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">배치 작업 목록</h1>
|
||||||
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
총 {jobs.length}개 작업
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? 'bg-white/25 text-white'
|
||||||
|
: 'bg-wing-border text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusCounts[tab.key]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Sort + View Toggle */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<select
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||||
|
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
>
|
||||||
|
<option value="name">작업명순</option>
|
||||||
|
<option value="recent">최신 실행순</option>
|
||||||
|
<option value="status">상태별(실패 우선)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface 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="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('card')}
|
||||||
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface 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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filteredJobs.length}개 작업 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job List */}
|
||||||
|
{filteredJobs.length === 0 ? (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon="🔍"
|
||||||
|
message={searchTerm || statusFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
|
||||||
|
sub={searchTerm || statusFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* Card View */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredJobs.map((job) => {
|
||||||
|
const isRunning = job.lastExecution?.status === 'STARTED';
|
||||||
|
const duration = job.lastExecution
|
||||||
|
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
|
||||||
|
: null;
|
||||||
|
const showDuration =
|
||||||
|
job.lastExecution?.endTime != null && duration !== null && duration !== '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={job.jobName}
|
||||||
|
className={`bg-wing-surface rounded-xl shadow-md p-6
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all
|
||||||
|
${isRunning ? 'border-l-4 border-emerald-500' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||||
|
{getJobLabel(job)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||||
|
{isRunning && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
{job.lastExecution && (
|
||||||
|
<StatusBadge status={job.lastExecution.status} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{showDuration && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
소요 시간: {duration}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isRunning && !showDuration && (
|
||||||
|
<p className="text-xs text-emerald-500">
|
||||||
|
소요 시간: 실행 중...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-wing-muted text-xs">실행 이력 없음</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-0.5">
|
||||||
|
{job.scheduleCron ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
|
||||||
|
자동
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
|
||||||
|
수동
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{job.scheduleCron && (
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
|
||||||
|
{job.scheduleCron}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
/* Table View */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
작업명
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
마지막 실행
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
소요시간
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
스케줄
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filteredJobs.map((job) => {
|
||||||
|
const isRunning = job.lastExecution?.status === 'STARTED';
|
||||||
|
const duration = job.lastExecution
|
||||||
|
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={job.jobName}
|
||||||
|
className={`hover:bg-wing-hover transition-colors ${
|
||||||
|
isRunning ? 'border-l-4 border-emerald-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text break-all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isRunning && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{getJobLabel(job)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{job.lastExecution ? (
|
||||||
|
<StatusBadge status={job.lastExecution.status} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-wing-muted">미실행</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">
|
||||||
|
{job.lastExecution
|
||||||
|
? formatDateTime(job.lastExecution.startTime)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">
|
||||||
|
{job.lastExecution ? duration : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{job.scheduleCron ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
|
||||||
|
자동
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
|
||||||
|
수동
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecuteClick(job.jobName)}
|
||||||
|
className="px-3 py-1.5 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="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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GuideModal
|
||||||
|
open={guideOpen}
|
||||||
|
pageTitle="배치 작업 목록"
|
||||||
|
sections={JOBS_GUIDE}
|
||||||
|
onClose={() => setGuideOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
"{displayNameMap[targetJob] || targetJob}" 작업을 실행하시겠습니까?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
839
frontend/src/pages/Schedules.tsx
Normal file
839
frontend/src/pages/Schedules.tsx
Normal file
@ -0,0 +1,839 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } 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';
|
||||||
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
|
type ScheduleMode = 'new' | 'existing';
|
||||||
|
type ScheduleViewMode = 'card' | 'table';
|
||||||
|
type ActiveFilterKey = 'ALL' | 'ACTIVE' | 'INACTIVE';
|
||||||
|
type ScheduleSortKey = 'name' | 'nextFire' | 'active';
|
||||||
|
|
||||||
|
interface ActiveTabConfig {
|
||||||
|
key: ActiveFilterKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_TABS: ActiveTabConfig[] = [
|
||||||
|
{ key: 'ALL', label: '전체' },
|
||||||
|
{ key: 'ACTIVE', label: '활성' },
|
||||||
|
{ key: 'INACTIVE', label: '비활성' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEDULES_GUIDE = [
|
||||||
|
{
|
||||||
|
title: '스케줄이란?',
|
||||||
|
content: '스케줄은 배치 작업을 자동으로 실행하는 설정입니다.\nCron 표현식으로 실행 주기를 지정하면 해당 시간에 자동 실행됩니다.\n활성화된 스케줄만 자동 실행되며, 비활성화하면 일시 중지됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '스케줄 등록/수정',
|
||||||
|
content: '"+ 새 스케줄" 버튼 또는 기존 스케줄의 "편집" 버튼을 클릭하면 설정 팝업이 열립니다.\n1. 작업 선택: 자동 실행할 배치 작업을 선택합니다\n2. Cron 표현식: 실행 주기를 설정합니다 (프리셋 버튼으로 간편 설정 가능)\n3. 설명: 스케줄에 대한 메모를 입력합니다 (선택)\n\n"다음 5회 실행 예정" 미리보기로 설정이 올바른지 확인하세요.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cron 표현식',
|
||||||
|
content: 'Cron 표현식은 "초 분 시 일 월 요일" 6자리로 구성됩니다.\n예시:\n• 0 0/15 * * * ? → 매 15분마다\n• 0 0 0 * * ? → 매일 자정\n• 0 0 12 * * ? → 매일 정오\n• 0 0 0 ? * MON → 매주 월요일 자정\n\n프리셋 버튼을 활용하면 직접 입력하지 않아도 됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '스케줄 관리',
|
||||||
|
content: '• 편집: 스케줄 설정(Cron, 설명)을 수정합니다\n• 활성화/비활성화: 자동 실행을 켜거나 끕니다\n• 삭제: 스케줄을 완전히 제거합니다\n\n상태 표시:\n• 활성 (초록): 정상 동작 중\n• 비활성 (회색): 일시 중지 상태\n• NORMAL: 트리거 정상\n• PAUSED: 트리거 일시 중지\n• BLOCKED: 이전 실행이 아직 진행 중\n• ERROR: 트리거 오류 발생',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Schedules() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
// Guide modal state
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// View mode state
|
||||||
|
const [viewMode, setViewMode] = useState<ScheduleViewMode>('table');
|
||||||
|
|
||||||
|
// Search / filter / sort state
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<ActiveFilterKey>('ALL');
|
||||||
|
const [sortKey, setSortKey] = useState<ScheduleSortKey>('name');
|
||||||
|
|
||||||
|
// Confirm modal state
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
|
||||||
|
// Form modal state
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
|
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 displayNameMap = useMemo<Record<string, string>>(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (s.description) map[s.jobName] = s.description;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [schedules]);
|
||||||
|
|
||||||
|
const activeCounts = useMemo(() => {
|
||||||
|
const searchFiltered = searchTerm.trim()
|
||||||
|
? schedules.filter((s) => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return s.jobName.toLowerCase().includes(term)
|
||||||
|
|| (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false)
|
||||||
|
|| (s.description?.toLowerCase().includes(term) ?? false);
|
||||||
|
})
|
||||||
|
: schedules;
|
||||||
|
|
||||||
|
return ACTIVE_TABS.reduce<Record<ActiveFilterKey, number>>(
|
||||||
|
(acc, tab) => {
|
||||||
|
acc[tab.key] = searchFiltered.filter((s) => {
|
||||||
|
if (tab.key === 'ALL') return true;
|
||||||
|
if (tab.key === 'ACTIVE') return s.active;
|
||||||
|
return !s.active;
|
||||||
|
}).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ ALL: 0, ACTIVE: 0, INACTIVE: 0 },
|
||||||
|
);
|
||||||
|
}, [schedules, searchTerm, displayNameMap]);
|
||||||
|
|
||||||
|
const filteredSchedules = useMemo(() => {
|
||||||
|
let result = schedules;
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
result = result.filter((s) =>
|
||||||
|
s.jobName.toLowerCase().includes(term)
|
||||||
|
|| (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false)
|
||||||
|
|| (s.description?.toLowerCase().includes(term) ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성/비활성 필터
|
||||||
|
if (activeFilter === 'ACTIVE') {
|
||||||
|
result = result.filter((s) => s.active);
|
||||||
|
} else if (activeFilter === 'INACTIVE') {
|
||||||
|
result = result.filter((s) => !s.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
if (sortKey === 'name') {
|
||||||
|
const aName = displayNameMap[a.jobName] || a.jobName;
|
||||||
|
const bName = displayNameMap[b.jobName] || b.jobName;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
}
|
||||||
|
if (sortKey === 'nextFire') {
|
||||||
|
const aTime = a.nextFireTime ? new Date(a.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
|
const bTime = b.nextFireTime ? new Date(b.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
|
return aTime - bTime;
|
||||||
|
}
|
||||||
|
if (sortKey === 'active') {
|
||||||
|
if (a.active === b.active) {
|
||||||
|
const aName = displayNameMap[a.jobName] || a.jobName;
|
||||||
|
const bName = displayNameMap[b.jobName] || b.jobName;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
}
|
||||||
|
return a.active ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [schedules, searchTerm, activeFilter, sortKey, displayNameMap]);
|
||||||
|
|
||||||
|
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();
|
||||||
|
setFormOpen(false);
|
||||||
|
resetForm();
|
||||||
|
} 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();
|
||||||
|
// Close form if deleted schedule was being edited
|
||||||
|
if (selectedJob === schedule.jobName) {
|
||||||
|
resetForm();
|
||||||
|
setFormOpen(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '삭제 실패';
|
||||||
|
showToast(message, 'error');
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSelectedJob('');
|
||||||
|
setCronExpression('');
|
||||||
|
setDescription('');
|
||||||
|
setScheduleMode('new');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditFromCard = (schedule: ScheduleResponse) => {
|
||||||
|
setSelectedJob(schedule.jobName);
|
||||||
|
setCronExpression(schedule.cronExpression);
|
||||||
|
setDescription(schedule.description ?? '');
|
||||||
|
setScheduleMode('existing');
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewSchedule = () => {
|
||||||
|
resetForm();
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScheduleLabel = (schedule: ScheduleResponse) =>
|
||||||
|
displayNameMap[schedule.jobName] || schedule.jobName;
|
||||||
|
|
||||||
|
if (listLoading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Form Modal */}
|
||||||
|
{formOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={() => setFormOpen(false)} />
|
||||||
|
<div className="relative bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-wing-text">
|
||||||
|
{scheduleMode === 'existing' ? '스케줄 수정' : '스케줄 등록'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setFormOpen(false)}
|
||||||
|
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-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 || scheduleMode === 'existing'}
|
||||||
|
>
|
||||||
|
<option value="">-- 작업을 선택하세요 --</option>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<option key={job} value={job}>
|
||||||
|
{displayNameMap[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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFormOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">스케줄 관리</h1>
|
||||||
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleNewSchedule}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
+ 새 스케줄
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
총 {schedules.length}개 스케줄
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filter Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ACTIVE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveFilter(tab.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
activeFilter === tab.key
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
|
||||||
|
activeFilter === tab.key
|
||||||
|
? 'bg-white/25 text-white'
|
||||||
|
: 'bg-wing-border text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activeCounts[tab.key]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Sort + View Toggle */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<select
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value as ScheduleSortKey)}
|
||||||
|
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
>
|
||||||
|
<option value="name">작업명순</option>
|
||||||
|
<option value="nextFire">다음 실행순</option>
|
||||||
|
<option value="active">활성 우선</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface 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="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('card')}
|
||||||
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface 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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filteredSchedules.length}개 스케줄 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule List */}
|
||||||
|
{filteredSchedules.length === 0 ? (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon="🔍"
|
||||||
|
message={searchTerm || activeFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 스케줄이 없습니다.'}
|
||||||
|
sub={searchTerm || activeFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : "'+ 새 스케줄' 버튼을 클릭하여 등록하세요"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* Card View */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSchedules.map((schedule) => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className={`bg-wing-surface rounded-xl shadow-md p-6
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all
|
||||||
|
${!schedule.active ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||||
|
{getScheduleLabel(schedule)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Detail Info */}
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
|
||||||
|
{schedule.cronExpression}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
다음 실행: {formatDateTime(schedule.nextFireTime)}
|
||||||
|
</p>
|
||||||
|
{schedule.previousFireTime && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
이전 실행: {formatDateTime(schedule.previousFireTime)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditFromCard(schedule)}
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
|
||||||
|
className={`flex-1 px-3 py-2 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-2 text-xs font-medium text-red-700 bg-red-50 rounded-lg
|
||||||
|
hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Table View */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">작업명</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Cron 표현식</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">다음 실행</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">이전 실행</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filteredSchedules.map((schedule) => (
|
||||||
|
<tr
|
||||||
|
key={schedule.id}
|
||||||
|
className={`hover:bg-wing-hover transition-colors ${!schedule.active ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text break-all">
|
||||||
|
{getScheduleLabel(schedule)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">{schedule.cronExpression}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">{formatDateTime(schedule.nextFireTime)}</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditFromCard(schedule)}
|
||||||
|
className="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>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
schedule.active
|
||||||
|
? 'text-amber-600 bg-amber-50 hover:bg-amber-100'
|
||||||
|
: 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', schedule })}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg
|
||||||
|
hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GuideModal
|
||||||
|
open={guideOpen}
|
||||||
|
onClose={() => setGuideOpen(false)}
|
||||||
|
pageTitle="스케줄 관리"
|
||||||
|
sections={SCHEDULES_GUIDE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
513
frontend/src/pages/Timeline.tsx
Normal file
513
frontend/src/pages/Timeline.tsx
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleResponse, 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';
|
||||||
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMELINE_GUIDE = [
|
||||||
|
{
|
||||||
|
title: '타임라인이란?',
|
||||||
|
content: '타임라인은 배치 작업의 실행 스케줄과 결과를 시각적으로 보여주는 화면입니다.\n세로축은 작업 목록, 가로축은 시간대를 나타냅니다.\n각 셀의 색상으로 실행 상태를 한눈에 파악할 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '보기 모드',
|
||||||
|
content: '3가지 보기 모드를 제공합니다.\n• Day: 하루 단위 (시간대별 상세 보기)\n• Week: 일주일 단위\n• Month: 한 달 단위\n\n이전/다음 버튼으로 기간을 이동하고, "오늘" 버튼으로 현재 날짜로 돌아옵니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '색상 범례',
|
||||||
|
content: '각 셀의 색상은 실행 상태를 나타냅니다.\n• 초록색: 완료 (성공적으로 실행됨)\n• 빨간색: 실패 (오류 발생)\n• 파란색: 실행 중 (현재 진행 중)\n• 보라색: 예정 (아직 실행 전)\n• 회색: 없음 (해당 시간대에 실행 기록 없음)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '상세 보기',
|
||||||
|
content: '셀 위에 마우스를 올리면 툴팁으로 작업명, 기간, 상태 등 요약 정보를 보여줍니다.\n셀을 클릭하면 하단에 상세 패널이 열리며, 해당 시간대의 실행 이력 목록을 확인할 수 있습니다.\n"상세" 링크를 클릭하면 실행 상세 화면으로 이동합니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Timeline() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
// Guide modal state
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const [scheduleList, setScheduleList] = useState<ScheduleResponse[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
batchApi.getSchedules().then(res => setScheduleList(res.schedules)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const s of scheduleList) {
|
||||||
|
if (s.description) map[s.jobName] = s.description;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [scheduleList]);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
{/* Help */}
|
||||||
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
|
</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={displayNameMap[schedule.jobName] || schedule.jobName}
|
||||||
|
>
|
||||||
|
{displayNameMap[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">{displayNameMap[tooltip.jobName] || 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">
|
||||||
|
{displayNameMap[selectedCell.jobName] || 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">
|
||||||
|
<Link
|
||||||
|
to={`/executions/${exec.executionId}`}
|
||||||
|
className="text-xs text-wing-accent hover:text-wing-accent font-medium no-underline"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GuideModal
|
||||||
|
open={guideOpen}
|
||||||
|
onClose={() => setGuideOpen(false)}
|
||||||
|
pageTitle="타임라인"
|
||||||
|
sections={TIMELINE_GUIDE}
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
154
frontend/src/utils/cronPreview.ts
Normal file
154
frontend/src/utils/cronPreview.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
if (step <= 0) return range(min, max);
|
||||||
|
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-sync/api': {
|
||||||
|
target: 'http://localhost:8051',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: '/snp-sync/',
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
186
pom.xml
Normal file
186
pom.xml
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.snp</groupId>
|
||||||
|
<artifactId>snp-sync-batch</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>SNP Sync Batch</name>
|
||||||
|
<description>Spring Batch project for JSON to PostgreSQL with Web GUI</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
|
||||||
|
<!-- Dependency versions -->
|
||||||
|
<spring-boot.version>3.2.1</spring-boot.version>
|
||||||
|
<spring-batch.version>5.1.0</spring-batch.version>
|
||||||
|
<postgresql.version>42.7.6</postgresql.version>
|
||||||
|
<lombok.version>1.18.30</lombok.version>
|
||||||
|
<quartz.version>2.5.0</quartz.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Starter Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Batch -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Data JPA -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostgreSQL Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>${postgresql.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Quartz (for Job Scheduling) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-quartz</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson for JSON processing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok for reducing boilerplate code -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot DevTools -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Actuator for monitoring -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebClient for REST API calls -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Springdoc OpenAPI (Swagger) for API Documentation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test Dependencies -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.batch</groupId>
|
||||||
|
<artifactId>spring-batch-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</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>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
<encoding>UTF-8</encoding>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
14
src/main/java/com/snp/batch/SnpBatchApplication.java
Normal file
14
src/main/java/com/snp/batch/SnpBatchApplication.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
public class SnpBatchApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(SnpBatchApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package com.snp.batch.common.batch.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.batch.item.ItemProcessor;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.batch.item.ItemWriter;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch Job 설정을 위한 추상 클래스
|
||||||
|
* Reader → Processor → Writer 패턴의 표준 Job 구성 제공
|
||||||
|
*
|
||||||
|
* @param <I> 입력 타입 (Reader 출력, Processor 입력)
|
||||||
|
* @param <O> 출력 타입 (Processor 출력, Writer 입력)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public abstract class BaseJobConfig<I, O> {
|
||||||
|
|
||||||
|
protected final JobRepository jobRepository;
|
||||||
|
protected final PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름 반환 (하위 클래스에서 구현)
|
||||||
|
* 예: "shipDataImportJob"
|
||||||
|
*/
|
||||||
|
protected abstract String getJobName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 이름 반환 (선택사항, 기본: {jobName}Step)
|
||||||
|
*/
|
||||||
|
protected String getStepName() {
|
||||||
|
return getJobName() + "Step";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reader 생성 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract ItemReader<I> createReader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor 생성 (하위 클래스에서 구현)
|
||||||
|
* 처리 로직이 없는 경우 null 반환 가능
|
||||||
|
*/
|
||||||
|
protected abstract ItemProcessor<I, O> createProcessor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writer 생성 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract ItemWriter<O> createWriter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk 크기 반환 (선택사항, 기본: 100)
|
||||||
|
*/
|
||||||
|
protected int getChunkSize() {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 시작 전 실행 (선택사항)
|
||||||
|
* Job Listener 등록 시 사용
|
||||||
|
*/
|
||||||
|
protected void configureJob(JobBuilder jobBuilder) {
|
||||||
|
// 기본 구현: 아무것도 하지 않음
|
||||||
|
// 하위 클래스에서 필요시 오버라이드
|
||||||
|
// 예: jobBuilder.listener(jobExecutionListener())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 커스터마이징 (선택사항)
|
||||||
|
* Step Listener, FaultTolerant 등 설정 시 사용
|
||||||
|
*/
|
||||||
|
protected void configureStep(StepBuilder stepBuilder) {
|
||||||
|
// 기본 구현: 아무것도 하지 않음
|
||||||
|
// 하위 클래스에서 필요시 오버라이드
|
||||||
|
// 예: stepBuilder.listener(stepExecutionListener())
|
||||||
|
// stepBuilder.faultTolerant().skip(Exception.class).skipLimit(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ItemReader/Processor/Writer를 사용하는 표준 Step 생성
|
||||||
|
*/
|
||||||
|
public Step step() {
|
||||||
|
log.info("표준 Step 생성: {}", getStepName());
|
||||||
|
|
||||||
|
ItemProcessor<I, O> processor = createProcessor();
|
||||||
|
StepBuilder stepBuilder = new StepBuilder(getStepName(), jobRepository);
|
||||||
|
|
||||||
|
if (processor != null) {
|
||||||
|
var chunkBuilder = stepBuilder
|
||||||
|
.<I, O>chunk(getChunkSize(), transactionManager)
|
||||||
|
.reader(createReader())
|
||||||
|
.processor(processor)
|
||||||
|
.writer(createWriter());
|
||||||
|
|
||||||
|
configureStep(stepBuilder);
|
||||||
|
return chunkBuilder.build();
|
||||||
|
} else {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var chunkBuilder = stepBuilder
|
||||||
|
.<I, I>chunk(getChunkSize(), transactionManager)
|
||||||
|
.reader(createReader())
|
||||||
|
.writer((ItemWriter<? super I>) createWriter());
|
||||||
|
|
||||||
|
configureStep(stepBuilder);
|
||||||
|
return chunkBuilder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 흐름 정의 (하위 클래스에서 Job의 start() 및 next()를 정의)
|
||||||
|
* **멀티 Step 구현을 위해 이 메서드를 추상 메서드로 변경합니다.**
|
||||||
|
*/
|
||||||
|
protected abstract Job createJobFlow(JobBuilder jobBuilder);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 생성 (표준 구현 제공)
|
||||||
|
* **변경된 createJobFlow를 호출하도록 수정합니다.**
|
||||||
|
*/
|
||||||
|
public final Job job() {
|
||||||
|
log.info("Job 생성 시작: {}", getJobName());
|
||||||
|
|
||||||
|
JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository);
|
||||||
|
|
||||||
|
// 커스텀 설정 적용
|
||||||
|
configureJob(jobBuilder);
|
||||||
|
|
||||||
|
// Job 흐름 정의
|
||||||
|
Job job = createJobFlow(jobBuilder);
|
||||||
|
|
||||||
|
log.info("Job 생성 완료: {}", getJobName());
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.snp.batch.common.batch.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 Entity의 공통 베이스 클래스 - JDBC 전용
|
||||||
|
* 생성/수정 감사(Audit) 필드 제공
|
||||||
|
*
|
||||||
|
* 이 필드들은 Repository의 Insert/Update 시 자동으로 설정됩니다.
|
||||||
|
* BaseJdbcRepository가 감사 필드를 자동으로 관리합니다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@SuperBuilder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public abstract class BaseEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 일시
|
||||||
|
* 컬럼: created_at (TIMESTAMP)
|
||||||
|
*/
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 일시
|
||||||
|
* 컬럼: updated_at (TIMESTAMP)
|
||||||
|
*/
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자
|
||||||
|
* 컬럼: created_by (VARCHAR(100))
|
||||||
|
*/
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정자
|
||||||
|
* 컬럼: updated_by (VARCHAR(100))
|
||||||
|
*/
|
||||||
|
private String updatedBy;
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package com.snp.batch.common.batch.processor;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.item.ItemProcessor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ItemProcessor 추상 클래스 (v2.0)
|
||||||
|
* 데이터 변환 및 처리 로직을 위한 템플릿 제공
|
||||||
|
*
|
||||||
|
* Template Method Pattern:
|
||||||
|
* - process(): 공통 로직 (null 체크, 로깅)
|
||||||
|
* - processItem(): 하위 클래스에서 변환 로직 구현
|
||||||
|
*
|
||||||
|
* 기본 용도:
|
||||||
|
* - 단순 변환: DTO → Entity
|
||||||
|
* - 데이터 필터링: null 반환 시 해당 아이템 스킵
|
||||||
|
* - 데이터 검증: 유효하지 않은 데이터 필터링
|
||||||
|
*
|
||||||
|
* 고급 용도 (다중 depth JSON 처리):
|
||||||
|
* - 중첩된 JSON을 여러 Entity로 분해
|
||||||
|
* - 1:N 관계 처리 (Order → OrderItems)
|
||||||
|
* - CompositeWriter와 조합하여 여러 테이블에 저장
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* - 단순 변환: ProductDataProcessor (DTO → Entity)
|
||||||
|
* - 복잡한 처리: 복잡한 JSON 처리 예제 참고
|
||||||
|
*
|
||||||
|
* @param <I> 입력 DTO 타입
|
||||||
|
* @param <O> 출력 Entity 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseProcessor<I, O> implements ItemProcessor<I, O> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 변환 로직 (하위 클래스에서 구현)
|
||||||
|
* DTO → Entity 변환 등의 비즈니스 로직 구현
|
||||||
|
*
|
||||||
|
* @param item 입력 DTO
|
||||||
|
* @return 변환된 Entity (필터링 시 null 반환 가능)
|
||||||
|
* @throws Exception 처리 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
protected abstract O processItem(I item) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Batch ItemProcessor 인터페이스 구현
|
||||||
|
* 데이터 변환 및 필터링 수행
|
||||||
|
*
|
||||||
|
* @param item 입력 DTO
|
||||||
|
* @return 변환된 Entity (null이면 해당 아이템 스킵)
|
||||||
|
* @throws Exception 처리 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public O process(I item) throws Exception {
|
||||||
|
if (item == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("데이터 처리 중: {}", item);
|
||||||
|
return processItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,633 @@
|
|||||||
|
package com.snp.batch.common.batch.reader;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.StepExecution;
|
||||||
|
import org.springframework.batch.core.annotation.BeforeStep;
|
||||||
|
import org.springframework.batch.item.ExecutionContext;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.util.UriBuilder;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API 기반 ItemReader 추상 클래스 (v3.0 - Chunk 기반)
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - HTTP Method 지원: GET, POST
|
||||||
|
* - 다중 Query Parameter 처리
|
||||||
|
* - Path Variable 지원
|
||||||
|
* - Request Body 지원 (POST)
|
||||||
|
* - 동적 Header 설정
|
||||||
|
* - 복잡한 JSON 응답 파싱
|
||||||
|
* - ✨ Chunk 기반 배치 처리 (Iterator 패턴)
|
||||||
|
*
|
||||||
|
* Template Method Pattern:
|
||||||
|
* - read(): 공통 로직 (1건씩 순차 반환)
|
||||||
|
* - fetchNextBatch(): 다음 배치 조회 (구현체에서 오버라이드)
|
||||||
|
* - 새로운 훅 메서드들: HTTP Method, 파라미터, 헤더 등
|
||||||
|
*
|
||||||
|
* 동작 방식:
|
||||||
|
* 1. read() 호출 시 currentBatch가 비어있으면 fetchNextBatch() 호출
|
||||||
|
* 2. fetchNextBatch()가 100건 반환
|
||||||
|
* 3. read()가 100번 호출되면서 1건씩 반환
|
||||||
|
* 4. 100건 모두 반환되면 다시 fetchNextBatch() 호출
|
||||||
|
* 5. fetchNextBatch()가 null/empty 반환 시 Job 종료
|
||||||
|
*
|
||||||
|
* 하위 호환성:
|
||||||
|
* - 기존 fetchDataFromApi() 메서드 계속 지원
|
||||||
|
* - 새로운 fetchNextBatch() 메서드 사용 권장
|
||||||
|
*
|
||||||
|
* @param <T> DTO 타입 (API 응답 데이터)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||||
|
|
||||||
|
// Chunk 기반 Iterator 패턴
|
||||||
|
private java.util.Iterator<T> currentBatch;
|
||||||
|
private boolean initialized = false;
|
||||||
|
private boolean useChunkMode = false; // Chunk 모드 사용 여부
|
||||||
|
|
||||||
|
// 하위 호환성을 위한 필드 (fetchDataFromApi 사용 시)
|
||||||
|
private List<T> legacyDataList;
|
||||||
|
private int legacyNextIndex = 0;
|
||||||
|
|
||||||
|
// WebClient는 하위 클래스에서 주입받아 사용
|
||||||
|
protected WebClient webClient;
|
||||||
|
|
||||||
|
// StepExecution - API 정보 저장용
|
||||||
|
protected StepExecution stepExecution;
|
||||||
|
|
||||||
|
// API 호출 통계
|
||||||
|
private int totalApiCalls = 0;
|
||||||
|
private int completedApiCalls = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 생성자 (WebClient 없이 사용 - Mock 데이터용)
|
||||||
|
*/
|
||||||
|
protected BaseApiReader() {
|
||||||
|
this.webClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient를 주입받는 생성자 (실제 API 연동용)
|
||||||
|
*
|
||||||
|
* @param webClient Spring WebClient 인스턴스
|
||||||
|
*/
|
||||||
|
protected BaseApiReader(WebClient webClient) {
|
||||||
|
this.webClient = webClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 실행 전 초기화 및 API 정보 저장
|
||||||
|
* Spring Batch가 자동으로 StepExecution을 주입하고 이 메서드를 호출함
|
||||||
|
*
|
||||||
|
* @param stepExecution Step 실행 정보
|
||||||
|
*/
|
||||||
|
@BeforeStep
|
||||||
|
public void saveApiInfoToContext(StepExecution stepExecution) {
|
||||||
|
this.stepExecution = stepExecution;
|
||||||
|
|
||||||
|
// API 정보를 StepExecutionContext에 저장
|
||||||
|
ExecutionContext context = stepExecution.getExecutionContext();
|
||||||
|
|
||||||
|
// WebClient가 있는 경우에만 API 정보 저장
|
||||||
|
if (webClient != null) {
|
||||||
|
// 1. API URL 저장
|
||||||
|
String baseUrl = getApiBaseUrl();
|
||||||
|
String apiPath = getApiPath();
|
||||||
|
String fullUrl = baseUrl != null ? baseUrl + apiPath : apiPath;
|
||||||
|
context.putString("apiUrl", fullUrl);
|
||||||
|
|
||||||
|
// 2. HTTP Method 저장
|
||||||
|
context.putString("apiMethod", getHttpMethod());
|
||||||
|
|
||||||
|
// 3. API Parameters 저장
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
Map<String, Object> queryParams = getQueryParams();
|
||||||
|
if (queryParams != null && !queryParams.isEmpty()) {
|
||||||
|
params.putAll(queryParams);
|
||||||
|
}
|
||||||
|
Map<String, Object> pathVars = getPathVariables();
|
||||||
|
if (pathVars != null && !pathVars.isEmpty()) {
|
||||||
|
params.putAll(pathVars);
|
||||||
|
}
|
||||||
|
context.put("apiParameters", params);
|
||||||
|
|
||||||
|
// 4. 통계 초기화
|
||||||
|
context.putInt("totalApiCalls", 0);
|
||||||
|
context.putInt("completedApiCalls", 0);
|
||||||
|
|
||||||
|
log.info("[{}] API 정보 저장: {} {}", getReaderName(), getHttpMethod(), fullUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Base URL 반환 (WebClient의 baseUrl)
|
||||||
|
* 하위 클래스에서 필요 시 오버라이드
|
||||||
|
*/
|
||||||
|
protected String getApiBaseUrl() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 통계 업데이트
|
||||||
|
*/
|
||||||
|
protected void updateApiCallStats(int totalCalls, int completedCalls) {
|
||||||
|
if (stepExecution != null) {
|
||||||
|
ExecutionContext context = stepExecution.getExecutionContext();
|
||||||
|
context.putInt("totalApiCalls", totalCalls);
|
||||||
|
context.putInt("completedApiCalls", completedCalls);
|
||||||
|
|
||||||
|
// 마지막 호출 시간 저장
|
||||||
|
String lastCallTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
context.putString("lastCallTime", lastCallTime);
|
||||||
|
|
||||||
|
this.totalApiCalls = totalCalls;
|
||||||
|
this.completedApiCalls = completedCalls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ItemReader 구현 (공통 로직)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Batch ItemReader 인터페이스 구현
|
||||||
|
* 데이터를 순차적으로 하나씩 반환
|
||||||
|
*
|
||||||
|
* Chunk 기반 동작:
|
||||||
|
* 1. currentBatch가 비어있으면 fetchNextBatch() 호출하여 다음 배치 로드
|
||||||
|
* 2. Iterator에서 1건씩 반환
|
||||||
|
* 3. Iterator가 비면 다시 1번으로
|
||||||
|
* 4. fetchNextBatch()가 null/empty 반환하면 Job 종료
|
||||||
|
*
|
||||||
|
* @return 다음 데이터 항목 (더 이상 없으면 null)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public T read() throws Exception {
|
||||||
|
// Chunk 모드 사용 여부는 첫 호출 시 결정
|
||||||
|
if (!initialized && !useChunkMode) {
|
||||||
|
// Legacy 모드로 시작
|
||||||
|
return readLegacyMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk 모드가 활성화된 경우
|
||||||
|
if (useChunkMode) {
|
||||||
|
return readChunkMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy 모드
|
||||||
|
return readLegacyMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk 모드 활성화 (하위 클래스에서 명시적 호출)
|
||||||
|
*/
|
||||||
|
protected void enableChunkMode() {
|
||||||
|
this.useChunkMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk 기반 read() 구현 (신규 방식)
|
||||||
|
*/
|
||||||
|
private T readChunkMode() throws Exception {
|
||||||
|
// 최초 호출 시 초기화
|
||||||
|
if (!initialized) {
|
||||||
|
beforeFetch();
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentBatch가 비어있으면 다음 배치 로드
|
||||||
|
if (currentBatch == null || !currentBatch.hasNext()) {
|
||||||
|
List<T> nextBatch = fetchNextBatch();
|
||||||
|
|
||||||
|
// 더 이상 데이터가 없으면 종료
|
||||||
|
if (nextBatch == null || nextBatch.isEmpty()) {
|
||||||
|
afterFetch(null);
|
||||||
|
log.info("[{}] 모든 배치 처리 완료", getReaderName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterator 갱신
|
||||||
|
currentBatch = nextBatch.iterator();
|
||||||
|
log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterator에서 1건씩 반환
|
||||||
|
return currentBatch.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy 모드 read() 구현 (하위 호환성)
|
||||||
|
* 기존 fetchDataFromApi()를 오버라이드한 구현체 지원
|
||||||
|
*/
|
||||||
|
private T readLegacyMode() throws Exception {
|
||||||
|
// 최초 호출 시 API에서 전체 데이터 조회
|
||||||
|
if (legacyDataList == null) {
|
||||||
|
beforeFetch();
|
||||||
|
legacyDataList = fetchDataFromApi();
|
||||||
|
afterFetch(legacyDataList);
|
||||||
|
log.info("[{}] 데이터 {}건 조회 완료 (Legacy 모드)",
|
||||||
|
getReaderName(), legacyDataList != null ? legacyDataList.size() : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터를 순차적으로 반환
|
||||||
|
if (legacyDataList != null && legacyNextIndex < legacyDataList.size()) {
|
||||||
|
return legacyDataList.get(legacyNextIndex++);
|
||||||
|
} else {
|
||||||
|
return null; // 데이터 끝
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 핵심 추상 메서드 (하위 클래스에서 구현)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✨ 다음 배치 데이터를 조회하여 리스트로 반환 (신규 방식 - Chunk 기반)
|
||||||
|
*
|
||||||
|
* Chunk 기반 배치 처리를 위한 메서드:
|
||||||
|
* - read()가 호출될 때마다 필요 시 이 메서드가 호출됨
|
||||||
|
* - 일반적으로 100~1000건씩 반환
|
||||||
|
* - 더 이상 데이터가 없으면 null 또는 빈 리스트 반환
|
||||||
|
*
|
||||||
|
* 구현 예시:
|
||||||
|
* <pre>
|
||||||
|
* private int currentPage = 0;
|
||||||
|
* private final int pageSize = 100;
|
||||||
|
*
|
||||||
|
* @Override
|
||||||
|
* protected List<ProductDto> fetchNextBatch() {
|
||||||
|
* if (currentPage >= totalPages) {
|
||||||
|
* return null; // 종료
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // API 호출 (100건씩)
|
||||||
|
* ProductApiResponse response = callApiForPage(currentPage, pageSize);
|
||||||
|
* currentPage++;
|
||||||
|
*
|
||||||
|
* return response.getProducts();
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @return 다음 배치 데이터 리스트 (null 또는 빈 리스트면 종료)
|
||||||
|
* @throws Exception API 호출 실패 등
|
||||||
|
*/
|
||||||
|
protected List<T> fetchNextBatch() throws Exception {
|
||||||
|
// 기본 구현: Legacy 모드 fallback
|
||||||
|
// 하위 클래스에서 오버라이드 안 하면 fetchDataFromApi() 사용
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API에서 데이터를 조회하여 리스트로 반환 (Legacy 방식 - 하위 호환성)
|
||||||
|
*
|
||||||
|
* ⚠️ Deprecated: fetchNextBatch()를 사용하세요.
|
||||||
|
*
|
||||||
|
* 구현 방법:
|
||||||
|
* 1. WebClient 없이 Mock 데이터 생성 (sample용)
|
||||||
|
* 2. WebClient로 실제 API 호출 (실전용)
|
||||||
|
* 3. callApi() 헬퍼 메서드 사용 (권장)
|
||||||
|
*
|
||||||
|
* @return API에서 조회한 데이터 리스트 (전체)
|
||||||
|
*/
|
||||||
|
protected List<T> fetchDataFromApi() {
|
||||||
|
// 기본 구현: 빈 리스트 반환
|
||||||
|
// 하위 클래스에서 오버라이드 필요
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reader 이름 반환 (로깅용)
|
||||||
|
*
|
||||||
|
* @return Reader 이름 (예: "ProductDataReader")
|
||||||
|
*/
|
||||||
|
protected abstract String getReaderName();
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// HTTP 요청 설정 메서드 (선택적 오버라이드)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Method 반환
|
||||||
|
*
|
||||||
|
* 기본값: GET
|
||||||
|
* POST 요청 시 오버라이드
|
||||||
|
*
|
||||||
|
* @return HTTP Method ("GET" 또는 "POST")
|
||||||
|
*/
|
||||||
|
protected String getHttpMethod() {
|
||||||
|
return "GET";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 엔드포인트 경로 반환
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* - "/api/v1/products"
|
||||||
|
* - "/api/v1/orders/{orderId}" (Path Variable 포함)
|
||||||
|
*
|
||||||
|
* @return API 경로
|
||||||
|
*/
|
||||||
|
protected String getApiPath() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query Parameter 맵 반환
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* Map<String, Object> params = new HashMap<>();
|
||||||
|
* params.put("status", "active");
|
||||||
|
* params.put("page", 1);
|
||||||
|
* params.put("size", 100);
|
||||||
|
* return params;
|
||||||
|
*
|
||||||
|
* @return Query Parameter 맵 (null이면 파라미터 없음)
|
||||||
|
*/
|
||||||
|
protected Map<String, Object> getQueryParams() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path Variable 맵 반환
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* Map<String, Object> pathVars = new HashMap<>();
|
||||||
|
* pathVars.put("orderId", "ORD-001");
|
||||||
|
* return pathVars;
|
||||||
|
*
|
||||||
|
* @return Path Variable 맵 (null이면 Path Variable 없음)
|
||||||
|
*/
|
||||||
|
protected Map<String, Object> getPathVariables() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request Body 반환 (POST 요청용)
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* return RequestDto.builder()
|
||||||
|
* .startDate("2025-01-01")
|
||||||
|
* .endDate("2025-12-31")
|
||||||
|
* .build();
|
||||||
|
*
|
||||||
|
* @return Request Body 객체 (null이면 Body 없음)
|
||||||
|
*/
|
||||||
|
protected Object getRequestBody() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Header 맵 반환
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* Map<String, String> headers = new HashMap<>();
|
||||||
|
* headers.put("Authorization", "Bearer token123");
|
||||||
|
* headers.put("X-Custom-Header", "value");
|
||||||
|
* return headers;
|
||||||
|
*
|
||||||
|
* 기본 헤더 (자동 추가):
|
||||||
|
* - Content-Type: application/json
|
||||||
|
* - Accept: application/json
|
||||||
|
*
|
||||||
|
* @return HTTP Header 맵 (null이면 기본 헤더만 사용)
|
||||||
|
*/
|
||||||
|
protected Map<String, String> getHeaders() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답 타입 반환
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* return ProductApiResponse.class;
|
||||||
|
*
|
||||||
|
* @return 응답 클래스 타입
|
||||||
|
*/
|
||||||
|
protected Class<?> getResponseType() {
|
||||||
|
return Object.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답에서 데이터 리스트 추출
|
||||||
|
*
|
||||||
|
* 복잡한 JSON 응답 구조 처리:
|
||||||
|
* - 단순: response.getData()
|
||||||
|
* - 중첩: response.getResult().getItems()
|
||||||
|
*
|
||||||
|
* @param response API 응답 객체
|
||||||
|
* @return 추출된 데이터 리스트
|
||||||
|
*/
|
||||||
|
protected List<T> extractDataFromResponse(Object response) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 라이프사이클 훅 메서드 (선택적 오버라이드)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 전 전처리
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* - 파라미터 검증
|
||||||
|
* - 로깅
|
||||||
|
* - 캐시 확인
|
||||||
|
*/
|
||||||
|
protected void beforeFetch() {
|
||||||
|
log.debug("[{}] API 호출 준비 중...", getReaderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 후 후처리
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* - 데이터 검증
|
||||||
|
* - 로깅
|
||||||
|
* - 캐시 저장
|
||||||
|
*
|
||||||
|
* @param data 조회된 데이터 리스트
|
||||||
|
*/
|
||||||
|
protected void afterFetch(List<T> data) {
|
||||||
|
log.debug("[{}] API 호출 완료", getReaderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 실패 시 에러 처리
|
||||||
|
*
|
||||||
|
* 기본 동작: 빈 리스트 반환 (Job 실패 방지)
|
||||||
|
* 오버라이드 시: 예외 던지기 또는 재시도 로직 구현
|
||||||
|
*
|
||||||
|
* @param e 발생한 예외
|
||||||
|
* @return 대체 데이터 리스트 (빈 리스트 또는 캐시 데이터)
|
||||||
|
*/
|
||||||
|
protected List<T> handleApiError(Exception e) {
|
||||||
|
log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 메서드 (하위 클래스에서 사용 가능)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient를 사용한 API 호출 (GET/POST 자동 처리)
|
||||||
|
*
|
||||||
|
* 사용 방법 (fetchDataFromApi()에서):
|
||||||
|
*
|
||||||
|
* @Override
|
||||||
|
* protected List<ProductDto> fetchDataFromApi() {
|
||||||
|
* ProductApiResponse response = callApi();
|
||||||
|
* return extractDataFromResponse(response);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @param <R> 응답 타입
|
||||||
|
* @return API 응답 객체
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
protected <R> R callApi() {
|
||||||
|
if (webClient == null) {
|
||||||
|
throw new IllegalStateException("WebClient가 초기화되지 않았습니다. 생성자에서 WebClient를 주입하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String method = getHttpMethod().toUpperCase();
|
||||||
|
String path = getApiPath();
|
||||||
|
|
||||||
|
log.info("[{}] {} 요청 시작: {}", getReaderName(), method, path);
|
||||||
|
|
||||||
|
if ("GET".equals(method)) {
|
||||||
|
return callGetApi();
|
||||||
|
} else if ("POST".equals(method)) {
|
||||||
|
return callPostApi();
|
||||||
|
} else {
|
||||||
|
throw new UnsupportedOperationException("지원하지 않는 HTTP Method: " + method);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] API 호출 중 오류 발생", getReaderName(), e);
|
||||||
|
throw new RuntimeException("API 호출 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 요청 내부 처리
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <R> R callGetApi() {
|
||||||
|
return (R) webClient
|
||||||
|
.get()
|
||||||
|
.uri(buildUri())
|
||||||
|
.headers(this::applyHeaders)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(getResponseType())
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST 요청 내부 처리
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <R> R callPostApi() {
|
||||||
|
Object requestBody = getRequestBody();
|
||||||
|
|
||||||
|
if (requestBody == null) {
|
||||||
|
// Body 없는 POST 요청
|
||||||
|
return (R) webClient
|
||||||
|
.post()
|
||||||
|
.uri(buildUri())
|
||||||
|
.headers(this::applyHeaders)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(getResponseType())
|
||||||
|
.block();
|
||||||
|
} else {
|
||||||
|
// Body 있는 POST 요청
|
||||||
|
return (R) webClient
|
||||||
|
.post()
|
||||||
|
.uri(buildUri())
|
||||||
|
.headers(this::applyHeaders)
|
||||||
|
.bodyValue(requestBody)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(getResponseType())
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URI 빌드 (Path + Query Parameters + Path Variables)
|
||||||
|
*/
|
||||||
|
private Function<UriBuilder, URI> buildUri() {
|
||||||
|
return uriBuilder -> {
|
||||||
|
// 1. Path 설정
|
||||||
|
String path = getApiPath();
|
||||||
|
uriBuilder.path(path);
|
||||||
|
|
||||||
|
// 2. Query Parameters 추가
|
||||||
|
Map<String, Object> queryParams = getQueryParams();
|
||||||
|
if (queryParams != null && !queryParams.isEmpty()) {
|
||||||
|
queryParams.forEach((key, value) -> {
|
||||||
|
if (value != null) {
|
||||||
|
uriBuilder.queryParam(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log.debug("[{}] Query Parameters: {}", getReaderName(), queryParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Path Variables 적용
|
||||||
|
Map<String, Object> pathVars = getPathVariables();
|
||||||
|
if (pathVars != null && !pathVars.isEmpty()) {
|
||||||
|
log.debug("[{}] Path Variables: {}", getReaderName(), pathVars);
|
||||||
|
return uriBuilder.build(pathVars);
|
||||||
|
} else {
|
||||||
|
return uriBuilder.build();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Header 적용
|
||||||
|
*/
|
||||||
|
private void applyHeaders(HttpHeaders httpHeaders) {
|
||||||
|
// 1. 기본 헤더 설정
|
||||||
|
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
httpHeaders.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
// 2. 커스텀 헤더 추가
|
||||||
|
Map<String, String> customHeaders = getHeaders();
|
||||||
|
if (customHeaders != null && !customHeaders.isEmpty()) {
|
||||||
|
customHeaders.forEach(httpHeaders::set);
|
||||||
|
log.debug("[{}] Custom Headers: {}", getReaderName(), customHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 유틸리티 메서드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 리스트가 비어있는지 확인
|
||||||
|
*/
|
||||||
|
protected boolean isEmpty(List<T> data) {
|
||||||
|
return data == null || data.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 리스트 크기 반환 (null-safe)
|
||||||
|
*/
|
||||||
|
protected int getDataSize(List<T> data) {
|
||||||
|
return data != null ? data.size() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,339 @@
|
|||||||
|
package com.snp.batch.common.batch.repository;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JdbcTemplate 기반 Repository 추상 클래스
|
||||||
|
* 모든 Repository가 상속받아 일관된 CRUD 패턴 제공
|
||||||
|
*
|
||||||
|
* @param <T> Entity 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public abstract class BaseJdbcRepository<T, ID> {
|
||||||
|
|
||||||
|
protected final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블명 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract String getTableName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID 컬럼명 반환 (기본값: "id")
|
||||||
|
*/
|
||||||
|
protected String getIdColumnName() {
|
||||||
|
return "id";
|
||||||
|
}
|
||||||
|
protected String getIdColumnName(String customId) {
|
||||||
|
return customId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RowMapper 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract RowMapper<T> getRowMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity에서 ID 추출 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract ID extractId(T entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT SQL 생성 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract String getInsertSql();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPDATE SQL 생성 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract String getUpdateSql();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract void setInsertParameters(PreparedStatement ps, T entity) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPDATE용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract void setUpdateParameters(PreparedStatement ps, T entity) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티명 반환 (로깅용)
|
||||||
|
*/
|
||||||
|
protected abstract String getEntityName();
|
||||||
|
|
||||||
|
// ==================== CRUD 메서드 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID로 조회
|
||||||
|
*/
|
||||||
|
public Optional<T> findById(ID id) {
|
||||||
|
String sql = String.format("SELECT * FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
|
||||||
|
log.debug("{} 조회: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
List<T> results = jdbcTemplate.query(sql, getRowMapper(), id);
|
||||||
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회
|
||||||
|
*/
|
||||||
|
public List<T> findAll() {
|
||||||
|
String sql = String.format("SELECT * FROM %s ORDER BY %s DESC", getTableName(), getIdColumnName());
|
||||||
|
log.debug("{} 전체 조회", getEntityName());
|
||||||
|
return jdbcTemplate.query(sql, getRowMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개수 조회
|
||||||
|
*/
|
||||||
|
public long count() {
|
||||||
|
String sql = String.format("SELECT COUNT(*) FROM %s", getTableName());
|
||||||
|
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
||||||
|
return count != null ? count : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존재 여부 확인
|
||||||
|
*/
|
||||||
|
public boolean existsById(ID id) {
|
||||||
|
String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
|
||||||
|
Long count = jdbcTemplate.queryForObject(sql, Long.class, id);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 저장 (INSERT 또는 UPDATE)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public T save(T entity) {
|
||||||
|
ID id = extractId(entity);
|
||||||
|
|
||||||
|
if (id == null || !existsById(id)) {
|
||||||
|
return insert(entity);
|
||||||
|
} else {
|
||||||
|
return update(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 INSERT
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
protected T insert(T entity) {
|
||||||
|
log.info("{} 삽입 시작", getEntityName());
|
||||||
|
|
||||||
|
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||||
|
|
||||||
|
jdbcTemplate.update(connection -> {
|
||||||
|
PreparedStatement ps = connection.prepareStatement(getInsertSql(), Statement.RETURN_GENERATED_KEYS);
|
||||||
|
try {
|
||||||
|
setInsertParameters(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 삽입 파라미터 설정 실패", getEntityName(), e);
|
||||||
|
throw new RuntimeException("Failed to set insert parameters", e);
|
||||||
|
}
|
||||||
|
return ps;
|
||||||
|
}, keyHolder);
|
||||||
|
|
||||||
|
// 생성된 ID 조회
|
||||||
|
if (keyHolder.getKeys() != null && !keyHolder.getKeys().isEmpty()) {
|
||||||
|
Object idValue = keyHolder.getKeys().get(getIdColumnName());
|
||||||
|
if (idValue != null) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ID generatedId = (ID) (idValue instanceof Number ? ((Number) idValue).longValue() : idValue);
|
||||||
|
log.info("{} 삽입 완료: ID={}", getEntityName(), generatedId);
|
||||||
|
return findById(generatedId).orElse(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("{} 삽입 완료 (ID 미반환)", getEntityName());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 UPDATE
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
protected T update(T entity) {
|
||||||
|
ID id = extractId(entity);
|
||||||
|
log.info("{} 수정 시작: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
int updated = jdbcTemplate.update(connection -> {
|
||||||
|
PreparedStatement ps = connection.prepareStatement(getUpdateSql());
|
||||||
|
try {
|
||||||
|
setUpdateParameters(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 수정 파라미터 설정 실패", getEntityName(), e);
|
||||||
|
throw new RuntimeException("Failed to set update parameters", e);
|
||||||
|
}
|
||||||
|
return ps;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated == 0) {
|
||||||
|
throw new IllegalStateException(getEntityName() + " 수정 실패: ID=" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("{} 수정 완료: ID={}", getEntityName(), id);
|
||||||
|
return findById(id).orElse(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 INSERT (대량 삽입)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void batchInsert(List<T> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("{} 배치 삽입 시작: {} 건", getEntityName(), entities.size());
|
||||||
|
|
||||||
|
jdbcTemplate.batchUpdate(getInsertSql(), entities, entities.size(),
|
||||||
|
(ps, entity) -> {
|
||||||
|
try {
|
||||||
|
setInsertParameters(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("배치 삽입 파라미터 설정 실패", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("{} 배치 삽입 완료: {} 건", getEntityName(), entities.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 UPDATE (대량 수정)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void batchUpdate(List<T> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("{} 배치 수정 시작: {} 건", getEntityName(), entities.size());
|
||||||
|
|
||||||
|
jdbcTemplate.batchUpdate(getUpdateSql(), entities, entities.size(),
|
||||||
|
(ps, entity) -> {
|
||||||
|
try {
|
||||||
|
setUpdateParameters(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("배치 수정 파라미터 설정 실패", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("{} 배치 수정 완료: {} 건", getEntityName(), entities.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 저장 (INSERT 또는 UPDATE)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void saveAll(List<T> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("{} 전체 저장 시작: {} 건", getEntityName(), entities.size());
|
||||||
|
|
||||||
|
// INSERT와 UPDATE 분리
|
||||||
|
List<T> toInsert = entities.stream()
|
||||||
|
.filter(e -> extractId(e) == null || !existsById(extractId(e)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<T> toUpdate = entities.stream()
|
||||||
|
.filter(e -> extractId(e) != null && existsById(extractId(e)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (!toInsert.isEmpty()) {
|
||||||
|
batchInsert(toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toUpdate.isEmpty()) {
|
||||||
|
batchUpdate(toUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("{} 전체 저장 완료: 삽입={} 건, 수정={} 건", getEntityName(), toInsert.size(), toUpdate.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID로 삭제
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteById(ID id) {
|
||||||
|
String sql = String.format("DELETE FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
|
||||||
|
log.info("{} 삭제: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
int deleted = jdbcTemplate.update(sql, id);
|
||||||
|
|
||||||
|
if (deleted == 0) {
|
||||||
|
log.warn("{} 삭제 실패 (존재하지 않음): ID={}", getEntityName(), id);
|
||||||
|
} else {
|
||||||
|
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 삭제
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteAll() {
|
||||||
|
String sql = String.format("DELETE FROM %s", getTableName());
|
||||||
|
log.warn("{} 전체 삭제", getEntityName());
|
||||||
|
|
||||||
|
int deleted = jdbcTemplate.update(sql);
|
||||||
|
log.info("{} 전체 삭제 완료: {} 건", getEntityName(), deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 헬퍼 메서드 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 시각 반환 (감사 필드용)
|
||||||
|
*/
|
||||||
|
protected LocalDateTime now() {
|
||||||
|
return LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 쿼리 실행 (단건 조회)
|
||||||
|
*/
|
||||||
|
protected Optional<T> executeQueryForObject(String sql, Object... params) {
|
||||||
|
log.debug("커스텀 쿼리 실행: {}", sql);
|
||||||
|
List<T> results = jdbcTemplate.query(sql, getRowMapper(), params);
|
||||||
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 쿼리 실행 (다건 조회)
|
||||||
|
*/
|
||||||
|
protected List<T> executeQueryForList(String sql, Object... params) {
|
||||||
|
log.debug("커스텀 쿼리 실행: {}", sql);
|
||||||
|
return jdbcTemplate.query(sql, getRowMapper(), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 업데이트 실행
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
protected int executeUpdate(String sql, Object... params) {
|
||||||
|
log.debug("커스텀 업데이트 실행: {}", sql);
|
||||||
|
return jdbcTemplate.update(sql, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package com.snp.batch.common.batch.repository;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JdbcTemplate 기반 Repository 추상 클래스 (멀티 데이터 소스 지원)
|
||||||
|
* 모든 Repository가 상속받아 일관된 CRUD 패턴을 제공하며,
|
||||||
|
* Batch DB용과 Business DB용 JdbcTemplate을 모두 제공합니다.
|
||||||
|
*
|
||||||
|
* @param <T> Entity 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public abstract class MultiDataSourceJdbcRepository<T, ID> {
|
||||||
|
|
||||||
|
// ⭐ Batch 메타데이터/설정용 DB 템플릿 (1번 DB)
|
||||||
|
protected final JdbcTemplate batchJdbcTemplate;
|
||||||
|
|
||||||
|
// ⭐ Business 데이터용 DB 템플릿 (2번 DB)
|
||||||
|
protected final JdbcTemplate businessJdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자: 두 개의 JdbcTemplate을 주입받아 초기화합니다.
|
||||||
|
* (하위 클래스는 이 생성자를 호출하여 템플릿을 초기화해야 합니다.)
|
||||||
|
*/
|
||||||
|
public MultiDataSourceJdbcRepository(JdbcTemplate batchJdbcTemplate, JdbcTemplate businessJdbcTemplate) {
|
||||||
|
this.batchJdbcTemplate = batchJdbcTemplate;
|
||||||
|
this.businessJdbcTemplate = businessJdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 추상 메서드 (BaseJdbcRepository와 동일) ====================
|
||||||
|
|
||||||
|
// 이 부분은 기존 BaseJdbcRepository의 추상 메서드와 동일하게 유지하여
|
||||||
|
// 하위 클래스가 공통 CRUD 기능을 구현할 수 있도록 합니다.
|
||||||
|
|
||||||
|
protected abstract String getTableName();
|
||||||
|
protected String getIdColumnName() { return "id"; }
|
||||||
|
protected String getIdColumnName(String customId) { return customId; }
|
||||||
|
protected abstract RowMapper<T> getRowMapper();
|
||||||
|
protected abstract ID extractId(T entity);
|
||||||
|
protected abstract String getInsertSql();
|
||||||
|
protected abstract String getUpdateSql();
|
||||||
|
protected abstract void setInsertParameters(PreparedStatement ps, T entity) throws Exception;
|
||||||
|
protected abstract void setUpdateParameters(PreparedStatement ps, T entity) throws Exception;
|
||||||
|
protected abstract String getEntityName();
|
||||||
|
|
||||||
|
// ==================== 공통 CRUD 메서드 (businessJdbcTemplate 사용) ====================
|
||||||
|
|
||||||
|
// CRUD 로직은 주로 비즈니스 데이터(2번 DB)에 적용된다고 가정하고 businessJdbcTemplate을 사용합니다.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID로 조회 (Business DB)
|
||||||
|
*/
|
||||||
|
public Optional<T> findById(ID id) {
|
||||||
|
String sql = String.format("SELECT * FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
|
||||||
|
log.debug("{} 조회: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
// ⭐ businessJdbcTemplate 사용
|
||||||
|
List<T> results = businessJdbcTemplate.query(sql, getRowMapper(), id);
|
||||||
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 INSERT (Business DB)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void batchInsert(List<T> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("{} 배치 삽입 시작: {} 건 (Business DB)", getEntityName(), entities.size());
|
||||||
|
|
||||||
|
// ⭐ businessJdbcTemplate 사용
|
||||||
|
businessJdbcTemplate.batchUpdate(getInsertSql(), entities, entities.size(),
|
||||||
|
(ps, entity) -> {
|
||||||
|
try {
|
||||||
|
setInsertParameters(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("배치 삽입 파라미터 설정 실패", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("{} 배치 삽입 완료: {} 건", getEntityName(), entities.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (나머지 find, save, update, delete 메서드도 businessJdbcTemplate을 사용하여 구현합니다.)
|
||||||
|
|
||||||
|
// ==================== 헬퍼 메서드 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 쿼리 실행 (Batch DB용)
|
||||||
|
*/
|
||||||
|
protected List<T> executeBatchQueryForList(String sql, Object... params) {
|
||||||
|
log.debug("Batch DB 커스텀 쿼리 실행: {}", sql);
|
||||||
|
// ⭐ batchJdbcTemplate 사용
|
||||||
|
return batchJdbcTemplate.query(sql, getRowMapper(), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 업데이트 실행 (Business DB용)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
protected int executeBusinessUpdate(String sql, Object... params) {
|
||||||
|
log.debug("Business DB 커스텀 업데이트 실행: {}", sql);
|
||||||
|
// ⭐ businessJdbcTemplate 사용
|
||||||
|
return businessJdbcTemplate.update(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (나머지 헬퍼 메서드 생략) ...
|
||||||
|
|
||||||
|
protected LocalDateTime now() {
|
||||||
|
return LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
package com.snp.batch.common.batch.writer;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.item.Chunk;
|
||||||
|
import org.springframework.batch.item.ItemWriter;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-Chunk 분할 Writer
|
||||||
|
*
|
||||||
|
* 대량 데이터(60,000건 이상)를 subChunkSize 단위로 분할하여 커밋
|
||||||
|
* - 트랜잭션 부담 감소
|
||||||
|
* - 메모리 효율 향상
|
||||||
|
* - 실패 시 부분 복구 가능
|
||||||
|
*
|
||||||
|
* @param <T> Entity 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseChunkedWriter<T> implements ItemWriter<T> {
|
||||||
|
|
||||||
|
private static final int DEFAULT_SUB_CHUNK_SIZE = 5000;
|
||||||
|
|
||||||
|
private final String entityName;
|
||||||
|
private final int subChunkSize;
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
protected BaseChunkedWriter(String entityName, PlatformTransactionManager transactionManager) {
|
||||||
|
this(entityName, transactionManager, DEFAULT_SUB_CHUNK_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BaseChunkedWriter(String entityName, PlatformTransactionManager transactionManager, int subChunkSize) {
|
||||||
|
this.entityName = entityName;
|
||||||
|
this.subChunkSize = subChunkSize;
|
||||||
|
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 데이터 저장 로직 (하위 클래스에서 구현)
|
||||||
|
*
|
||||||
|
* @param items 저장할 Entity 리스트
|
||||||
|
* @throws Exception 저장 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
protected abstract void writeItems(List<T> items) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Batch ItemWriter 인터페이스 구현
|
||||||
|
* Chunk를 subChunkSize 단위로 분할하여 각각 독립적인 트랜잭션으로 커밋
|
||||||
|
*
|
||||||
|
* @param chunk 저장할 데이터 청크
|
||||||
|
* @throws Exception 저장 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void write(Chunk<? extends T> chunk) throws Exception {
|
||||||
|
List<T> items = new ArrayList<>(chunk.getItems());
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
log.debug("[{}] 저장할 데이터가 없습니다", entityName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalSize = items.size();
|
||||||
|
int totalSubChunks = (int) Math.ceil((double) totalSize / subChunkSize);
|
||||||
|
|
||||||
|
log.info("[{}] 전체 데이터 {}건을 {}건 단위로 분할 처리 시작 (총 {} Sub-Chunk)",
|
||||||
|
entityName, totalSize, subChunkSize, totalSubChunks);
|
||||||
|
|
||||||
|
int processedCount = 0;
|
||||||
|
int subChunkIndex = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < totalSize; i += subChunkSize) {
|
||||||
|
subChunkIndex++;
|
||||||
|
int endIndex = Math.min(i + subChunkSize, totalSize);
|
||||||
|
List<T> subChunk = items.subList(i, endIndex);
|
||||||
|
|
||||||
|
final int currentSubChunkIndex = subChunkIndex;
|
||||||
|
final int currentSubChunkSize = subChunk.size();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 각 Sub-Chunk를 독립적인 트랜잭션으로 처리
|
||||||
|
transactionTemplate.executeWithoutResult(status -> {
|
||||||
|
try {
|
||||||
|
log.debug("[{}] Sub-Chunk {}/{} 처리 시작 ({}건)",
|
||||||
|
entityName, currentSubChunkIndex, totalSubChunks, currentSubChunkSize);
|
||||||
|
|
||||||
|
writeItems(new ArrayList<>(subChunk));
|
||||||
|
|
||||||
|
log.debug("[{}] Sub-Chunk {}/{} 커밋 완료 ({}건)",
|
||||||
|
entityName, currentSubChunkIndex, totalSubChunks, currentSubChunkSize);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] Sub-Chunk {}/{} 처리 실패", entityName, currentSubChunkIndex, totalSubChunks, e);
|
||||||
|
status.setRollbackOnly();
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
processedCount += currentSubChunkSize;
|
||||||
|
log.info("[{}] 진행률: {}/{} ({}%)",
|
||||||
|
entityName, processedCount, totalSize, (processedCount * 100 / totalSize));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] Sub-Chunk {}/{} 실패. 처리 완료: {}건, 미처리: {}건",
|
||||||
|
entityName, subChunkIndex, totalSubChunks, processedCount, totalSize - processedCount);
|
||||||
|
throw new RuntimeException(
|
||||||
|
String.format("[%s] Sub-Chunk %d/%d 처리 중 오류 발생. 성공: %d건, 실패 시작 위치: %d",
|
||||||
|
entityName, subChunkIndex, totalSubChunks, processedCount, i), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[{}] 전체 데이터 {}건 저장 완료 ({} Sub-Chunk)", entityName, totalSize, totalSubChunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package com.snp.batch.common.batch.writer;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.item.Chunk;
|
||||||
|
import org.springframework.batch.item.ItemWriter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ItemWriter 추상 클래스
|
||||||
|
* 데이터 저장 로직을 위한 템플릿 제공
|
||||||
|
*
|
||||||
|
* Template Method Pattern:
|
||||||
|
* - write(): 공통 로직 (로깅, null 체크)
|
||||||
|
* - writeItems(): 하위 클래스에서 저장 로직 구현
|
||||||
|
*
|
||||||
|
* @param <T> Entity 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public abstract class BaseWriter<T> implements ItemWriter<T> {
|
||||||
|
|
||||||
|
private final String entityName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 데이터 저장 로직 (하위 클래스에서 구현)
|
||||||
|
* Repository의 saveAll() 또는 batchInsert() 호출 등
|
||||||
|
*
|
||||||
|
* @param items 저장할 Entity 리스트
|
||||||
|
* @throws Exception 저장 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
protected abstract void writeItems(List<T> items) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Batch ItemWriter 인터페이스 구현
|
||||||
|
* Chunk 단위로 데이터를 저장
|
||||||
|
*
|
||||||
|
* @param chunk 저장할 데이터 청크
|
||||||
|
* @throws Exception 저장 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void write(Chunk<? extends T> chunk) throws Exception {
|
||||||
|
List<T> items = new ArrayList<>(chunk.getItems());
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
log.debug("{} 저장할 데이터가 없습니다", entityName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("{} 데이터 {}건 저장 시작", entityName, items.size());
|
||||||
|
writeItems(items);
|
||||||
|
log.info("{} 데이터 {}건 저장 완료", entityName, items.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 데이터 저장 실패", entityName, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.ItemWriteListener;
|
||||||
|
import org.springframework.batch.item.Chunk;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BatchWriteListener<S extends JobExecutionGroupable> implements ItemWriteListener<S> {
|
||||||
|
|
||||||
|
private final JdbcTemplate businessJdbcTemplate;
|
||||||
|
private final String updateSql; // 실행할 쿼리 (예: "UPDATE ... SET batch_flag = 'S' ...")
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterWrite(Chunk<? extends S> items) {
|
||||||
|
// afterWrite는 Writer가 예외 없이 성공했을 때만 실행되는 것이 보장되어야 함
|
||||||
|
if (items.isEmpty()) return;
|
||||||
|
|
||||||
|
Long jobExecutionId = items.getItems().get(0).getJobExecutionId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
int updatedRows = businessJdbcTemplate.update(updateSql, jobExecutionId);
|
||||||
|
log.info("[BatchWriteListener] Success update 'S'. jobExecutionId: {}, rows: {}", jobExecutionId, updatedRows);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[BatchWriteListener] Update 'S' failed. jobExecutionId: {}", jobExecutionId, e);
|
||||||
|
// ❗중요: 리스너의 업데이트가 실패해도 배치를 중단시키려면 예외를 던져야 함
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWriteError(Exception exception, Chunk<? extends S> items) {
|
||||||
|
// ⭐ Writer에서 에러가 발생하면 이 메서드가 호출됨
|
||||||
|
if (!items.isEmpty()) {
|
||||||
|
Long jobExecutionId = items.getItems().get(0).getJobExecutionId();
|
||||||
|
log.error("[BatchWriteListener] Write Error Detected! jobExecutionId: {}. Status will NOT be updated to 'S'. Error: {}",
|
||||||
|
jobExecutionId, exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❗중요: 여기서 예외를 다시 던져야 배치가 중단(FAILED)됨
|
||||||
|
// 만약 여기서 예외를 던지지 않으면 배치는 다음 청크를 계속 시도할 수 있음
|
||||||
|
if (exception instanceof RuntimeException) {
|
||||||
|
throw (RuntimeException) exception;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Force stop batch due to write error", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
69
src/main/java/com/snp/batch/common/util/CommonSql.java
Normal file
69
src/main/java/com/snp/batch/common/util/CommonSql.java
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CommonSql {
|
||||||
|
private static String SOURCE_SCHEMA;
|
||||||
|
|
||||||
|
public CommonSql(@Value("${app.batch.source-schema.name}") String sourceSchema) {
|
||||||
|
SOURCE_SCHEMA = sourceSchema;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 동기화 대상 Job Execution ID 조회
|
||||||
|
*/
|
||||||
|
public static String getNextTargetQuery(String targetTable){
|
||||||
|
return """
|
||||||
|
SELECT MIN(a.job_execution_id)
|
||||||
|
FROM %s.%s a
|
||||||
|
INNER JOIN %s.batch_job_execution b
|
||||||
|
ON a.job_execution_id = b.job_execution_id
|
||||||
|
AND b.status = 'COMPLETED'
|
||||||
|
WHERE 1=1
|
||||||
|
AND a.batch_flag = 'N'
|
||||||
|
""".formatted(SOURCE_SCHEMA, targetTable, SOURCE_SCHEMA);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 동기화 대상 데이터 조회 by Job Execution ID
|
||||||
|
*/
|
||||||
|
public static String getTargetDataQuery(String targetTable){
|
||||||
|
return """
|
||||||
|
SELECT a.*
|
||||||
|
FROM %s.%s a
|
||||||
|
INNER JOIN %s.batch_job_execution b
|
||||||
|
ON a.job_execution_id = b.job_execution_id
|
||||||
|
AND b.status = 'COMPLETED'
|
||||||
|
WHERE 1=1
|
||||||
|
AND a.batch_flag = 'N'
|
||||||
|
AND a.job_execution_id = ?
|
||||||
|
ORDER BY a.job_execution_id, a.row_index;
|
||||||
|
""".formatted(SOURCE_SCHEMA, targetTable, SOURCE_SCHEMA);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 동기화 상대 업데이트 N(대기) -> P(진행)
|
||||||
|
*/
|
||||||
|
public static String getProcessBatchQuery(String targetTable) {
|
||||||
|
return """
|
||||||
|
UPDATE %s.%s
|
||||||
|
SET batch_flag = 'P'
|
||||||
|
, mdfcn_dt = CURRENT_TIMESTAMP
|
||||||
|
, mdfr_id = 'SYSTEM'
|
||||||
|
WHERE batch_flag = 'N'
|
||||||
|
and job_execution_id = ?
|
||||||
|
""".formatted(SOURCE_SCHEMA, targetTable);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 동기화 상대 업데이트 P(진행) -> S(완료)
|
||||||
|
*/
|
||||||
|
public static String getCompleteBatchQuery(String targetTable) {
|
||||||
|
return """
|
||||||
|
UPDATE %s.%s
|
||||||
|
SET batch_flag = 'S'
|
||||||
|
, mdfcn_dt = CURRENT_TIMESTAMP
|
||||||
|
, mdfr_id = 'SYSTEM'
|
||||||
|
WHERE batch_flag = 'P'
|
||||||
|
and job_execution_id = ?
|
||||||
|
""".formatted(SOURCE_SCHEMA, targetTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/main/java/com/snp/batch/common/util/EntityUtils.java
Normal file
31
src/main/java/com/snp/batch/common/util/EntityUtils.java
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.common.util; // 적절한 유틸리티 패키지로 변경
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class EntityUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제네릭 리스트(Entity 또는 DTO)에서 원하는 필드(값)만 추출하여 List<Long> 형태로 반환하는 공통 함수.
|
||||||
|
* 추출된 필드가 Long이 아닌 경우에도 Function 람다 내에서 Long으로 변환해야 합니다.
|
||||||
|
* * @param <T> 리스트의 요소 타입 (예: ShipDto, OwnerHistoryEntity)
|
||||||
|
* @param list 데이터를 추출할 리스트
|
||||||
|
* @param indexExtractor 리스트 요소에서 shipresultindex (Long 타입) 값을 추출하는 Function
|
||||||
|
* @return 추출된 shipresultindex 값들의 List<Long>
|
||||||
|
*/
|
||||||
|
public static <T> List<Long> getIndexesFromList(
|
||||||
|
List<T> list,
|
||||||
|
Function<T, Long> indexExtractor) { // ⭐ 함수명 변경 (getIndexesFromEntityList -> getIndexesFromList)
|
||||||
|
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.stream()
|
||||||
|
// Function<T, Long> 인터페이스를 사용하여 각 요소에서 Long 값을 추출
|
||||||
|
.map(indexExtractor)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.ChunkListener;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 청크 완료 후 ThreadLocal을 정리하는 리스너
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class GroupByExecutionIdChunkListener implements ChunkListener {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeChunk(ChunkContext context) {
|
||||||
|
// 청크 시작 전 - 필요시 구현
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterChunk(ChunkContext context) {
|
||||||
|
// 청크 완료 후 ThreadLocal 정리
|
||||||
|
GroupByExecutionIdPolicy.clearCurrentItem();
|
||||||
|
log.debug("[GroupByExecutionIdChunkListener] 청크 완료 - ThreadLocal 정리됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterChunkError(ChunkContext context) {
|
||||||
|
// 청크 에러 시에도 ThreadLocal 정리
|
||||||
|
GroupByExecutionIdPolicy.clearCurrentItem();
|
||||||
|
log.warn("[GroupByExecutionIdChunkListener] 청크 에러 발생 - ThreadLocal 정리됨");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.repeat.CompletionPolicy;
|
||||||
|
import org.springframework.batch.repeat.RepeatContext;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class GroupByExecutionIdPolicy implements CompletionPolicy {
|
||||||
|
|
||||||
|
// ThreadLocal을 통해 Reader에서 읽은 아이템을 전달받음
|
||||||
|
private static final ThreadLocal<Object> CURRENT_ITEM = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private Long currentId = null;
|
||||||
|
private boolean isComplete = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ItemReadListener에서 호출하여 현재 읽은 아이템을 설정
|
||||||
|
*/
|
||||||
|
public static void setCurrentItem(Object item) {
|
||||||
|
CURRENT_ITEM.set(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 청크 완료 후 ThreadLocal 정리
|
||||||
|
*/
|
||||||
|
public static void clearCurrentItem() {
|
||||||
|
CURRENT_ITEM.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isComplete(RepeatContext context, RepeatStatus result) {
|
||||||
|
// Reader가 null을 반환하면 (FINISHED) 청크 종료
|
||||||
|
if (result == RepeatStatus.FINISHED) {
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] isComplete - Reader 종료 (FINISHED)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] isComplete(context, result) 호출 - result: {}, isComplete: {}", result, isComplete);
|
||||||
|
return isComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isComplete(RepeatContext context) {
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] isComplete(context) 호출 - isComplete: {}", isComplete);
|
||||||
|
return isComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatContext start(RepeatContext parent) {
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] start 호출 - 청크 초기화");
|
||||||
|
this.currentId = null;
|
||||||
|
this.isComplete = false;
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(RepeatContext context) {
|
||||||
|
Object item = CURRENT_ITEM.get();
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] update 호출 - item: {}, currentId: {}, isComplete: {}",
|
||||||
|
item != null ? item.getClass().getSimpleName() : "null", currentId, isComplete);
|
||||||
|
|
||||||
|
// 1. 아이템이 null이면 무시 (첫 번째 호출이거나 Reader 종료 - isComplete는 isComplete(context, result)에서 처리)
|
||||||
|
if (item == null) {
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] item이 null - 무시 (아직 아이템을 읽지 않았거나 Reader 종료)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JobExecutionGroupable 구현체인 경우 job_execution_id 기준으로 청크 분리
|
||||||
|
if (item instanceof JobExecutionGroupable groupableItem) {
|
||||||
|
Long rowId = groupableItem.getJobExecutionId();
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] jobExecutionId 비교 - currentId: {}, rowId: {}", currentId, rowId);
|
||||||
|
|
||||||
|
if (currentId == null) {
|
||||||
|
// 첫 번째 아이템
|
||||||
|
currentId = rowId;
|
||||||
|
log.debug("[GroupByExecutionIdPolicy] 청크 시작 - jobExecutionId: {}", currentId);
|
||||||
|
} else if (!currentId.equals(rowId)) {
|
||||||
|
// job_execution_id가 바뀌면 청크 완료
|
||||||
|
log.info("[GroupByExecutionIdPolicy] 청크 완료 - jobExecutionId: {}, 다음: {}", currentId, rowId);
|
||||||
|
isComplete = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn("[GroupByExecutionIdPolicy] item이 JobExecutionGroupable이 아님: {}", item.getClass().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.ItemReadListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reader가 아이템을 읽을 때마다 GroupByExecutionIdPolicy에 전달하는 리스너
|
||||||
|
* 이를 통해 job_execution_id 단위로 청크를 분리할 수 있음
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class GroupByExecutionIdReadListener<T> implements ItemReadListener<T> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeRead() {
|
||||||
|
// Reader 호출 전 - 필요시 구현
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterRead(T item) {
|
||||||
|
// Reader가 아이템을 읽은 후 ThreadLocal에 설정
|
||||||
|
log.debug("[GroupByExecutionIdReadListener] afterRead 호출 - item: {}", item != null ? item.getClass().getSimpleName() : "null");
|
||||||
|
GroupByExecutionIdPolicy.setCurrentItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReadError(Exception ex) {
|
||||||
|
log.error("[GroupByExecutionIdReadListener] Read error occurred", ex);
|
||||||
|
GroupByExecutionIdPolicy.clearCurrentItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
public interface JobExecutionGroupable {
|
||||||
|
Long getJobExecutionId();
|
||||||
|
}
|
||||||
179
src/main/java/com/snp/batch/common/util/JsonChangeDetector.java
Normal file
179
src/main/java/com/snp/batch/common/util/JsonChangeDetector.java
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
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 Set<String> EXCLUDE_KEYS =
|
||||||
|
Set.of("DataSetVersion", "APSStatus", "LastUpdateDate", "LastUpdateDateTime");
|
||||||
|
|
||||||
|
private static final Map<String, String> LIST_SORT_KEYS = Map.of(
|
||||||
|
// List 필드명 // 정렬 기준 키
|
||||||
|
"OwnerHistory" ,"Sequence", // OwnerHistory는 Sequence를 기준으로 정렬
|
||||||
|
"SurveyDatesHistoryUnique" , "SurveyDate" // SurveyDatesHistoryUnique는 SurveyDate를 기준으로 정렬
|
||||||
|
// 추가적인 List/Array 필드가 있다면 여기에 추가
|
||||||
|
);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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 sortKey = LIST_SORT_KEYS.get(listFieldName);
|
||||||
|
|
||||||
|
if (sortKey != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
|
||||||
|
// Map 요소를 가진 리스트인 경우에만 정렬 실행
|
||||||
|
try {
|
||||||
|
// 정렬 기준 키를 사용하여 Comparator를 생성
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 정렬 기준 키(sortKey)의 값을 가져와 비교
|
||||||
|
Object key1 = map1.get(sortKey);
|
||||||
|
Object key2 = map2.get(sortKey);
|
||||||
|
|
||||||
|
if (key1 == null || key2 == null) {
|
||||||
|
// 키 값이 null인 경우, Map의 전체 문자열로 비교 (안전장치)
|
||||||
|
return map1.toString().compareTo(map2.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// String 타입으로 변환하여 비교 (Date, Number 타입도 대부분 String으로 처리 가능)
|
||||||
|
return key1.toString().compareTo(key2.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
|
||||||
|
// 정렬 실패 시 원래 순서 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
340
src/main/java/com/snp/batch/common/util/TableMetaInfo.java
Normal file
340
src/main/java/com/snp/batch/common/util/TableMetaInfo.java
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
package com.snp.batch.common.util;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TableMetaInfo {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ============================================
|
||||||
|
* Source Schema Tables (std_snp_data)
|
||||||
|
* ============================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ship Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-001}")
|
||||||
|
public String sourceShipData;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-002}")
|
||||||
|
public String sourceShipDetailData;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-003}")
|
||||||
|
public String sourceAdditionalShipsData;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-004}")
|
||||||
|
public String sourceBareboatCharterHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-005}")
|
||||||
|
public String sourceCallsignAndMmsiHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-006}")
|
||||||
|
public String sourceClassHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-007}")
|
||||||
|
public String sourceCompanyVesselRelationships;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-008}")
|
||||||
|
public String sourceCrewList;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-009}")
|
||||||
|
public String sourceDarkActivityConfirmed;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-010}")
|
||||||
|
public String sourceFlagHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-011}")
|
||||||
|
public String sourceGroupBeneficialOwnerHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-012}")
|
||||||
|
public String sourceIceClass;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-013}")
|
||||||
|
public String sourceNameHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-014}")
|
||||||
|
public String sourceOperatorHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-015}")
|
||||||
|
public String sourceOwnerHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-016}")
|
||||||
|
public String sourcePandiHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-017}")
|
||||||
|
public String sourceSafetyManagementCertificateHist;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-018}")
|
||||||
|
public String sourceShipManagerHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-019}")
|
||||||
|
public String sourceSisterShipLinks;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-020}")
|
||||||
|
public String sourceSpecialFeature;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-021}")
|
||||||
|
public String sourceStatusHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-022}")
|
||||||
|
public String sourceStowageCommodity;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-023}")
|
||||||
|
public String sourceSurveyDates;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-024}")
|
||||||
|
public String sourceSurveyDatesHistoryUnique;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-025}")
|
||||||
|
public String sourceTechnicalManagerHistory;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.ship-026}")
|
||||||
|
public String sourceThrusters;
|
||||||
|
|
||||||
|
// Company Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.company-001}")
|
||||||
|
public String sourceTbCompanyDetail;
|
||||||
|
|
||||||
|
// Event Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.event-001}")
|
||||||
|
public String sourceEvent;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.event-002}")
|
||||||
|
public String sourceEventCargo;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.event-003}")
|
||||||
|
public String sourceEventHumanCasualty;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.event-004}")
|
||||||
|
public String sourceEventRelationship;
|
||||||
|
|
||||||
|
// Facility Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.facility-001}")
|
||||||
|
public String sourceFacilityPort;
|
||||||
|
|
||||||
|
// PSC Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.psc-001}")
|
||||||
|
public String sourcePscDetail;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.psc-002}")
|
||||||
|
public String sourcePscDefect;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.psc-003}")
|
||||||
|
public String sourcePscAllCertificate;
|
||||||
|
|
||||||
|
// Movements Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-001}")
|
||||||
|
public String sourceTAnchorageCall;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-002}")
|
||||||
|
public String sourceTBerthCall;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-003}")
|
||||||
|
public String sourceTCurrentlyAt;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-004}")
|
||||||
|
public String sourceTDestination;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-005}")
|
||||||
|
public String sourceTShipStpovInfo;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-006}")
|
||||||
|
public String sourceTStsOperation;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-007}")
|
||||||
|
public String sourceTTerminalCall;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.movements-008}")
|
||||||
|
public String sourceTTransit;
|
||||||
|
|
||||||
|
// Code Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.code-001}")
|
||||||
|
public String sourceStat5Code;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.code-002}")
|
||||||
|
public String sourceFlagCode;
|
||||||
|
|
||||||
|
// Risk & Compliance Tables
|
||||||
|
@Value("${app.batch.source-schema.tables.risk-compliance-001}")
|
||||||
|
public String sourceRisk;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.risk-compliance-002}")
|
||||||
|
public String sourceCompliance;
|
||||||
|
|
||||||
|
@Value("${app.batch.source-schema.tables.risk-compliance-003}")
|
||||||
|
public String sourceTbCompanyComplianceInfo;
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ============================================
|
||||||
|
* Target Schema Tables (std_snp_svc)
|
||||||
|
* ============================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ship Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-001}")
|
||||||
|
public String targetTbShipInfoMst;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-002}")
|
||||||
|
public String targetTbShipMainInfo;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-003}")
|
||||||
|
public String targetTbShipAddInfo;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-004}")
|
||||||
|
public String targetTbShipBbctrHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-005}")
|
||||||
|
public String targetTbShipIdntfInfoHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-006}")
|
||||||
|
public String targetTbShipClficHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-007}")
|
||||||
|
public String targetTbShipCompanyRel;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-008}")
|
||||||
|
public String targetTbShipCrewList;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-009}")
|
||||||
|
public String targetTbShipDarkActvIdnty;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-010}")
|
||||||
|
public String targetTbShipCountryHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-011}")
|
||||||
|
public String targetTbShipGroupRevnOwnrHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-012}")
|
||||||
|
public String targetTbShipIceGrd;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-013}")
|
||||||
|
public String targetTbShipNmChgHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-014}")
|
||||||
|
public String targetTbShipOperatorHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-015}")
|
||||||
|
public String targetTbShipOwnrHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-016}")
|
||||||
|
public String targetTbShipPrtcRpnHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-017}")
|
||||||
|
public String targetTbShipSftyMngEvdcHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-018}")
|
||||||
|
public String targetTbShipMngCompanyHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-019}")
|
||||||
|
public String targetTbShipSstrvslRel;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-020}")
|
||||||
|
public String targetTbShipSpcFetr;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-021}")
|
||||||
|
public String targetTbShipStatusHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-022}")
|
||||||
|
public String targetTbShipCargoCapacity;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-023}")
|
||||||
|
public String targetTbShipInspectionYmd;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-024}")
|
||||||
|
public String targetTbShipInspectionYmdHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-025}")
|
||||||
|
public String targetTbShipTechMngCompanyHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.ship-026}")
|
||||||
|
public String targetTbThrstrInfo;
|
||||||
|
|
||||||
|
// Company Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.company-001}")
|
||||||
|
public String targetTbCompanyDtlInfo;
|
||||||
|
|
||||||
|
// Event Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.event-001}")
|
||||||
|
public String targetTbEventDtl;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.event-002}")
|
||||||
|
public String targetTbEventCargo;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.event-003}")
|
||||||
|
public String targetTbEventHumnAcdnt;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.event-004}")
|
||||||
|
public String targetTbEventRel;
|
||||||
|
|
||||||
|
// Facility Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.facility-001}")
|
||||||
|
public String targetTbPortFacilityInfo;
|
||||||
|
|
||||||
|
// PSC Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.psc-001}")
|
||||||
|
public String targetTbPscDtl;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.psc-002}")
|
||||||
|
public String targetTbPscDefect;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.psc-003}")
|
||||||
|
public String targetTbPscOaCertf;
|
||||||
|
|
||||||
|
// Movements Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-001}")
|
||||||
|
public String targetTbShipAnchrgcallHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-002}")
|
||||||
|
public String targetTbShipBerthcallHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-003}")
|
||||||
|
public String targetTbShipNowStatusHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-004}")
|
||||||
|
public String targetTbShipDestHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-005}")
|
||||||
|
public String targetTbShipPrtcllHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-006}")
|
||||||
|
public String targetTbShipStsOpertHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-007}")
|
||||||
|
public String targetTbShipTeminalcallHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.movements-008}")
|
||||||
|
public String targetTbShipTrnstHstry;
|
||||||
|
|
||||||
|
// Code Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.code-001}")
|
||||||
|
public String targetTbShipTypeCd;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.code-002}")
|
||||||
|
public String targetTbShipCountryCd;
|
||||||
|
|
||||||
|
// Risk & Compliance Tables
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-001}")
|
||||||
|
public String targetTbShipRiskInfo;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-002}")
|
||||||
|
public String targetTbShipRiskHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-003}")
|
||||||
|
public String targetTbShipComplianceInfo;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-004}")
|
||||||
|
public String targetTbShipComplianceHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-005}")
|
||||||
|
public String targetTbShipComplianceInfoHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-006}")
|
||||||
|
public String targetTbCompanyComplianceInfo;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-007}")
|
||||||
|
public String targetTbCompanyComplianceHstry;
|
||||||
|
|
||||||
|
@Value("${app.batch.target-schema.tables.risk-compliance-008}")
|
||||||
|
public String targetTbCompanyComplianceInfoHstry;
|
||||||
|
}
|
||||||
81
src/main/java/com/snp/batch/common/web/ApiResponse.java
Normal file
81
src/main/java/com/snp/batch/common/web/ApiResponse.java
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package com.snp.batch.common.web;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통일된 API 응답 형식
|
||||||
|
*
|
||||||
|
* @param <T> 응답 데이터 타입
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 여부
|
||||||
|
*/
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응답 데이터
|
||||||
|
*/
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 코드 (실패 시)
|
||||||
|
*/
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(true)
|
||||||
|
.message("Success")
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성 (메시지 포함)
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(String message, T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(true)
|
||||||
|
.message(message)
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답 생성
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답 생성 (에러 코드 포함)
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(String message, String errorCode) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.message(message)
|
||||||
|
.errorCode(errorCode)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,300 @@
|
|||||||
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/snp/batch/common/web/dto/BaseDto.java
Normal file
33
src/main/java/com/snp/batch/common/web/dto/BaseDto.java
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
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("커스텀 요청이 구현되지 않았습니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class DataSourceConfig {
|
||||||
|
|
||||||
|
// ==============================================================
|
||||||
|
// 1. 배치 메타 및 Quartz DataSource (1번 DB)
|
||||||
|
// ==============================================================
|
||||||
|
@Bean
|
||||||
|
@Primary // Spring Batch/Boot가 기본적으로 이 DataSource를 메타데이터 저장용으로 사용하도록 지정
|
||||||
|
@ConfigurationProperties(prefix = "spring.batch-meta-datasource")
|
||||||
|
public DataSource batchDataSource() {
|
||||||
|
return DataSourceBuilder.create().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-1. 배치 메타 데이터용 트랜잭션 매니저 (JPA + JDBC 모두 지원)
|
||||||
|
// Spring Data JPA가 기본으로 'transactionManager' 이름을 탐색하므로 빈 이름을 맞춤
|
||||||
|
@Bean(name = "transactionManager")
|
||||||
|
@Primary
|
||||||
|
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
|
||||||
|
return new JpaTransactionManager(entityManagerFactory);
|
||||||
|
}
|
||||||
|
// ==============================================================
|
||||||
|
// 2. 비즈니스 데이터 DataSource (2번 DB)
|
||||||
|
// ==============================================================
|
||||||
|
@Bean
|
||||||
|
@ConfigurationProperties(prefix = "spring.business-datasource")
|
||||||
|
public DataSource businessDataSource() {
|
||||||
|
return DataSourceBuilder.create().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-1. 비즈니스 데이터용 트랜잭션 매니저 (Step/Chunk에 사용)
|
||||||
|
@Bean
|
||||||
|
public PlatformTransactionManager businessTransactionManager(
|
||||||
|
@Qualifier("businessDataSource") DataSource businessDataSource) {
|
||||||
|
return new DataSourceTransactionManager(businessDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maritime API WebClient 설정
|
||||||
|
*
|
||||||
|
* 목적:
|
||||||
|
* - Maritime API 서버에 대한 WebClient Bean 등록
|
||||||
|
* - 동일한 API 서버를 사용하는 여러 Job에서 재사용
|
||||||
|
* - 설정 변경 시 한 곳에서만 수정
|
||||||
|
*
|
||||||
|
* 사용 Job:
|
||||||
|
* - shipDataImportJob: IMO 번호 조회
|
||||||
|
* - shipDetailImportJob: 선박 상세 정보 조회
|
||||||
|
*
|
||||||
|
* 다른 API 서버 추가 시:
|
||||||
|
* - 새로운 Config 클래스 생성 (예: OtherApiWebClientConfig)
|
||||||
|
* - Bean 이름을 다르게 지정 (예: @Bean(name = "otherApiWebClient"))
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
public class MaritimeApiWebClientConfig {
|
||||||
|
|
||||||
|
@Value("${app.batch.ship-api.url}")
|
||||||
|
private String maritimeApiUrl;
|
||||||
|
|
||||||
|
@Value("${app.batch.ship-api.username}")
|
||||||
|
private String maritimeApiUsername;
|
||||||
|
|
||||||
|
@Value("${app.batch.ship-api.password}")
|
||||||
|
private String maritimeApiPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maritime API용 WebClient Bean
|
||||||
|
*
|
||||||
|
* 설정:
|
||||||
|
* - Base URL: Maritime API 서버 주소
|
||||||
|
* - 인증: Basic Authentication
|
||||||
|
* - 버퍼: 20MB (대용량 응답 처리)
|
||||||
|
*
|
||||||
|
* @return Maritime API WebClient
|
||||||
|
*/
|
||||||
|
@Bean(name = "maritimeApiWebClient")
|
||||||
|
public WebClient maritimeApiWebClient() {
|
||||||
|
log.info("========================================");
|
||||||
|
log.info("Maritime API WebClient 생성");
|
||||||
|
log.info("Base URL: {}", maritimeApiUrl);
|
||||||
|
log.info("========================================");
|
||||||
|
|
||||||
|
return WebClient.builder()
|
||||||
|
.baseUrl(maritimeApiUrl)
|
||||||
|
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||||
|
.codecs(configurer -> configurer
|
||||||
|
.defaultCodecs()
|
||||||
|
.maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ========================================
|
||||||
|
* 다른 API 서버 추가 예시
|
||||||
|
* ========================================
|
||||||
|
*
|
||||||
|
* 1. 새로운 Config 클래스 생성:
|
||||||
|
*
|
||||||
|
* @Configuration
|
||||||
|
* public class ExternalApiWebClientConfig {
|
||||||
|
*
|
||||||
|
* @Bean(name = "externalApiWebClient")
|
||||||
|
* public WebClient externalApiWebClient(
|
||||||
|
* @Value("${app.batch.external-api.url}") String url,
|
||||||
|
* @Value("${app.batch.external-api.token}") String token) {
|
||||||
|
*
|
||||||
|
* return WebClient.builder()
|
||||||
|
* .baseUrl(url)
|
||||||
|
* .defaultHeader("Authorization", "Bearer " + token)
|
||||||
|
* .build();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 2. JobConfig에서 사용:
|
||||||
|
*
|
||||||
|
* public ExternalJobConfig(
|
||||||
|
* ...,
|
||||||
|
* @Qualifier("externalApiWebClient") WebClient externalApiWebClient) {
|
||||||
|
* this.webClient = externalApiWebClient;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 3. application.yml에 설정 추가:
|
||||||
|
*
|
||||||
|
* app:
|
||||||
|
* batch:
|
||||||
|
* external-api:
|
||||||
|
* url: https://external-api.example.com
|
||||||
|
* token: ${EXTERNAL_API_TOKEN}
|
||||||
|
*/
|
||||||
82
src/main/java/com/snp/batch/global/config/QuartzConfig.java
Normal file
82
src/main/java/com/snp/batch/global/config/QuartzConfig.java
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import org.quartz.spi.TriggerFiredBundle;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier; // ⚠️ 추가 필요
|
||||||
|
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.ApplicationContextAware;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
|
||||||
|
import org.springframework.scheduling.quartz.SpringBeanJobFactory;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quartz 설정
|
||||||
|
* Batch Meta DataSource (1번 DB)를 Quartz 메타데이터 저장용으로 재활용합니다.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class QuartzConfig {
|
||||||
|
|
||||||
|
private final DataSource batchDataSource; // 1번 DB를 주입받을 필드
|
||||||
|
|
||||||
|
// 1. 생성자를 통해 @Qualifier를 사용하여 'batchDataSource' Bean을 주입받습니다.
|
||||||
|
// 'batchDataSource'는 Spring Batch 메타데이터와 Quartz 메타데이터를 함께 저장할 DB입니다.
|
||||||
|
public QuartzConfig(@Qualifier("batchDataSource") DataSource batchDataSource) {
|
||||||
|
this.batchDataSource = batchDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quartz Scheduler Factory Bean 설정
|
||||||
|
* Spring Boot Auto-configuration 대신, batchDataSource를 명시적으로 설정합니다.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SchedulerFactoryBean schedulerFactoryBean(ApplicationContext applicationContext) {
|
||||||
|
SchedulerFactoryBean factory = new SchedulerFactoryBean();
|
||||||
|
|
||||||
|
// 2. 주입받은 batchDataSource를 SchedulerFactoryBean에 명시적으로 설정합니다.
|
||||||
|
// Quartz 메타데이터(Job, Trigger 정보)가 이 DB에 저장됩니다.
|
||||||
|
factory.setDataSource(batchDataSource);
|
||||||
|
|
||||||
|
factory.setJobFactory(springBeanJobFactory(applicationContext));
|
||||||
|
factory.setOverwriteExistingJobs(true);
|
||||||
|
factory.setAutoStartup(true);
|
||||||
|
|
||||||
|
Properties quartzProps = new Properties();
|
||||||
|
quartzProps.put("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate");
|
||||||
|
factory.setQuartzProperties(quartzProps);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Bean 자동 주입을 지원하는 JobFactory (기존 코드 유지)
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) {
|
||||||
|
AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
|
||||||
|
jobFactory.setApplicationContext(applicationContext);
|
||||||
|
return jobFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quartz Job에서 Spring Bean 자동 주입을 가능하게 하는 Factory (기존 코드 유지)
|
||||||
|
*/
|
||||||
|
public static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware {
|
||||||
|
|
||||||
|
private AutowireCapableBeanFactory beanFactory;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||||
|
beanFactory = applicationContext.getAutowireCapableBeanFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
|
||||||
|
Object jobInstance = super.createJobInstance(bundle);
|
||||||
|
beanFactory.autowireBean(jobInstance);
|
||||||
|
return jobInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/main/java/com/snp/batch/global/config/SwaggerConfig.java
Normal file
86
src/main/java/com/snp/batch/global/config/SwaggerConfig.java
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.info.License;
|
||||||
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger/OpenAPI 3.0 설정
|
||||||
|
*
|
||||||
|
* Swagger UI 접속 URL:
|
||||||
|
* - Swagger UI: http://localhost:8051/snp-sync/swagger-ui/index.html
|
||||||
|
* - API 문서 (JSON): http://localhost:8051/snp-sync/v3/api-docs
|
||||||
|
* - API 문서 (YAML): http://localhost:8051/snp-sync/v3/api-docs.yaml
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - REST API 자동 문서화
|
||||||
|
* - API 테스트 UI 제공
|
||||||
|
* - OpenAPI 3.0 스펙 준수
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SwaggerConfig {
|
||||||
|
|
||||||
|
@Value("${server.port:8051}")
|
||||||
|
private int serverPort;
|
||||||
|
|
||||||
|
@Value("${server.servlet.context-path:}")
|
||||||
|
private String contextPath;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(apiInfo())
|
||||||
|
.servers(List.of(
|
||||||
|
new Server()
|
||||||
|
.url("http://localhost:" + serverPort + contextPath)
|
||||||
|
.description("로컬 개발 서버"),
|
||||||
|
new Server()
|
||||||
|
.url("http://10.26.252.39:" + serverPort + contextPath)
|
||||||
|
.description("개발 서버"),
|
||||||
|
new Server()
|
||||||
|
.url("http://10.187.58.58:" + serverPort + contextPath)
|
||||||
|
.description("운영 서버")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Info apiInfo() {
|
||||||
|
return new Info()
|
||||||
|
.title("SNP Batch REST API")
|
||||||
|
.description("""
|
||||||
|
## SNP Sync Batch 시스템 REST API 문서
|
||||||
|
|
||||||
|
해양 데이터 API 동기화 시스템의 REST API 문서입니다.
|
||||||
|
|
||||||
|
### 제공 API
|
||||||
|
- **Batch Management API**: 배치 Job 실행, 중지, 이력 조회
|
||||||
|
- **Schedule API**: Quartz 기반 스케줄 CRUD 및 활성화/비활성화
|
||||||
|
- **Dashboard API**: 대시보드 데이터 및 타임라인 조회
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
- 배치 Job 수동 실행 및 중지
|
||||||
|
- Job/Step 실행 이력 상세 조회
|
||||||
|
- Cron 기반 스케줄 관리 (Quartz JDBC Store)
|
||||||
|
- 대시보드 현황 및 타임라인 시각화
|
||||||
|
|
||||||
|
### 버전 정보
|
||||||
|
- API Version: v1.0.0
|
||||||
|
- Spring Boot: 3.2.1
|
||||||
|
- Spring Batch: 5.1.0
|
||||||
|
""")
|
||||||
|
.version("v1.0.0")
|
||||||
|
.contact(new Contact()
|
||||||
|
.name("SNP Batch Team")
|
||||||
|
.email("support@snp-batch.com")
|
||||||
|
.url("https://github.com/snp-batch"))
|
||||||
|
.license(new License()
|
||||||
|
.name("Apache 2.0")
|
||||||
|
.url("https://www.apache.org/licenses/LICENSE-2.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,327 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.global.dto.JobExecutionDto;
|
||||||
|
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.ScheduleService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/batch")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Batch Management API", description = "배치 작업 실행 및 스케줄 관리 API")
|
||||||
|
public class BatchController {
|
||||||
|
|
||||||
|
private final BatchService batchService;
|
||||||
|
private final ScheduleService scheduleService;
|
||||||
|
|
||||||
|
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "작업 실행 성공"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "작업 실행 실패")
|
||||||
|
})
|
||||||
|
@PostMapping("/jobs/{jobName}/execute")
|
||||||
|
public ResponseEntity<Map<String, Object>> executeJob(
|
||||||
|
@Parameter(description = "실행할 배치 작업 이름", required = true, example = "shipDetailSyncJob")
|
||||||
|
@PathVariable String jobName,
|
||||||
|
@Parameter(description = "Job Parameters (동적 파라미터)", required = false, example = "?param1=value1¶m2=value2")
|
||||||
|
@RequestParam(required = false) Map<String, String> params) {
|
||||||
|
log.info("Received request to execute job: {} with params: {}", jobName, params);
|
||||||
|
try {
|
||||||
|
Long executionId = batchService.executeJob(jobName, params);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Job started successfully",
|
||||||
|
"executionId", executionId
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error executing job: {}", jobName, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to start job: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공")
|
||||||
|
})
|
||||||
|
@GetMapping("/jobs")
|
||||||
|
public ResponseEntity<List<String>> listJobs() {
|
||||||
|
log.info("Received request to list all jobs");
|
||||||
|
List<String> jobs = batchService.listAllJobs();
|
||||||
|
return ResponseEntity.ok(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다")
|
||||||
|
@GetMapping("/jobs/detail")
|
||||||
|
public ResponseEntity<List<com.snp.batch.global.dto.JobDetailDto>> getJobsDetail() {
|
||||||
|
List<com.snp.batch.global.dto.JobDetailDto> jobs = batchService.getJobsWithDetail();
|
||||||
|
return ResponseEntity.ok(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "배치 작업 실행 이력 조회", description = "특정 배치 작업의 실행 이력을 조회합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공")
|
||||||
|
})
|
||||||
|
@GetMapping("/jobs/{jobName}/executions")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getJobExecutions(
|
||||||
|
@Parameter(description = "배치 작업 이름", required = true, example = "shipDetailSyncJob")
|
||||||
|
@PathVariable String jobName) {
|
||||||
|
log.info("Received request to get executions for job: {}", jobName);
|
||||||
|
List<JobExecutionDto> executions = batchService.getJobExecutions(jobName);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "최근 실행 이력 조회", description = "전체 작업의 최근 실행 이력을 조회합니다")
|
||||||
|
@GetMapping("/executions/recent")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getRecentExecutions(
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
List<JobExecutionDto> executions = batchService.getRecentExecutions(limit);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/executions/{executionId}")
|
||||||
|
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
|
||||||
|
log.info("Received request to get execution details for: {}", executionId);
|
||||||
|
try {
|
||||||
|
JobExecutionDto execution = batchService.getExecutionDetails(executionId);
|
||||||
|
return ResponseEntity.ok(execution);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting execution details: {}", executionId, e);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/executions/{executionId}/detail")
|
||||||
|
public ResponseEntity<com.snp.batch.global.dto.JobExecutionDetailDto> getExecutionDetailWithSteps(@PathVariable Long executionId) {
|
||||||
|
log.info("Received request to get detailed execution for: {}", executionId);
|
||||||
|
try {
|
||||||
|
com.snp.batch.global.dto.JobExecutionDetailDto detail = batchService.getExecutionDetailWithSteps(executionId);
|
||||||
|
return ResponseEntity.ok(detail);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting detailed execution: {}", executionId, e);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/executions/{executionId}/stop")
|
||||||
|
public ResponseEntity<Map<String, Object>> stopExecution(@PathVariable Long executionId) {
|
||||||
|
log.info("Received request to stop execution: {}", executionId);
|
||||||
|
try {
|
||||||
|
batchService.stopExecution(executionId);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Execution stop requested"
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error stopping execution: {}", executionId, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to stop execution: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "스케줄 목록 조회", description = "등록된 모든 스케줄을 조회합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공")
|
||||||
|
})
|
||||||
|
@GetMapping("/schedules")
|
||||||
|
public ResponseEntity<Map<String, Object>> getSchedules() {
|
||||||
|
log.info("Received request to get all schedules");
|
||||||
|
List<ScheduleResponse> schedules = scheduleService.getAllSchedules();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"schedules", schedules,
|
||||||
|
"count", schedules.size()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/schedules/{jobName}")
|
||||||
|
public ResponseEntity<ScheduleResponse> getSchedule(@PathVariable String jobName) {
|
||||||
|
log.debug("Received request to get schedule for job: {}", jobName);
|
||||||
|
try {
|
||||||
|
ScheduleResponse schedule = scheduleService.getScheduleByJobName(jobName);
|
||||||
|
return ResponseEntity.ok(schedule);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// 스케줄이 없는 경우 - 정상적인 시나리오 (UI에서 존재 여부 확인용)
|
||||||
|
log.debug("Schedule not found for job: {} (정상 - 존재 확인)", jobName);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting schedule for job: {}", jobName, e);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "스케줄 생성", description = "새로운 배치 작업 스케줄을 등록합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "생성 성공"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "생성 실패")
|
||||||
|
})
|
||||||
|
@PostMapping("/schedules")
|
||||||
|
public ResponseEntity<Map<String, Object>> createSchedule(
|
||||||
|
@Parameter(description = "스케줄 생성 요청 데이터", required = true)
|
||||||
|
@RequestBody ScheduleRequest request) {
|
||||||
|
log.info("Received request to create schedule for job: {}", request.getJobName());
|
||||||
|
try {
|
||||||
|
ScheduleResponse schedule = scheduleService.createSchedule(request);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Schedule created successfully",
|
||||||
|
"data", schedule
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error creating schedule for job: {}", request.getJobName(), e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to create schedule: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/schedules/{jobName}")
|
||||||
|
public ResponseEntity<Map<String, Object>> updateSchedule(
|
||||||
|
@PathVariable String jobName,
|
||||||
|
@RequestBody Map<String, String> request) {
|
||||||
|
log.info("Received request to update schedule for job: {}", jobName);
|
||||||
|
try {
|
||||||
|
String cronExpression = request.get("cronExpression");
|
||||||
|
String description = request.get("description");
|
||||||
|
ScheduleResponse schedule = scheduleService.updateSchedule(jobName, cronExpression, description);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Schedule updated successfully",
|
||||||
|
"data", schedule
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error updating schedule for job: {}", jobName, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to update schedule: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "스케줄 삭제", description = "배치 작업 스케줄을 삭제합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "삭제 성공"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "삭제 실패")
|
||||||
|
})
|
||||||
|
@DeleteMapping("/schedules/{jobName}")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteSchedule(
|
||||||
|
@Parameter(description = "배치 작업 이름", required = true)
|
||||||
|
@PathVariable String jobName) {
|
||||||
|
log.info("Received request to delete schedule for job: {}", jobName);
|
||||||
|
try {
|
||||||
|
scheduleService.deleteSchedule(jobName);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Schedule deleted successfully"
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error deleting schedule for job: {}", jobName, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to delete schedule: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/schedules/{jobName}/toggle")
|
||||||
|
public ResponseEntity<Map<String, Object>> toggleSchedule(
|
||||||
|
@PathVariable String jobName,
|
||||||
|
@RequestBody Map<String, Boolean> request) {
|
||||||
|
log.info("Received request to toggle schedule for job: {}", jobName);
|
||||||
|
try {
|
||||||
|
Boolean active = request.get("active");
|
||||||
|
ScheduleResponse schedule = scheduleService.toggleScheduleActive(jobName, active);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Schedule toggled successfully",
|
||||||
|
"data", schedule
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error toggling schedule for job: {}", jobName, e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Failed to toggle schedule: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/timeline")
|
||||||
|
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
|
||||||
|
@RequestParam String view,
|
||||||
|
@RequestParam String date) {
|
||||||
|
log.info("Received request to get timeline: view={}, date={}", view, date);
|
||||||
|
try {
|
||||||
|
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
|
||||||
|
return ResponseEntity.ok(timeline);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting timeline", e);
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
|
||||||
|
log.info("Received request to get dashboard data");
|
||||||
|
try {
|
||||||
|
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
|
||||||
|
return ResponseEntity.ok(dashboard);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting dashboard data", e);
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/timeline/period-executions")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getPeriodExecutions(
|
||||||
|
@RequestParam String jobName,
|
||||||
|
@RequestParam String view,
|
||||||
|
@RequestParam String periodKey) {
|
||||||
|
log.info("Received request to get period executions: jobName={}, view={}, periodKey={}", jobName, view, periodKey);
|
||||||
|
try {
|
||||||
|
List<JobExecutionDto> executions = batchService.getPeriodExecutions(jobName, view, periodKey);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting period executions", e);
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F8: 실행 통계 API ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<com.snp.batch.global.dto.ExecutionStatisticsDto> getStatistics(
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
com.snp.batch.global.dto.ExecutionStatisticsDto stats = batchService.getStatistics(days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다")
|
||||||
|
@GetMapping("/statistics/{jobName}")
|
||||||
|
public ResponseEntity<com.snp.batch.global.dto.ExecutionStatisticsDto> getJobStatistics(
|
||||||
|
@Parameter(description = "Job 이름", required = true) @PathVariable String jobName,
|
||||||
|
@Parameter(description = "조회 기간(일)", example = "30")
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
com.snp.batch.global.dto.ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA(React) fallback 라우터
|
||||||
|
*
|
||||||
|
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
|
||||||
|
* 모든 프론트 경로를 index.html로 포워딩한다.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class WebViewController {
|
||||||
|
|
||||||
|
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||||
|
"/schedules", "/schedule-timeline",
|
||||||
|
"/jobs/**", "/executions/**",
|
||||||
|
"/schedules/**", "/schedule-timeline/**"})
|
||||||
|
public String forward() {
|
||||||
|
return "forward:/index.html";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class DashboardResponse {
|
||||||
|
private Stats stats;
|
||||||
|
private List<RunningJob> runningJobs;
|
||||||
|
private List<RecentExecution> recentExecutions;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Stats {
|
||||||
|
private int totalSchedules;
|
||||||
|
private int activeSchedules;
|
||||||
|
private int inactiveSchedules;
|
||||||
|
private int totalJobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class RunningJob {
|
||||||
|
private String jobName;
|
||||||
|
private Long executionId;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class RecentExecution {
|
||||||
|
private Long executionId;
|
||||||
|
private String jobName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 실행 상세 정보 DTO
|
||||||
|
* JobExecution + StepExecution 정보 포함
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class JobExecutionDetailDto {
|
||||||
|
|
||||||
|
// Job Execution 기본 정보
|
||||||
|
private Long executionId;
|
||||||
|
private String jobName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private String exitCode;
|
||||||
|
private String exitMessage;
|
||||||
|
|
||||||
|
// Job Parameters
|
||||||
|
private Map<String, Object> jobParameters;
|
||||||
|
|
||||||
|
// Job Instance 정보
|
||||||
|
private Long jobInstanceId;
|
||||||
|
|
||||||
|
// 실행 통계
|
||||||
|
private Long duration; // 실행 시간 (ms)
|
||||||
|
private Integer readCount;
|
||||||
|
private Integer writeCount;
|
||||||
|
private Integer skipCount;
|
||||||
|
private Integer filterCount;
|
||||||
|
|
||||||
|
// Step 실행 정보
|
||||||
|
private List<StepExecutionDto> stepExecutions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 실행 정보 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class StepExecutionDto {
|
||||||
|
private Long stepExecutionId;
|
||||||
|
private String stepName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private Integer readCount;
|
||||||
|
private Integer writeCount;
|
||||||
|
private Integer commitCount;
|
||||||
|
private Integer rollbackCount;
|
||||||
|
private Integer readSkipCount;
|
||||||
|
private Integer processSkipCount;
|
||||||
|
private Integer writeSkipCount;
|
||||||
|
private Integer filterCount;
|
||||||
|
private String exitCode;
|
||||||
|
private String exitMessage;
|
||||||
|
private Long duration; // 실행 시간 (ms)
|
||||||
|
private ApiCallInfo apiCallInfo; // API 호출 정보 (옵셔널)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 정보 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ApiCallInfo {
|
||||||
|
private String apiUrl; // API URL
|
||||||
|
private String method; // HTTP Method (GET, POST, etc.)
|
||||||
|
private Map<String, Object> parameters; // API 파라미터
|
||||||
|
private Integer totalCalls; // 전체 API 호출 횟수
|
||||||
|
private Integer completedCalls; // 완료된 API 호출 횟수
|
||||||
|
private String lastCallTime; // 마지막 호출 시간
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/main/java/com/snp/batch/global/dto/JobExecutionDto.java
Normal file
23
src/main/java/com/snp/batch/global/dto/JobExecutionDto.java
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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 JobExecutionDto {
|
||||||
|
|
||||||
|
private Long executionId;
|
||||||
|
private String jobName;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime startTime;
|
||||||
|
private LocalDateTime endTime;
|
||||||
|
private String exitCode;
|
||||||
|
private String exitMessage;
|
||||||
|
}
|
||||||
46
src/main/java/com/snp/batch/global/dto/ScheduleRequest.java
Normal file
46
src/main/java/com/snp/batch/global/dto/ScheduleRequest.java
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 등록/수정 요청 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ScheduleRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 작업 이름
|
||||||
|
* 예: "jsonToPostgresJob", "shipDataImportJob"
|
||||||
|
*/
|
||||||
|
private String jobName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 표현식
|
||||||
|
* 예: "0 0 2 * * ?" (매일 새벽 2시)
|
||||||
|
* "0 0 * * * ?" (매 시간)
|
||||||
|
* "0 0/30 * * * ?" (30분마다)
|
||||||
|
*/
|
||||||
|
private String cronExpression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 설명 (선택)
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화 여부 (선택, 기본값 true)
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean active = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자/수정자 정보 (선택)
|
||||||
|
*/
|
||||||
|
private String updatedBy;
|
||||||
|
}
|
||||||
80
src/main/java/com/snp/batch/global/dto/ScheduleResponse.java
Normal file
80
src/main/java/com/snp/batch/global/dto/ScheduleResponse.java
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 조회 응답 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ScheduleResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 ID
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 작업 이름
|
||||||
|
*/
|
||||||
|
private String jobName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 표현식
|
||||||
|
*/
|
||||||
|
private String cronExpression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 설명
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화 여부
|
||||||
|
*/
|
||||||
|
private Boolean active;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 실행 예정 시간 (Quartz에서 계산)
|
||||||
|
*/
|
||||||
|
private Date nextFireTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 실행 시간 (Quartz에서 조회)
|
||||||
|
*/
|
||||||
|
private Date previousFireTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quartz Trigger 상태
|
||||||
|
* NORMAL, PAUSED, COMPLETE, ERROR, BLOCKED, NONE
|
||||||
|
*/
|
||||||
|
private String triggerState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 일시
|
||||||
|
*/
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 일시
|
||||||
|
*/
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자
|
||||||
|
*/
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정자
|
||||||
|
*/
|
||||||
|
private String updatedBy;
|
||||||
|
}
|
||||||
48
src/main/java/com/snp/batch/global/dto/TimelineResponse.java
Normal file
48
src/main/java/com/snp/batch/global/dto/TimelineResponse.java
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TimelineResponse {
|
||||||
|
private String periodLabel;
|
||||||
|
private List<PeriodInfo> periods;
|
||||||
|
private List<ScheduleTimeline> schedules;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class PeriodInfo {
|
||||||
|
private String key;
|
||||||
|
private String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ScheduleTimeline {
|
||||||
|
private String jobName;
|
||||||
|
private Map<String, ExecutionInfo> executions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ExecutionInfo {
|
||||||
|
private Long executionId;
|
||||||
|
private String status;
|
||||||
|
private String startTime;
|
||||||
|
private String endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/main/java/com/snp/batch/global/model/JobScheduleEntity.java
Normal file
110
src/main/java/com/snp/batch/global/model/JobScheduleEntity.java
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 작업 스케줄 정보를 저장하는 엔티티
|
||||||
|
* Quartz 스케줄러와 연동하여 DB에 영속화
|
||||||
|
*
|
||||||
|
* JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "job_schedule", schema = "snp_batch", indexes = {
|
||||||
|
@Index(name = "idx_job_name", columnList = "job_name", unique = true),
|
||||||
|
@Index(name = "idx_active", columnList = "active")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class JobScheduleEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 작업 이름 (BatchConfig에 등록된 Job Bean 이름)
|
||||||
|
* 예: "jsonToPostgresJob", "shipDataImportJob"
|
||||||
|
*/
|
||||||
|
@Column(name = "job_name", unique = true, nullable = false, length = 100)
|
||||||
|
private String jobName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 표현식
|
||||||
|
* 예: "0 0 2 * * ?" (매일 새벽 2시)
|
||||||
|
*/
|
||||||
|
@Column(name = "cron_expression", nullable = false, length = 100)
|
||||||
|
private String cronExpression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 설명
|
||||||
|
*/
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화 여부
|
||||||
|
* true: 스케줄 활성, false: 일시 중지
|
||||||
|
*/
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean active = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 일시 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 일시 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "created_by", length = 100)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정자 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "updated_by", length = 100)
|
||||||
|
private String updatedBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 저장 전 자동 호출 (INSERT 시)
|
||||||
|
*/
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
if (this.createdBy == null) {
|
||||||
|
this.createdBy = "SYSTEM";
|
||||||
|
}
|
||||||
|
if (this.updatedBy == null) {
|
||||||
|
this.updatedBy = "SYSTEM";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 업데이트 전 자동 호출 (UPDATE 시)
|
||||||
|
*/
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
if (this.updatedBy == null) {
|
||||||
|
this.updatedBy = "SYSTEM";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.JobScheduleEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JobScheduleEntity Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface JobScheduleRepository extends JpaRepository<JobScheduleEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름으로 스케줄 조회
|
||||||
|
*/
|
||||||
|
Optional<JobScheduleEntity> findByJobName(String jobName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByJobName(String jobName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 스케줄 목록 조회
|
||||||
|
*/
|
||||||
|
List<JobScheduleEntity> findByActive(Boolean active);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 모든 스케줄 조회
|
||||||
|
*/
|
||||||
|
default List<JobScheduleEntity> findAllActive() {
|
||||||
|
return findByActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름으로 스케줄 삭제
|
||||||
|
*/
|
||||||
|
void deleteByJobName(String jobName);
|
||||||
|
}
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 조회를 위한 경량 Repository
|
||||||
|
* Step Context 등 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TimelineRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 Job의 특정 범위 내 실행 이력 조회 (경량)
|
||||||
|
* Step Context를 조회하지 않아 성능이 매우 빠름
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findExecutionsByJobNameAndDateRange(
|
||||||
|
String jobName,
|
||||||
|
LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime) {
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime,
|
||||||
|
je.END_TIME as endTime
|
||||||
|
FROM BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE ji.JOB_NAME = ?
|
||||||
|
AND je.START_TIME >= ?
|
||||||
|
AND je.START_TIME < ?
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, jobName, startTime, endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 Job의 특정 범위 내 실행 이력 조회 (한 번의 쿼리)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findAllExecutionsByDateRange(
|
||||||
|
LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime) {
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
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
|
||||||
|
FROM BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.START_TIME >= ?
|
||||||
|
AND je.START_TIME < ?
|
||||||
|
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, startTime, endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 실행 중인 Job 조회 (STARTED, STARTING 상태)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findRunningExecutions() {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
ji.JOB_NAME as jobName,
|
||||||
|
je.JOB_EXECUTION_ID as executionId,
|
||||||
|
je.STATUS as status,
|
||||||
|
je.START_TIME as startTime
|
||||||
|
FROM BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
WHERE je.STATUS IN ('STARTED', 'STARTING')
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 실행 이력 조회 (상위 N개)
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Job별 가장 최근 실행 정보 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findLastExecutionPerJob() {
|
||||||
|
String sql = """
|
||||||
|
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 BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
ORDER BY ji.JOB_NAME, je.START_TIME DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 실행 통계 (전체)
|
||||||
|
*/
|
||||||
|
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 BATCH_JOB_EXECUTION 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
|
||||||
|
""", 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 BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE 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
|
||||||
|
""", days);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, jobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 실행 이력 조회 (exitCode, exitMessage 포함)
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findRecentExecutionsWithDetail(int limit) {
|
||||||
|
String sql = """
|
||||||
|
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 BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findRecentExecutions(int limit) {
|
||||||
|
String sql = """
|
||||||
|
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
|
||||||
|
FROM BATCH_JOB_EXECUTION je
|
||||||
|
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
|
||||||
|
ORDER BY je.START_TIME DESC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.config;
|
||||||
|
|
||||||
|
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||||
|
import com.snp.batch.common.util.BatchWriteListener;
|
||||||
|
import com.snp.batch.common.util.CommonSql;
|
||||||
|
import com.snp.batch.common.util.GroupByExecutionIdChunkListener;
|
||||||
|
import com.snp.batch.common.util.GroupByExecutionIdPolicy;
|
||||||
|
import com.snp.batch.common.util.GroupByExecutionIdReadListener;
|
||||||
|
import com.snp.batch.common.util.TableMetaInfo;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.dto.FlagCodeDto;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.dto.Stat5CodeDto;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.FlagCodeEntity;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.Stat5CodeEntity;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.processor.FlagCodeProcessor;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.processor.Stat5CodeProcessor;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.reader.FlagCodeReader;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.reader.Stat5CodeReader;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.repository.CodeRepository;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.writer.FlagCodeWriter;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.writer.Stat5CodeWriter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.batch.item.ItemProcessor;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.batch.item.ItemWriter;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
public class CodeSyncJobConfig extends BaseJobConfig<FlagCodeDto, FlagCodeEntity> {
|
||||||
|
private final TableMetaInfo tableMetaInfo;
|
||||||
|
private final CodeRepository codeRepository;
|
||||||
|
private final DataSource batchDataSource;
|
||||||
|
private final DataSource businessDataSource;
|
||||||
|
private final JdbcTemplate businessJdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자 주입
|
||||||
|
*/
|
||||||
|
public CodeSyncJobConfig(
|
||||||
|
JobRepository jobRepository,
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
CodeRepository codeRepository,
|
||||||
|
TableMetaInfo tableMetaInfo,
|
||||||
|
@Qualifier("batchDataSource") DataSource batchDataSource,
|
||||||
|
@Qualifier("businessDataSource") DataSource businessDataSource
|
||||||
|
) {
|
||||||
|
super(jobRepository, transactionManager);
|
||||||
|
this.codeRepository = codeRepository;
|
||||||
|
this.tableMetaInfo = tableMetaInfo;
|
||||||
|
this.batchDataSource = batchDataSource;
|
||||||
|
this.businessDataSource = businessDataSource;
|
||||||
|
this.businessJdbcTemplate = new JdbcTemplate(businessDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getJobName() {
|
||||||
|
return "codeDataSyncJob";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getStepName() {
|
||||||
|
return "flagCodeSyncStep";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemReader<FlagCodeDto> createReader() {
|
||||||
|
return flagCodeReader(businessDataSource, tableMetaInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemProcessor<FlagCodeDto, FlagCodeEntity> createProcessor() {
|
||||||
|
return new FlagCodeProcessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemWriter<FlagCodeEntity> createWriter() {
|
||||||
|
return new FlagCodeWriter(codeRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FlagCode Reader ---
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public ItemReader<FlagCodeDto> flagCodeReader(
|
||||||
|
@Qualifier("businessDataSource") DataSource businessDataSource,
|
||||||
|
TableMetaInfo tableMetaInfo) {
|
||||||
|
return new FlagCodeReader(businessDataSource, tableMetaInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stat5Code Reader ---
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public ItemReader<Stat5CodeDto> stat5CodeReader(
|
||||||
|
@Qualifier("businessDataSource") DataSource businessDataSource,
|
||||||
|
TableMetaInfo tableMetaInfo) {
|
||||||
|
return new Stat5CodeReader(businessDataSource, tableMetaInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Listeners ---
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public BatchWriteListener<FlagCodeEntity> flagCodeWriteListener() {
|
||||||
|
String sql = CommonSql.getCompleteBatchQuery(tableMetaInfo.sourceFlagCode);
|
||||||
|
return new BatchWriteListener<>(businessJdbcTemplate, sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public BatchWriteListener<Stat5CodeEntity> stat5CodeWriteListener() {
|
||||||
|
String sql = CommonSql.getCompleteBatchQuery(tableMetaInfo.sourceStat5Code);
|
||||||
|
return new BatchWriteListener<>(businessJdbcTemplate, sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steps ---
|
||||||
|
|
||||||
|
@Bean(name = "flagCodeSyncStep")
|
||||||
|
public Step flagCodeSyncStep() {
|
||||||
|
log.info("Step 생성: flagCodeSyncStep");
|
||||||
|
return new StepBuilder(getStepName(), jobRepository)
|
||||||
|
.<FlagCodeDto, FlagCodeEntity>chunk(new GroupByExecutionIdPolicy(), transactionManager)
|
||||||
|
.reader(createReader())
|
||||||
|
.processor(createProcessor())
|
||||||
|
.writer(createWriter())
|
||||||
|
.listener(new GroupByExecutionIdReadListener<FlagCodeDto>())
|
||||||
|
.listener(new GroupByExecutionIdChunkListener())
|
||||||
|
.listener(flagCodeWriteListener())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "stat5CodeSyncStep")
|
||||||
|
public Step stat5CodeSyncStep() {
|
||||||
|
log.info("Step 생성: stat5CodeSyncStep");
|
||||||
|
return new StepBuilder("stat5CodeSyncStep", jobRepository)
|
||||||
|
.<Stat5CodeDto, Stat5CodeEntity>chunk(new GroupByExecutionIdPolicy(), transactionManager)
|
||||||
|
.reader(stat5CodeReader(businessDataSource, tableMetaInfo))
|
||||||
|
.processor(new Stat5CodeProcessor())
|
||||||
|
.writer(new Stat5CodeWriter(codeRepository))
|
||||||
|
.listener(new GroupByExecutionIdReadListener<Stat5CodeDto>())
|
||||||
|
.listener(new GroupByExecutionIdChunkListener())
|
||||||
|
.listener(stat5CodeWriteListener())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Job createJobFlow(JobBuilder jobBuilder) {
|
||||||
|
return jobBuilder
|
||||||
|
.start(flagCodeSyncStep())
|
||||||
|
.next(stat5CodeSyncStep())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "codeDataSyncJob")
|
||||||
|
public Job codeDataSyncJob() {
|
||||||
|
return job();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.dto;
|
||||||
|
|
||||||
|
import com.snp.batch.common.util.JobExecutionGroupable;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class FlagCodeDto implements JobExecutionGroupable {
|
||||||
|
private Long jobExecutionId;
|
||||||
|
private String datasetVer;
|
||||||
|
private String shipCountryCd;
|
||||||
|
private String cdNm;
|
||||||
|
private String isoTwoCd;
|
||||||
|
private String isoThrCd;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getJobExecutionId() {
|
||||||
|
return this.jobExecutionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.dto;
|
||||||
|
|
||||||
|
import com.snp.batch.common.util.JobExecutionGroupable;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Stat5CodeDto implements JobExecutionGroupable {
|
||||||
|
private Long jobExecutionId;
|
||||||
|
private String lvOne;
|
||||||
|
private String lvOneDesc;
|
||||||
|
private String lvTwo;
|
||||||
|
private String lvTwoDesc;
|
||||||
|
private String lvThr;
|
||||||
|
private String lvThrDesc;
|
||||||
|
private String lvFour;
|
||||||
|
private String lvFourDesc;
|
||||||
|
private String lvFive;
|
||||||
|
private String lvFiveDesc;
|
||||||
|
private String dtlDesc;
|
||||||
|
private String rlsIem;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getJobExecutionId() {
|
||||||
|
return this.jobExecutionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.entity;
|
||||||
|
|
||||||
|
import com.snp.batch.common.util.JobExecutionGroupable;
|
||||||
|
import lombok.*;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@SuperBuilder
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class FlagCodeEntity implements JobExecutionGroupable {
|
||||||
|
private String datasetVer;
|
||||||
|
private String shipCountryCd;
|
||||||
|
private String cdNm;
|
||||||
|
private String isoTwoCd;
|
||||||
|
private String isoThrCd;
|
||||||
|
|
||||||
|
private Long jobExecutionId;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getJobExecutionId() {
|
||||||
|
return this.jobExecutionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.entity;
|
||||||
|
|
||||||
|
import com.snp.batch.common.util.JobExecutionGroupable;
|
||||||
|
import lombok.*;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@SuperBuilder
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Stat5CodeEntity implements JobExecutionGroupable {
|
||||||
|
private String lvOne;
|
||||||
|
private String lvOneDesc;
|
||||||
|
private String lvTwo;
|
||||||
|
private String lvTwoDesc;
|
||||||
|
private String lvThr;
|
||||||
|
private String lvThrDesc;
|
||||||
|
private String lvFour;
|
||||||
|
private String lvFourDesc;
|
||||||
|
private String lvFive;
|
||||||
|
private String lvFiveDesc;
|
||||||
|
private String dtlDesc;
|
||||||
|
private String rlsIem;
|
||||||
|
|
||||||
|
private Long jobExecutionId;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long getJobExecutionId() {
|
||||||
|
return this.jobExecutionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.processor;
|
||||||
|
|
||||||
|
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.dto.FlagCodeDto;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.FlagCodeEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class FlagCodeProcessor extends BaseProcessor<FlagCodeDto, FlagCodeEntity> {
|
||||||
|
@Override
|
||||||
|
protected FlagCodeEntity processItem(FlagCodeDto dto) throws Exception {
|
||||||
|
return FlagCodeEntity.builder()
|
||||||
|
.jobExecutionId(dto.getJobExecutionId())
|
||||||
|
.datasetVer(dto.getDatasetVer())
|
||||||
|
.shipCountryCd(dto.getShipCountryCd())
|
||||||
|
.cdNm(dto.getCdNm())
|
||||||
|
.isoTwoCd(dto.getIsoTwoCd())
|
||||||
|
.isoThrCd(dto.getIsoThrCd())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.processor;
|
||||||
|
|
||||||
|
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.dto.Stat5CodeDto;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.Stat5CodeEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class Stat5CodeProcessor extends BaseProcessor<Stat5CodeDto, Stat5CodeEntity> {
|
||||||
|
@Override
|
||||||
|
protected Stat5CodeEntity processItem(Stat5CodeDto dto) throws Exception {
|
||||||
|
return Stat5CodeEntity.builder()
|
||||||
|
.jobExecutionId(dto.getJobExecutionId())
|
||||||
|
.lvOne(dto.getLvOne())
|
||||||
|
.lvOneDesc(dto.getLvOneDesc())
|
||||||
|
.lvTwo(dto.getLvTwo())
|
||||||
|
.lvTwoDesc(dto.getLvTwoDesc())
|
||||||
|
.lvThr(dto.getLvThr())
|
||||||
|
.lvThrDesc(dto.getLvThrDesc())
|
||||||
|
.lvFour(dto.getLvFour())
|
||||||
|
.lvFourDesc(dto.getLvFourDesc())
|
||||||
|
.lvFive(dto.getLvFive())
|
||||||
|
.lvFiveDesc(dto.getLvFiveDesc())
|
||||||
|
.dtlDesc(dto.getDtlDesc())
|
||||||
|
.rlsIem(dto.getRlsIem())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.reader;
|
||||||
|
|
||||||
|
import com.snp.batch.common.util.CommonSql;
|
||||||
|
import com.snp.batch.common.util.TableMetaInfo;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.dto.FlagCodeDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class FlagCodeReader implements ItemReader<FlagCodeDto> {
|
||||||
|
private final TableMetaInfo tableMetaInfo;
|
||||||
|
private final JdbcTemplate businessJdbcTemplate;
|
||||||
|
private List<FlagCodeDto> allDataBuffer = new ArrayList<>();
|
||||||
|
|
||||||
|
public FlagCodeReader(@Qualifier("businessDataSource") DataSource businessDataSource, TableMetaInfo tableMetaInfo) {
|
||||||
|
this.businessJdbcTemplate = new JdbcTemplate(businessDataSource);
|
||||||
|
this.tableMetaInfo = tableMetaInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FlagCodeDto read() throws Exception {
|
||||||
|
if (allDataBuffer.isEmpty()) {
|
||||||
|
fetchNextGroup();
|
||||||
|
}
|
||||||
|
if (allDataBuffer.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return allDataBuffer.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fetchNextGroup() {
|
||||||
|
Long nextTargetId = null;
|
||||||
|
try {
|
||||||
|
nextTargetId = businessJdbcTemplate.queryForObject(
|
||||||
|
CommonSql.getNextTargetQuery(tableMetaInfo.sourceFlagCode), Long.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextTargetId != null) {
|
||||||
|
log.info("[FlagCodeReader] 다음 처리 대상 ID 발견: {}", nextTargetId);
|
||||||
|
String sql = CommonSql.getTargetDataQuery(tableMetaInfo.sourceFlagCode);
|
||||||
|
final Long targetId = nextTargetId;
|
||||||
|
this.allDataBuffer = businessJdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
|
return FlagCodeDto.builder()
|
||||||
|
.jobExecutionId(targetId)
|
||||||
|
.datasetVer(rs.getString("dataset_ver"))
|
||||||
|
.shipCountryCd(rs.getString("ship_country_cd"))
|
||||||
|
.cdNm(rs.getString("cd_nm"))
|
||||||
|
.isoTwoCd(rs.getString("iso_two_cd"))
|
||||||
|
.isoThrCd(rs.getString("iso_thr_cd"))
|
||||||
|
.build();
|
||||||
|
}, nextTargetId);
|
||||||
|
updateBatchProcessing(nextTargetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateBatchProcessing(Long targetExecutionId) {
|
||||||
|
String sql = CommonSql.getProcessBatchQuery(tableMetaInfo.sourceFlagCode);
|
||||||
|
businessJdbcTemplate.update(sql, targetExecutionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.reader;
|
||||||
|
|
||||||
|
import com.snp.batch.common.util.CommonSql;
|
||||||
|
import com.snp.batch.common.util.TableMetaInfo;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.dto.Stat5CodeDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class Stat5CodeReader implements ItemReader<Stat5CodeDto> {
|
||||||
|
private final TableMetaInfo tableMetaInfo;
|
||||||
|
private final JdbcTemplate businessJdbcTemplate;
|
||||||
|
private List<Stat5CodeDto> allDataBuffer = new ArrayList<>();
|
||||||
|
|
||||||
|
public Stat5CodeReader(@Qualifier("businessDataSource") DataSource businessDataSource, TableMetaInfo tableMetaInfo) {
|
||||||
|
this.businessJdbcTemplate = new JdbcTemplate(businessDataSource);
|
||||||
|
this.tableMetaInfo = tableMetaInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stat5CodeDto read() throws Exception {
|
||||||
|
if (allDataBuffer.isEmpty()) {
|
||||||
|
fetchNextGroup();
|
||||||
|
}
|
||||||
|
if (allDataBuffer.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return allDataBuffer.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fetchNextGroup() {
|
||||||
|
Long nextTargetId = null;
|
||||||
|
try {
|
||||||
|
nextTargetId = businessJdbcTemplate.queryForObject(
|
||||||
|
CommonSql.getNextTargetQuery(tableMetaInfo.sourceStat5Code), Long.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextTargetId != null) {
|
||||||
|
log.info("[Stat5CodeReader] 다음 처리 대상 ID 발견: {}", nextTargetId);
|
||||||
|
String sql = CommonSql.getTargetDataQuery(tableMetaInfo.sourceStat5Code);
|
||||||
|
final Long targetId = nextTargetId;
|
||||||
|
this.allDataBuffer = businessJdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
|
return Stat5CodeDto.builder()
|
||||||
|
.jobExecutionId(targetId)
|
||||||
|
.lvOne(rs.getString("lv_one"))
|
||||||
|
.lvOneDesc(rs.getString("lv_one_desc"))
|
||||||
|
.lvTwo(rs.getString("lv_two"))
|
||||||
|
.lvTwoDesc(rs.getString("lv_two_desc"))
|
||||||
|
.lvThr(rs.getString("lv_thr"))
|
||||||
|
.lvThrDesc(rs.getString("lv_thr_desc"))
|
||||||
|
.lvFour(rs.getString("lv_four"))
|
||||||
|
.lvFourDesc(rs.getString("lv_four_desc"))
|
||||||
|
.lvFive(rs.getString("lv_five"))
|
||||||
|
.lvFiveDesc(rs.getString("lv_five_desc"))
|
||||||
|
.dtlDesc(rs.getString("dtl_desc"))
|
||||||
|
.rlsIem(rs.getString("rls_iem"))
|
||||||
|
.build();
|
||||||
|
}, nextTargetId);
|
||||||
|
updateBatchProcessing(nextTargetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateBatchProcessing(Long targetExecutionId) {
|
||||||
|
String sql = CommonSql.getProcessBatchQuery(tableMetaInfo.sourceStat5Code);
|
||||||
|
businessJdbcTemplate.update(sql, targetExecutionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.FlagCodeEntity;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.Stat5CodeEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeEntity Repository 인터페이스
|
||||||
|
* 구현체: CodeRepositoryImpl (JdbcTemplate 기반)
|
||||||
|
*/
|
||||||
|
public interface CodeRepository {
|
||||||
|
void saveFlagCode(List<FlagCodeEntity> flagCodeEntityList);
|
||||||
|
void saveStat5Code(List<Stat5CodeEntity> stat5CodeEntityList);
|
||||||
|
}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.common.batch.repository.MultiDataSourceJdbcRepository;
|
||||||
|
import com.snp.batch.common.util.TableMetaInfo;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.FlagCodeEntity;
|
||||||
|
import com.snp.batch.jobs.datasync.batch.code.entity.Stat5CodeEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeEntity Repository (JdbcTemplate 기반)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Repository("codeRepository")
|
||||||
|
public class CodeRepositoryImpl extends MultiDataSourceJdbcRepository<FlagCodeEntity, Long> implements CodeRepository {
|
||||||
|
|
||||||
|
private DataSource batchDataSource;
|
||||||
|
private DataSource businessDataSource;
|
||||||
|
private final TableMetaInfo tableMetaInfo;
|
||||||
|
|
||||||
|
public CodeRepositoryImpl(@Qualifier("batchDataSource") DataSource batchDataSource,
|
||||||
|
@Qualifier("businessDataSource") DataSource businessDataSource,
|
||||||
|
TableMetaInfo tableMetaInfo) {
|
||||||
|
|
||||||
|
super(new JdbcTemplate(batchDataSource), new JdbcTemplate(businessDataSource));
|
||||||
|
|
||||||
|
this.batchDataSource = batchDataSource;
|
||||||
|
this.businessDataSource = businessDataSource;
|
||||||
|
this.tableMetaInfo = tableMetaInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getTableName() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected RowMapper<FlagCodeEntity> getRowMapper() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Long extractId(FlagCodeEntity entity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getInsertSql() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getUpdateSql() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setInsertParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setUpdateParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getEntityName() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveFlagCode(List<FlagCodeEntity> flagCodeEntityList) {
|
||||||
|
String sql = CodeSql.getFlagCodeUpsertSql(tableMetaInfo.targetTbShipCountryCd);
|
||||||
|
if (flagCodeEntityList == null || flagCodeEntityList.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug("{} 배치 삽입 시작: {} 건", "FlagCodeEntity", flagCodeEntityList.size());
|
||||||
|
|
||||||
|
batchJdbcTemplate.batchUpdate(sql, flagCodeEntityList, flagCodeEntityList.size(),
|
||||||
|
(ps, entity) -> {
|
||||||
|
try {
|
||||||
|
bindFlagCode(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("배치 삽입 파라미터 설정 실패", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("{} 배치 삽입 완료: {} 건", "FlagCodeEntity", flagCodeEntityList.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bindFlagCode(PreparedStatement pstmt, FlagCodeEntity entity) throws Exception {
|
||||||
|
int idx = 1;
|
||||||
|
pstmt.setString(idx++, "SYSTEM"); // 1. creatr_id
|
||||||
|
pstmt.setString(idx++, entity.getDatasetVer()); // 2. dataset_ver
|
||||||
|
pstmt.setString(idx++, entity.getShipCountryCd()); // 3. ship_country_cd
|
||||||
|
pstmt.setString(idx++, entity.getCdNm()); // 4. cd_nm
|
||||||
|
pstmt.setString(idx++, entity.getIsoTwoCd()); // 5. iso_two_cd
|
||||||
|
pstmt.setString(idx++, entity.getIsoThrCd()); // 6. iso_thr_cd
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveStat5Code(List<Stat5CodeEntity> stat5CodeEntityList) {
|
||||||
|
String sql = CodeSql.getStat5CodeUpsertSql(tableMetaInfo.targetTbShipTypeCd);
|
||||||
|
if (stat5CodeEntityList == null || stat5CodeEntityList.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug("{} 배치 삽입 시작: {} 건", "Stat5CodeEntity", stat5CodeEntityList.size());
|
||||||
|
|
||||||
|
batchJdbcTemplate.batchUpdate(sql, stat5CodeEntityList, stat5CodeEntityList.size(),
|
||||||
|
(ps, entity) -> {
|
||||||
|
try {
|
||||||
|
bindStat5Code(ps, entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("배치 삽입 파라미터 설정 실패", e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("{} 배치 삽입 완료: {} 건", "Stat5CodeEntity", stat5CodeEntityList.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bindStat5Code(PreparedStatement pstmt, Stat5CodeEntity entity) throws Exception {
|
||||||
|
int idx = 1;
|
||||||
|
pstmt.setString(idx++, "SYSTEM"); // 1. creatr_id
|
||||||
|
pstmt.setString(idx++, entity.getLvOne()); // 2. lv_one
|
||||||
|
pstmt.setString(idx++, entity.getLvOneDesc()); // 3. lv_one_desc
|
||||||
|
pstmt.setString(idx++, entity.getLvTwo()); // 4. lv_two
|
||||||
|
pstmt.setString(idx++, entity.getLvTwoDesc()); // 5. lv_two_desc
|
||||||
|
pstmt.setString(idx++, entity.getLvThr()); // 6. lv_thr
|
||||||
|
pstmt.setString(idx++, entity.getLvThrDesc()); // 7. lv_thr_desc
|
||||||
|
pstmt.setString(idx++, entity.getLvFour()); // 8. lv_four
|
||||||
|
pstmt.setString(idx++, entity.getLvFourDesc()); // 9. lv_four_desc
|
||||||
|
pstmt.setString(idx++, entity.getLvFive()); // 10. lv_five
|
||||||
|
pstmt.setString(idx++, entity.getLvFiveDesc()); // 11. lv_five_desc
|
||||||
|
pstmt.setString(idx++, entity.getDtlDesc()); // 12. dtl_desc
|
||||||
|
pstmt.setString(idx++, entity.getRlsIem()); // 13. rls_iem
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.snp.batch.jobs.datasync.batch.code.repository;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CodeSql {
|
||||||
|
private static String TARGET_SCHEMA;
|
||||||
|
public CodeSql(@Value("${app.batch.target-schema.name}") String targetSchema) {
|
||||||
|
TARGET_SCHEMA = targetSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getFlagCodeUpsertSql(String targetTable) {
|
||||||
|
return """
|
||||||
|
INSERT INTO %s.%s (
|
||||||
|
crt_dt, creatr_id,
|
||||||
|
dataset_ver, ship_country_cd, cd_nm, iso_two_cd, iso_thr_cd
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
CURRENT_TIMESTAMP, ?,
|
||||||
|
?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (ship_country_cd)
|
||||||
|
DO UPDATE SET
|
||||||
|
mdfcn_dt = CURRENT_TIMESTAMP,
|
||||||
|
mdfr_id = 'SYSTEM',
|
||||||
|
dataset_ver = EXCLUDED.dataset_ver,
|
||||||
|
cd_nm = EXCLUDED.cd_nm,
|
||||||
|
iso_two_cd = EXCLUDED.iso_two_cd,
|
||||||
|
iso_thr_cd = EXCLUDED.iso_thr_cd;
|
||||||
|
""".formatted(TARGET_SCHEMA, targetTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getStat5CodeUpsertSql(String targetTable) {
|
||||||
|
return """
|
||||||
|
INSERT INTO %s.%s (
|
||||||
|
crt_dt, creatr_id,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
CURRENT_TIMESTAMP, ?,
|
||||||
|
?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (lv_one, lv_two, lv_thr, lv_four, lv_five)
|
||||||
|
DO UPDATE SET
|
||||||
|
mdfcn_dt = CURRENT_TIMESTAMP,
|
||||||
|
mdfr_id = 'SYSTEM',
|
||||||
|
lv_one_desc = EXCLUDED.lv_one_desc,
|
||||||
|
lv_two_desc = EXCLUDED.lv_two_desc,
|
||||||
|
lv_thr_desc = EXCLUDED.lv_thr_desc,
|
||||||
|
lv_four_desc = EXCLUDED.lv_four_desc,
|
||||||
|
lv_five_desc = EXCLUDED.lv_five_desc,
|
||||||
|
dtl_desc = EXCLUDED.dtl_desc,
|
||||||
|
rls_iem = EXCLUDED.rls_iem;
|
||||||
|
""".formatted(TARGET_SCHEMA, targetTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user