Compare commits

..

27 Commits

Author SHA1 Message Date
861fac88f7 Merge pull request 'feature/lucy-components' (#10) from feature/lucy-components into develop
Reviewed-on: #10
2026-04-14 10:40:14 +09:00
8d6bff88d6 feat: storybook 추가 2026-04-14 10:38:36 +09:00
52f8c30b2e feat: 공통 컴포넌트 추가 생성 2026-04-13 15:27:25 +09:00
01c3590682 Merge pull request 'feat: storybook 생성' (#9) from feature/lucy-storybook into develop
Reviewed-on: #9
2026-04-13 14:15:28 +09:00
01bfc751f2 feat: storybook 생성 2026-04-13 14:15:00 +09:00
b390777af0 Merge pull request 'feature/lucy-aerial-register' (#8) from feature/lucy-aerial-register into develop
Reviewed-on: #8
2026-04-13 10:03:58 +09:00
8b33161284 feat: 항공영상 업로드 모달 2026-04-10 12:24:10 +09:00
35f6023f5a feat: 컴포넌트 추가, 스타일 수정 2026-04-10 12:23:48 +09:00
a836333512 chore: lint 수정, package 추가 2026-04-10 12:23:07 +09:00
57a6e4b51e Merge pull request 'feat: 항공영상관리 api 함수 임시 구현' (#7) from feature/lucy-aerial into develop
Reviewed-on: #7
2026-04-10 08:57:20 +09:00
5c4df5a19a Merge branch 'develop' into feature/lucy-aerial 2026-04-10 08:51:27 +09:00
ec281bf354 Merge remote-tracking branch 'origin/develop' into develop 2026-04-09 17:43:46 +09:00
9cd5ff309f 샘플코드 추가 2026-04-09 17:43:36 +09:00
839901d663 feat: 항공영상관리 2026-04-09 17:03:06 +09:00
76dab1c6a9 feat: 항공영상관리 api 함수 임시 구현 2026-04-09 16:05:49 +09:00
df7c877319 Merge pull request 'feature/lucy-component' (#6) from feature/lucy-component into develop
Reviewed-on: #6
2026-04-09 16:04:46 +09:00
46dc4fdf73 feat: Table, Section 컴포넌트 추가 2026-04-09 11:46:41 +09:00
34d5a56a80 feat: button, calendar, datePicker, input, pagination, icons 추가 2026-04-09 11:02:18 +09:00
6da730f014 fix: 메뉴 상수 수정, 레이아웃 스타일 수정 2026-04-09 10:52:46 +09:00
9b9fb24028 .gitkeep 추가 2026-04-09 10:15:40 +09:00
db109dfe9a readme 수정 2026-04-09 09:22:33 +09:00
42cd85f8e5 패키지 구조 수정 및 추가 2026-04-08 17:13:45 +09:00
2d0834472d Merge pull request 'feat: 기본 레이아웃 설정, 스타일 설정, 메뉴 세팅 (ui는 kamco 프로젝트 기반)' (#5) from feature/lucy-menu into develop
Reviewed-on: #5
2026-04-08 16:58:38 +09:00
83ad885641 feat: 기본 레이아웃 설정, 스타일 설정, 메뉴 세팅 (ui는 kamco 프로젝트 기반) 2026-04-08 16:50:15 +09:00
68dccc10aa 패키지 구조 수정 및 추가 2026-04-08 16:40:27 +09:00
8a24288f1e Merge remote-tracking branch 'origin/develop' into develop 2026-04-08 16:30:40 +09:00
49447c9065 패키지 구조 수정 및 추가 2026-04-08 16:30:28 +09:00
177 changed files with 9145 additions and 302 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/api-app/app/src/main/resources/application-local.yml
api-app/.gradle
api-app/build

View File

@@ -2,10 +2,15 @@
> Dabeeo 변화 탐지 시스템을 위한 백엔드 API 서버
---
## 📋 프로젝트 소개
**dabeeo-detection-api**는 공간 데이터의 변화를 탐지하고 관리하기 위한 RESTful API 서버입니다.
JTS(Java Topology Suite)를 활용한 지오메트리 데이터 처리와 PostgreSQL을 통한 공간 데이터 저장을 지원합니다.
**dabeeo-detection-api**
본 프로젝트는 영상 데이터를 기반으로 변화 탐지를 수행하는 시스템의 백엔드 API 서버입니다.
---
## 🛠️ 기술 스택
@@ -20,8 +25,8 @@ JTS(Java Topology Suite)를 활용한 지오메트리 데이터 처리와 Postgr
| **Connection Pool** | HikariCP |
| **Build Tool** | Gradle 8.x |
| **Monitoring** | Spring Boot Actuator |
| **Container** | Docker + Docker Compose |
| **CI/CD** | Jenkins |
---
## 🚀 시작하기
@@ -32,16 +37,20 @@ JTS(Java Topology Suite)를 활용한 지오메트리 데이터 처리와 Postgr
- Gradle 8.x (또는 Gradle Wrapper 사용)
- Docker & Docker Compose (선택사항)
---
### 로컬 환경 설정
1. **저장소 클론**
```
#### 1. 저장소 클론
https://kamco.git.gs.dabeeo.com/MVPTeam/DABEEO-DETECTION-APPLICATION.git
```
2. **데이터베이스 설정**
---
PostgreSQL 데이터베이스를 준비하고 `src/main/resources/application-local.yml`을 생성:
#### 2. 데이터베이스 설정
PostgreSQL 데이터베이스를 준비하고
`src/main/resources/application-local.yml` 파일을 생성합니다.
```yaml
spring:
@@ -54,9 +63,11 @@ spring:
password: your_password
```
> **참고**: `application-local.yml`은 `.gitignore`에 포함되어 있어 Git에 커밋되지 않습니다.
> ⚠️ `application-local.yml`은 `.gitignore`에 포함되어 Git에 커밋되지 않습니다.
3. **빌드 및 실행**
---
#### 3. 빌드 및 실행
```bash
# 빌드
@@ -65,56 +76,157 @@ spring:
# 실행 (local 프로파일)
./gradlew bootRun
# 또는 JAR 파일로 실행
# 또는 JAR 실행
java -jar build/libs/ROOT.jar
```
서버가 시작되면 http://localhost:8080 에서 접근 가능합니다.
서버 실행 후:
http://localhost:8080
---
## ⚙️ 프로파일 설정
애플리케이션은 환경별로 다른 설정을 사용합니다:
| 프로파일 | 환경 | 포트 | 설정 파일 |
|---------|------|------|-----------|
| `local` | 로컬 개발 | 8080 | `application.yml` (기본) |
| `dev` | 개발 서버 | 7100 | `application-dev.yml` |
| `local` | 로컬 개발 | 8080 | `application.yml` |
| `dev` | 개발 서버 | 8080 | `application-dev.yml` |
| `prod` | 운영 서버 | 8080 | `application-prod.yml` |
### 프로파일 활성화
---
### 프로파일 실행 방법
```bash
# 개발 환경으로 실행
# dev 실행
./gradlew bootRun --args='--spring.profiles.active=dev'
# JAR 실행
# jar 실행
java -jar build/libs/ROOT.jar --spring.profiles.active=dev
```
---
## 🧪 테스트
```bash
# 전체 테스트 실행
# 전체 테스트
./gradlew test
# 특정 테스트 클래스 실행
# 특정 테스트
./gradlew test --tests com.kamco.cd.kamcoback.KamcoBackApplicationTests
# 테스트 리포트 확인
# 리포트 확인
open build/reports/tests/test/index.html
```
---
## 📦 빌드
```bash
# 전체 빌드 (테스트 포함)
# 전체 빌드
./gradlew clean build
# 테스트 제외 빌드 (CI/CD에서 사용)
# 테스트 제외
./gradlew clean build -x test
# JAR 파일만 생성
# JAR 생성
./gradlew bootJar
```
빌드된 JAR 파일: `build/libs/ROOT.jar`
빌드 결과:
build/libs/ROOT.jar
---
## 📁 프로젝트 구조
### (2026-04-08 기준)
```
api-app # 메인 실행 모듈
├── app
│ ├── src/main
│ │ ├── java/com/cd/detection
│ │ │ ├── DabeeoDetectionApiApplication.java
│ │ │
│ │ │ ├── config # 설정 파일
│ │ │ ├── db
│ │ │ │ ├── core # core service (Repository 비즈니스 서비스)
│ │ │ │ └── repository # Repository (DB 인터페이스/구현)
│ │ │ ├── core
│ │ │ ├── domain # 도메인
│ │ │ │ ├── imagery # 영상 데이터 관리
│ │ │ │ ├── labeling # 라벨링 툴
│ │ │ │ ├── label # 라벨링 관리
│ │ │ │ ├── model # 모델관리
│ │ │ │ ├── inference # 추론관리
│ │ │ │ ├── system # 시스템관리
│ │ │ │ └── log # 로그관리
│ │ │ ├── entity # 공용 JPA 엔티티 (DB 공통 사용)
│ │ │
│ │ └── resources # 설정 및 리소스 파일
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ └── application-prod.yml
│ │
│ └── build.gradle
├── infrastructure-db-postgres # Postgres 전용 설정/확장
│ └── build.gradle
├── build.gradle
├── settings.gradle
├── gradle/
└── gradlew
```
---
## 🧩 아키텍처 설계
본 프로젝트는 API 모듈과 DB 모듈을 분리한 멀티 모듈 구조를 사용합니다.
---
### 📌 모듈 구성
| 모듈 | 설명 |
|------|------|
| api-app | API 및 비즈니스 로직 |
| infrastructure-db-postgres | DB 접근 및 설정 |
---
## 🗄️ Database Module 분리 전략
DB 관련 로직은 별도의 모듈(infrastructure-db-postgres)로 분리되어 관리됩니다.
---
### 🎯 분리 목적
- 기술 의존성 분리
- DB 교체 용이성
- 관심사 분리
- 확장성 확보
---
### 🔄 Database 교체
- `api-app/build.gradle`에서 DB 모듈 의존성 변경
(`infrastructure-db-postgres``infrastructure-db-oracle`)
- `application.yml` 또는 DB별 설정 파일(`application-postgres.yml`, `application-oracle.yml`)에서
데이터베이스 연결 정보 변경
- 실행 시 profile을 통해 DB 선택
```bash
# PostgreSQL
--spring.profiles.active=postgres
# Oracle
--spring.profiles.active=oracle

41
api-app/app/build.gradle Normal file
View File

@@ -0,0 +1,41 @@
plugins {
id 'org.springframework.boot'
id 'java'
}
dependencies {
// DB 변경시 변경
implementation project(':infrastructure-db-postgres')
// DB (테스트용)
runtimeOnly 'com.h2database:h2'
// Spring
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// OpenAPI
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
// QueryDSL
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Devtools
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// QueryDSL APT
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
bootJar {
archiveFileName = 'ROOT.jar'
}

View File

@@ -2,6 +2,9 @@ package com.cd.detection;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
public class DabeeoDetectionApiApplication {

View File

@@ -0,0 +1,40 @@
package com.cd.detection.config.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponseDto<T> {
private final T data;
private final Error error;
private ApiResponseDto(T data, Error error) {
this.data = data;
this.error = error;
}
// 성공
public static <T> ApiResponseDto<T> ok(T data) {
return new ApiResponseDto<>(data, null);
}
// 실패
public static <T> ApiResponseDto<T> fail(String code, String message) {
return new ApiResponseDto<>(null, new Error(code, message));
}
@Getter
public static class Error {
private final String code;
private final String message;
public Error(String code, String message) {
this.code = code;
this.message = message;
}
}
}

View File

@@ -0,0 +1,68 @@
package com.cd.detection.db.core;
import com.cd.detection.db.repository.zoo.AnimalRepository;
import com.cd.detection.domain.zoo.dto.AnimalDto;
import com.cd.detection.domain.zoo.dto.AnimalDto.AddRequest;
import com.cd.detection.domain.zoo.dto.AnimalDto.ModifyRequest;
import com.cd.detection.domain.zoo.dto.AnimalDto.SearchListRequest;
import com.cd.detection.entity.AnimalEntity;
import com.cd.detection.entity.ZooEntity;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AnimalCoreService {
private final AnimalRepository animalRepository;
@Transactional
public void saveZoo(AddRequest dto) {
ZooEntity zoo = new ZooEntity();
zoo.setZooId(dto.getZooId()); // FK만 세팅
AnimalEntity animalEntity = new AnimalEntity();
animalEntity.setZoo(zoo);
animalEntity.setAnimalName(dto.getAnimalName());
animalEntity.setSpecies(dto.getSpecies());
animalEntity.setAge(dto.getAge());
animalRepository.save(animalEntity);
}
@Transactional
public void updateZoo(ModifyRequest dto) {
AnimalEntity animalEntity = animalRepository.findById(dto.getAnimalId()).orElseThrow(() -> new EntityNotFoundException("Animal Id: " + dto.getAnimalId()));
animalEntity.setAnimalName(dto.getAnimalName());
animalEntity.setSpecies(dto.getSpecies());
animalEntity.setUseYn(dto.getUseYn());
animalRepository.save(animalEntity);
}
@Transactional
public void deleteZoo(ModifyRequest dto) {
AnimalEntity animalEntity = animalRepository.findById(dto.getAnimalId()).orElseThrow(() -> new EntityNotFoundException("Animal Id: " + dto.getAnimalId()));
animalEntity.setDelYn(dto.getDelYn());
animalRepository.save(animalEntity);
}
public Page<AnimalDto.Basic> getAnimals(SearchListRequest searchListRequest) {
Page<AnimalEntity> animals = animalRepository.findAllAnimals(searchListRequest);
return animals.map(a -> {
AnimalDto.Basic res = new AnimalDto.Basic();
res.setAnimalId(a.getAnimalId());
res.setAnimalName(a.getAnimalName());
res.setSpecies(a.getSpecies());
res.setAge(a.getAge() != null ? a.getAge().toString() : null);
res.setDelYn(a.getDelYn());
res.setUseYn(a.getUseYn());
return res;
});
}
}

View File

@@ -0,0 +1,74 @@
package com.cd.detection.db.core;
import com.cd.detection.db.repository.zoo.ZooRepository;
import com.cd.detection.domain.zoo.dto.ZooDto.AddRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.AnimalRes;
import com.cd.detection.domain.zoo.dto.ZooDto.ModifyRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.ZooWithAnimalsRes;
import com.cd.detection.entity.ZooEntity;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ZooCoreService {
private final ZooRepository zooRepository;
@Transactional
public void saveZoo(AddRequest dto) {
ZooEntity zooEntity = new ZooEntity();
zooEntity.setZooName(dto.getZooName());
zooEntity.setDescription(dto.getDescription());
zooRepository.save(zooEntity);
}
@Transactional
public void updateZoo(ModifyRequest dto) {
ZooEntity zooEntity = zooRepository.findById(dto.getId()).orElseThrow(() -> new EntityNotFoundException("Zoo Id: " + dto.getId()));
zooEntity.setZooName(dto.getZooName());
zooEntity.setDescription(dto.getDescription());
zooEntity.setUseYn(dto.getUseYn());
zooRepository.save(zooEntity);
}
@Transactional
public void deleteZoo(ModifyRequest dto) {
ZooEntity zooEntity = zooRepository.findById(dto.getId()).orElseThrow(() -> new EntityNotFoundException("Zoo Id: " + dto.getId()));
zooEntity.setDelYn(dto.getDelYn());
zooRepository.save(zooEntity);
}
public Page<ZooWithAnimalsRes> getZooWithAnimals(SearchListRequest searchListRequest) {
Page<ZooEntity> zoo = zooRepository.findAllWithAnimals(searchListRequest);
return zoo.map(z -> {
ZooWithAnimalsRes res = new ZooWithAnimalsRes();
res.setZooId(z.getZooId());
res.setZooName(z.getZooName());
res.setDescription(z.getDescription());
List<AnimalRes> animals = z.getAnimals().stream()
.map(a -> {
AnimalRes ar = new AnimalRes();
ar.setAnimalId(a.getAnimalId());
ar.setAnimalName(a.getAnimalName());
ar.setSpecies(a.getSpecies());
ar.setAge(a.getAge());
return ar;
})
.toList();
res.setAnimals(animals);
return res;
});
}
}

View File

@@ -0,0 +1,8 @@
package com.cd.detection.db.repository.zoo;
import com.cd.detection.entity.AnimalEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AnimalRepository extends JpaRepository<AnimalEntity, Long>, AnimalRepositoryCustom {
}

View File

@@ -0,0 +1,10 @@
package com.cd.detection.db.repository.zoo;
import com.cd.detection.domain.zoo.dto.AnimalDto;
import com.cd.detection.entity.AnimalEntity;
import java.util.List;
import org.springframework.data.domain.Page;
public interface AnimalRepositoryCustom {
Page<AnimalEntity> findAllAnimals(AnimalDto.SearchListRequest dto);
}

View File

@@ -0,0 +1,46 @@
package com.cd.detection.db.repository.zoo;
import com.cd.detection.domain.zoo.dto.AnimalDto.SearchListRequest;
import com.cd.detection.entity.AnimalEntity;
import com.cd.detection.entity.QAnimalEntity;
import com.cd.detection.entity.QZooEntity;
import com.cd.detection.entity.ZooEntity;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@Repository
public class AnimalRepositoryImpl implements AnimalRepositoryCustom {
private final JPAQueryFactory queryFactory;
public AnimalRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
@Override
public Page<AnimalEntity> findAllAnimals(SearchListRequest searchListRequest) {
Pageable pageable = searchListRequest.toPageable();
QAnimalEntity animal = QAnimalEntity.animalEntity;
// content 조회
List<AnimalEntity> content = queryFactory
.selectFrom(animal)
.where(animal.delYn.eq(false))
.distinct()
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// count 조회 (fetchJoin 제거)
Long total = queryFactory
.select(animal.count())
.from(animal)
.where(animal.delYn.eq(false))
.fetchOne();
return new PageImpl<>(content, pageable, total == null ? 0L : total);
}
}

View File

@@ -0,0 +1,8 @@
package com.cd.detection.db.repository.zoo;
import com.cd.detection.entity.ZooEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ZooRepository extends JpaRepository<ZooEntity, Long>, ZooRepositoryCustom {
}

View File

@@ -0,0 +1,11 @@
package com.cd.detection.db.repository.zoo;
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
import com.cd.detection.entity.ZooEntity;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
public interface ZooRepositoryCustom {
Page<ZooEntity> findAllWithAnimals(SearchListRequest searchListRequest);
}

View File

@@ -0,0 +1,45 @@
package com.cd.detection.db.repository.zoo;
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
import com.cd.detection.entity.QZooEntity;
import com.cd.detection.entity.ZooEntity;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@Repository
public class ZooRepositoryImpl implements ZooRepositoryCustom {
private final JPAQueryFactory queryFactory;
public ZooRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
@Override
public PageImpl<ZooEntity> findAllWithAnimals(SearchListRequest searchListRequest) {
Pageable pageable = searchListRequest.toPageable();
QZooEntity zoo = QZooEntity.zooEntity;
// content 조회
List<ZooEntity> content = queryFactory
.selectFrom(zoo)
.leftJoin(zoo.animals).fetchJoin()
.where(zoo.delYn.eq(false))
.distinct()
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// count 조회 (fetchJoin 제거)
Long total = queryFactory
.select(zoo.count())
.from(zoo)
.where(zoo.delYn.eq(false))
.fetchOne();
return new PageImpl<>(content, pageable, total == null ? 0L : total);
}
}

View File

@@ -0,0 +1,125 @@
package com.cd.detection.domain.zoo;
import com.cd.detection.config.api.ApiResponseDto;
import com.cd.detection.domain.zoo.dto.AnimalDto;
import com.cd.detection.domain.zoo.service.AnimalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
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 org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "동물", description = "동물 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/animal")
public class AnimalController {
private final AnimalService animalService;
@Operation(summary = "동물 목록", description = "동물 목록 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "검색 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/list")
public ApiResponseDto<Page<AnimalDto.Basic>> getZooWithAnimals(
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
@RequestParam(defaultValue = "0")
int page,
@Parameter(description = "페이지 크기", example = "20")
@RequestParam(defaultValue = "20")
int size) {
AnimalDto.SearchListRequest searchListRequest = new AnimalDto.SearchListRequest();
searchListRequest.setPage(page);
searchListRequest.setSize(size);
return ApiResponseDto.ok(animalService.getAnimals(searchListRequest));
}
@Operation(summary = "동물 등록", description = "동물 등록 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "등록 성공",
content = @Content(
mediaType = "text/plain",
schema = @Schema(type = "string", example = "success")
)),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping
public ApiResponseDto<String> saveAnimal(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
content = @Content(
schema = @Schema(implementation = AnimalDto.AddRequest.class)
)
)
@RequestBody AnimalDto.AddRequest dto) {
animalService.saveAnimal(dto);
return ApiResponseDto.ok("success");
}
@Operation(summary = "동물 수정", description = "동물 수정 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "수정 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PutMapping
public ApiResponseDto<String> updateAnimal(@RequestBody AnimalDto.ModifyRequest dto) {
animalService.updateAnimal(dto);
return ApiResponseDto.ok("success");
}
@Operation(summary = "동물 삭제", description = "동물 삭제 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "삭제 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping
public ApiResponseDto<String> deleteAnimal(@RequestBody AnimalDto.ModifyRequest dto) {
animalService.deleteAnimal(dto);
return ApiResponseDto.ok("success");
}
}

View File

@@ -0,0 +1,121 @@
package com.cd.detection.domain.zoo;
import com.cd.detection.config.api.ApiResponseDto;
import com.cd.detection.domain.zoo.dto.ZooDto;
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.ZooWithAnimalsRes;
import com.cd.detection.domain.zoo.service.ZooService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
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 org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "동물원", description = "동물원 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/zoo")
public class ZooController {
private final ZooService zooService;
@Operation(summary = "동물원 목록", description = "동물원 목록 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "검색 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/list")
public ApiResponseDto<Page<ZooWithAnimalsRes>> getZooWithAnimals(
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
@RequestParam(defaultValue = "0")
int page,
@Parameter(description = "페이지 크기", example = "20")
@RequestParam(defaultValue = "20")
int size) {
SearchListRequest searchListRequest = new SearchListRequest();
searchListRequest.setPage(page);
searchListRequest.setSize(size);
return ApiResponseDto.ok(zooService.getZooWithAnimals(searchListRequest));
}
@Operation(summary = "동물원 등록", description = "동물원 등록 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "등록 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping
public ApiResponseDto<String> saveZoo(@RequestBody ZooDto.AddRequest dto) {
zooService.saveZoo(dto);
return ApiResponseDto.ok("success");
}
@Operation(summary = "동물원 수정", description = "동물원 수정 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "수정 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PutMapping
public ApiResponseDto<String> updateZoo(@RequestBody ZooDto.ModifyRequest dto) {
zooService.updateZoo(dto);
return ApiResponseDto.ok("success");
}
@Operation(summary = "동물원 삭제", description = "동물원 삭제 ")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "삭제 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping
public ApiResponseDto<String> deleteZoo(@RequestBody ZooDto.ModifyRequest dto) {
zooService.deleteZoo(dto);
return ApiResponseDto.ok("success");
}
}

View File

@@ -0,0 +1,78 @@
package com.cd.detection.domain.zoo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
public class AnimalDto {
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchListRequest {
// 페이징 파라미터
private int page = 0;
private int size = 20;
public Pageable toPageable() {
return PageRequest.of(page, size);
}
}
@Getter
@Setter
public static class AddRequest {
@Schema(description = "동물원 id")
private Long zooId;
@Schema(description = "동물명")
private String animalName;
@Schema(description = "")
private String species;
@Schema(description = "나이")
private Integer age;
}
@Getter
@Setter
public static class ModifyRequest {
@Schema(description = "동물 id")
private Long animalId;
@Schema(description = "동물명")
private String animalName;
@Schema(description = "")
private String species;
@Schema(description = "나이")
private String age;
@Schema(description = "삭제여부")
private Boolean delYn;
@Schema(description = "사용여부")
private Boolean useYn;
}
@Getter
@Setter
public static class Basic {
@Schema(description = "동물 id")
private Long animalId;
@Schema(description = "동물명")
private String animalName;
@Schema(description = "")
private String species;
@Schema(description = "나이")
private String age;
@Schema(description = "삭제여부")
private Boolean delYn;
@Schema(description = "사용여부")
private Boolean useYn;
}
}

View File

@@ -0,0 +1,66 @@
package com.cd.detection.domain.zoo.dto;
import java.time.LocalDate;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
public class ZooDto {
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchListRequest {
// 페이징 파라미터
private int page = 0;
private int size = 20;
public Pageable toPageable() {
return PageRequest.of(page, size);
}
}
@Getter
@Setter
public static class AddRequest {
private String zooName;
private String description;
}
@Getter
@Setter
public static class ModifyRequest {
private Long id;
private String zooName;
private String description;
private Boolean useYn;
private Boolean delYn;
}
@Getter
@Setter
public static class ZooWithAnimalsRes {
private Long zooId; // 동물원 ID
private String zooName; // 동물원 이름
private String description; // 설명
private List<AnimalRes> animals;
}
@Getter
@Setter
public static class AnimalRes {
private Long animalId;
private String animalName;
private String species;
private Integer age;
}
}

View File

@@ -0,0 +1,32 @@
package com.cd.detection.domain.zoo.service;
import com.cd.detection.db.core.AnimalCoreService;
import com.cd.detection.domain.zoo.dto.AnimalDto;
import com.cd.detection.domain.zoo.dto.AnimalDto.Basic;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AnimalService {
private final AnimalCoreService animalCoreService;
public Page<Basic> getAnimals(AnimalDto.SearchListRequest searchListRequest) {
return animalCoreService.getAnimals(searchListRequest);
}
public void saveAnimal(AnimalDto.AddRequest dto) {
animalCoreService.saveZoo(dto);
}
public void updateAnimal(AnimalDto.ModifyRequest dto) {
animalCoreService.updateZoo(dto);
}
public void deleteAnimal(AnimalDto.ModifyRequest dto) {
animalCoreService.deleteZoo(dto);
}
}

View File

@@ -0,0 +1,37 @@
package com.cd.detection.domain.zoo.service;
import com.cd.detection.db.core.ZooCoreService;
import com.cd.detection.domain.zoo.dto.ZooDto.AddRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.ModifyRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
import com.cd.detection.domain.zoo.dto.ZooDto.ZooWithAnimalsRes;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ZooService {
private final ZooCoreService zooCoreService;
// 동물원, 동물 조회
public Page<ZooWithAnimalsRes> getZooWithAnimals(SearchListRequest searchListRequest) {
return zooCoreService.getZooWithAnimals(searchListRequest);
}
// 동물원 저장
public void saveZoo(AddRequest dto) {
zooCoreService.saveZoo(dto);
}
// 동물원 수정
public void updateZoo(ModifyRequest dto) {
zooCoreService.updateZoo(dto);
}
// 동물원 삭제
public void deleteZoo(ModifyRequest dto) {
zooCoreService.deleteZoo(dto);
}
}

View File

@@ -0,0 +1,56 @@
package com.cd.detection.entity;
import jakarta.persistence.*;
import java.time.ZonedDateTime;
import lombok.*;
import org.hibernate.annotations.Comment;
@Entity
@Getter
@Setter
@Table(name = "tb_animal")
@Comment("동물 정보")
public class AnimalEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "animal_id")
@Comment("동물 ID")
private Long animalId;
/**
* 동물원 엔티티 (FK)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "zoo_id", nullable = false)
@Comment("동물원 ID (FK)")
private ZooEntity zoo;
@Column(name = "animal_name", nullable = false)
@Comment("동물 이름")
private String animalName;
@Column(name = "species")
@Comment("")
private String species;
@Column(name = "age")
@Comment("나이")
private Integer age;
@Column(name = "use_yn", nullable = false)
@Comment("사용 여부")
private Boolean useYn = true;
@Column(name = "del_yn", nullable = false)
@Comment("삭제 여부")
private Boolean delYn = false;
@Column(name = "created_dttm", nullable = false)
@Comment("생성 일시")
private ZonedDateTime createdDttm = ZonedDateTime.now();
@Column(name = "updated_dttm", nullable = false)
@Comment("수정 일시")
private ZonedDateTime updatedDttm = ZonedDateTime.now();
}

View File

@@ -0,0 +1,62 @@
package com.cd.detection.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Comment;
@Getter
@Setter
@Entity
@Table(name = "tb_zoo")
public class ZooEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "zoo_id")
@Comment("동물원 ID")
private Long zooId;
@OneToMany(mappedBy = "zoo", fetch = FetchType.LAZY)
private List<AnimalEntity> animals = new ArrayList<>();
@Column(name = "zoo_name", nullable = false, length = 200)
@Comment("동물원 이름")
private String zooName;
@Column(name = "description")
@Comment("설명")
private String description;
@Column(name = "use_yn", nullable = false)
@Comment("사용 여부")
private Boolean useYn = true;
@Column(name = "del_yn", nullable = false)
@Comment("삭제 여부")
private Boolean delYn = false;
@Column(name = "created_dttm", nullable = false, updatable = false)
@Comment("생성 일시")
private ZonedDateTime createdDttm = ZonedDateTime.now();
@Column(name = "updated_dttm", nullable = false)
@Comment("수정 일시")
private ZonedDateTime updatedDttm = ZonedDateTime.now();
public void addAnimal(AnimalEntity animal) {
animals.add(animal);
animal.setZoo(this);
}
}

View File

@@ -0,0 +1,15 @@
spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true

View File

@@ -0,0 +1,15 @@
spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true

View File

@@ -0,0 +1,20 @@
spring:
profiles:
active: local
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
management:
endpoints:
web:
base-path: /monitor
endpoint:
health:
probes:
enabled: true

View File

@@ -1,47 +1,30 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.7'
id 'io.spring.dependency-management' version '1.1.7'
id 'org.springframework.boot' version '3.5.7' apply false
id 'io.spring.dependency-management' version '1.1.7' apply false
}
group = 'com.cd.detection'
version = '0.0.1-SNAPSHOT'
description = 'dabeeo-detection-api'
allprojects {
group = 'com.cd.detection'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
repositories {
mavenCentral()
}
}
repositories {
mavenCentral()
}
subprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
bootJar {
archiveFileName = 'ROOT.jar'
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.5.7"
}
}
}

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

7
api-app/gradlew vendored
View File

@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,6 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -171,6 +172,7 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -210,6 +212,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"

3
api-app/gradlew.bat vendored
View File

@@ -70,10 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@@ -0,0 +1,8 @@
plugins {
id 'java'
}
dependencies {
// DB 드라이버
implementation 'org.postgresql:postgresql:42.7.3'
}

View File

@@ -1 +1,4 @@
rootProject.name = 'dabeeo-detection-api'
include 'app'
include 'infrastructure-db-postgres'

View File

@@ -1,21 +0,0 @@
package com.cd.detection.postgres.core;
import com.cd.detection.postgres.entity.SampleEntity;
import com.cd.detection.postgres.repository.sample.SampleRepository;
import com.cd.detection.sample.dto.SampleDto;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SampleCoreService {
private final SampleRepository sampleRepository;
public List<SampleDto> getSampleList() {
return sampleRepository.getSampleList()
.stream()
.map(SampleEntity::toDto)
.toList();
}
}

View File

@@ -1,25 +0,0 @@
package com.cd.detection.postgres.entity;
import com.cd.detection.sample.dto.SampleDto;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "sample_table")
@Getter
@NoArgsConstructor
public class SampleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public SampleDto toDto() {
return new SampleDto(
this.id,
this.name);
}
}

View File

@@ -1,8 +0,0 @@
package com.cd.detection.postgres.repository.sample;
import com.cd.detection.postgres.entity.SampleEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SampleRepository extends JpaRepository<SampleEntity, Long>, SampleRepositoryCustom {
}

View File

@@ -1,14 +0,0 @@
package com.cd.detection.postgres.repository.sample;
import com.cd.detection.postgres.entity.SampleEntity;
import java.util.List;
import java.util.Optional;
public interface SampleRepositoryCustom {
// 조회
Optional<SampleEntity> getSample(Long id);
// 리스트 조회
List<SampleEntity> getSampleList();
}

View File

@@ -1,28 +0,0 @@
package com.cd.detection.postgres.repository.sample;
import com.cd.detection.postgres.entity.QSampleEntity;
import com.cd.detection.postgres.entity.SampleEntity;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class SampleRepositoryImpl implements SampleRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Optional<SampleEntity> getSample(Long id) {
QSampleEntity sample = QSampleEntity.sampleEntity;
return Optional.ofNullable(queryFactory.selectFrom(sample).where(sample.id.eq(id)).fetchOne());
}
@Override
public List<SampleEntity> getSampleList() {
QSampleEntity sample = QSampleEntity.sampleEntity;
return queryFactory.selectFrom(sample).fetch();
}
}

View File

@@ -1,40 +0,0 @@
package com.cd.detection.sample;
import com.cd.detection.sample.dto.SampleDto;
import com.cd.detection.sample.service.SampleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sample")
public class SampleController {
private final SampleService sampleService;
@Operation(summary = "샘플 목록 조회", description = "샘플 목록 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = SampleDto.class))),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping
public List<SampleDto> getSampleList() {
return sampleService.getSampleList();
}
}

View File

@@ -1,16 +0,0 @@
package com.cd.detection.sample.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SampleDto {
private Long id;
private String name;
public SampleDto(Long id, String name) {
this.id = id;
this.name = name;
}
}

View File

@@ -1,19 +0,0 @@
package com.cd.detection.sample.service;
import com.cd.detection.postgres.core.SampleCoreService;
import com.cd.detection.postgres.repository.sample.SampleRepository;
import com.cd.detection.sample.dto.SampleDto;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SampleService {
private final SampleCoreService sampleCoreService;
public List<SampleDto> getSampleList() {
return sampleCoreService.getSampleList();
}
}

View File

@@ -1,5 +0,0 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/dabeeo_detection_dev
username: dabeeo_detection
password: 1234

View File

@@ -1,5 +0,0 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/dabeeo_detection_dev
username: dabeeo_detection
password: 1234

View File

@@ -1,5 +0,0 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/dabeeo_detection_dev
username: dabeeo_detection
password: 1234

View File

@@ -1,11 +0,0 @@
spring:
profiles:
active: local
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true

View File

@@ -0,0 +1,19 @@
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = {
stories: ['../app/**/*.stories.@(ts|tsx)'],
framework: '@storybook/react-vite',
// Storybook 전용 Vite 설정 파일 사용 (React Router 플러그인 제외)
core: {
builder: {
name: '@storybook/builder-vite',
options: {
viteConfigPath: '.storybook/vite.config.ts',
},
},
},
}
export default config

View File

@@ -0,0 +1,16 @@
import type { Preview } from '@storybook/react'
import '../app/app.css'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
}
export default preview

View File

@@ -0,0 +1,13 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// Storybook 전용 Vite 설정 (React Router 플러그인 제외)
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'~': new URL('../app', import.meta.url).pathname,
},
},
})

View File

@@ -1,9 +1,89 @@
@import "tailwindcss";
@import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
@font-face {
font-family: "Pretendard";
font-weight: 400;
font-style: normal;
src: url("~/shared/assets/fonts/Pretendard-Regular.subset.woff") format("woff");
font-display: swap;
}
@font-face {
font-family: "Pretendard";
font-weight: 500;
font-style: normal;
src: url("~/shared/assets/fonts/Pretendard-Medium.subset.woff") format("woff");
font-display: swap;
}
@font-face {
font-family: "Pretendard";
font-weight: 600;
font-style: normal;
src: url("~/shared/assets/fonts/Pretendard-SemiBold.subset.woff") format("woff");
font-display: swap;
}
@font-face {
font-family: "Pretendard";
font-weight: 700;
font-style: normal;
src: url("~/shared/assets/fonts/Pretendard-Bold.subset.woff") format("woff");
font-display: swap;
}
@theme {
--font-sans: "Pretendard Variable", ui-sans-serif, system-ui, sans-serif,
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--color-dabeeo-blue: #2768ff;
--color-dabeeo-red: #ff0000;
--color-dabeeo-yellow: #fffce8;
/* Primary (Navy) */
--color-primary: var(--color-dabeeo-navy-main);
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
--color-primary-tertiary: var(--color-dabeeo-navy-tertiary);
--color-primary-tertiary01: var(--color-dabeeo-navy-tertiary01);
--color-primary-tertiary02: var(--color-dabeeo-navy-tertiary02);
/* Navy */
--color-dabeeo-navy-main: #00387d;
--color-dabeeo-navy-secondary: #032651;
--color-dabeeo-navy-tertiary: #5c84b4;
--color-dabeeo-navy-tertiary01: #d4dde9;
--color-dabeeo-navy-tertiary02: #f0f3f7;
/* Green */
--color-dabeeo-green-main: #1b8466;
--color-dabeeo-green-secondary: #125a46;
--color-dabeeo-green-tertiary: #89bea7;
--color-dabeeo-green-tertiary01: #d1e6e1;
--color-dabeeo-green-tertiary02: #ebf2f0;
/* Orange */
--color-dabeeo-orange-main: #ff7937;
--color-dabeeo-orange-secondary: #c14b11;
--color-dabeeo-orange-tertiary01: #fff2eb;
--color-dabeeo-orange-tertiary02: #ffe4d7;
/* Yellow */
--color-dabeeo-yellow-main: #ffb724;
--color-dabeeo-yellow-secondary: #fffce8;
/* Gray */
--color-dabeeo-gray-44: #444444;
--color-dabeeo-gray-99: #999999;
--color-dabeeo-gray-be: #bebebe;
--color-dabeeo-gray-da: #dadada;
--color-dabeeo-gray-eb: #ebebeb;
--color-dabeeo-gray-f9: #f9f9f9;
--color-dabeeo-black-22: #222222;
--color-dabeeo-black-34: #343434;
--color-dabeeo-black-47: #475954;
--color-dabeeo-black-2a: #2a2e35;
--color-dabeeo-black-4d: #4d5562;
}
html,

View File

@@ -0,0 +1,87 @@
import axios from 'axios';
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
import type {
AerialData,
AerialDetail,
AerialItem,
AerialListParams,
} from '../types/aerial';
/**
* 항공영상 목록 조회
*/
export const fetchAerialList = async (params: AerialListParams) => {
try {
const response = await axios.get<ApiResponse<PagedResponse<AerialItem>>>('/api/imagery/aerial/list', {
params: {
dateRangeType: params.dateRangeType,
strtDttm: params.strtDttm,
endDttm: params.endDttm,
page: params.page ?? 0,
size: params.size ?? 20,
},
});
return {
list: response.data.data.content,
pagination: {
currentPage: response.data.data.number,
pageSize: response.data.data.size,
totalPages: response.data.data.totalPages,
totalItems: response.data.data.totalElements,
},
};
} catch (error) {
console.error('fetchAerialList error:', error);
throw error;
}
};
/**
* 항공영상 상세 요약정보 조회
*/
export const fetchAerialDetail = async (uuid: string) => {
try {
const response = await axios.get<ApiResponse<AerialDetail>>(`/api/imagery/aerial/detail/${uuid}`);
return response.data.data;
} catch (error) {
console.error('fetchAerialDetail error:', error);
throw error;
}
};
/**
* 항공영상 상세 영상 조회 (이미지 다운로드 URL 반환)
*/
export const getAerialImageUrl = (uuid: string, imageType: 'before' | 'after' = 'before') => {
return `/api/imagery/detail/image?uuid=${uuid}&imageType=${imageType}`;
};
/**
* 항공영상 상세 영상 조회 (Blob으로 다운로드)
*/
export const fetchAerialImage = async (uuid: string, imageType: 'before' | 'after' = 'before') => {
try {
const response = await axios.get('/api/imagery/detail/image', {
params: { uuid, imageType },
responseType: 'blob',
});
return response.data;
} catch (error) {
console.error('fetchAerialImage error:', error);
throw error;
}
};
/**
* 항공영상 데이터 등록
*/
export const registerAerialData = async (data: AerialData) => {
try {
const response = await axios.post('/api/imagery/aerial', data);
return response.data.data;
} catch (error) {
console.error('registerAerialData error:', error);
throw error;
}
}

View File

@@ -0,0 +1,74 @@
import axios from 'axios';
import type { ApiResponse } from '~/shared/types/api';
import type { ChunkUploadParams, ChunkUploadResponse, FolderListResponse, Region } from '../types/imageryRegister';
/**
* 업로드 지역 조회 (시/도)
*/
export const fetchRegions = async () => {
try {
const response = await axios.get<ApiResponse<Region[]>>('/api/imagery/regions/provinces');
return response.data.data;
} catch (error) {
console.error('fetchRegions error:', error);
throw error;
}
};
/**
* 업로드 폴더 조회
*/
export const fetchFolderList = async (dirPath: string) => {
try {
const response = await axios.post<ApiResponse<FolderListResponse>>('/api/imagery/folder-list', {
dirPath,
});
return response.data.data;
} catch (error) {
console.error('fetchFolderList error:', error);
throw error;
}
};
/**
* 대용량 파일 분할 전송 (청크 업로드)
*/
export const uploadFileChunk = async (params: ChunkUploadParams) => {
try {
const formData = new FormData();
formData.append('fileName', params.fileName);
formData.append('fileSize', String(params.fileSize));
formData.append('chunkIndex', String(params.chunkIndex));
formData.append('chunkTotalIndex', String(params.chunkTotalIndex));
formData.append('chunkFile', params.chunkFile);
const response = await axios.post<ApiResponse<ChunkUploadResponse>>(
'/api/imagery/upload/file-chunk-upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
return response.data.data;
} catch (error) {
console.error('uploadFileChunk error:', error);
throw error;
}
};
/**
* 업로드 완료된 파일 병합
*/
export const completeChunkUpload = async (uuid: string) => {
try {
const response = await axios.put<ApiResponse<ChunkUploadResponse>>(
`/api/imagery/upload/chunk-upload-complete/${uuid}`,
);
return response.data.data;
} catch (error) {
console.error('completeChunkUpload error:', error);
throw error;
}
};

View File

@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
import { Button } from '~/shared/components/button/Button';
import { Section } from '~/shared/components/section/Section';
import { Table } from '~/shared/components/table';
import type { AerialItem } from '../types/aerial';
import { AerialRegisterModal } from './AerialRegisterModal';
export function AerialList() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<AerialItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
useEffect(() => {
const load = async () => {
setIsLoading(true);
try {
// const result = await fetchAerialList('YEAR', strtDttm, endDttm);
const result = {
list: [],
pagination: {
currentPage: 0,
pagiSize: 0,
totalPages: 0,
totalItems: 0,
},
};
if (result) {
setData(result.list);
setTotalCount(result.pagination.totalItems);
}
} finally {
setIsLoading(false);
}
};
load();
}, []);
return (
<>
<Section className="w-full pb-4" variant="list">
<div className="flex flex-col h-full p-4 gap-4">
<h1 className="text-xl font-bold"></h1>
<Table isLayoutFixed isFullHeight>
<Table.Caption>
<Table.CaptionLeft>
<Table.Total count={totalCount} />
</Table.CaptionLeft>
<Table.CaptionRight>
<div className="flex gap-2">
<Button color="primary" onClick={() => setIsRegisterModalOpen(true)}> </Button>
</div>
</Table.CaptionRight>
</Table.Caption>
<Table.Container>
<Table.Colgroup>
<Table.Col width={60} />
<Table.Col width={180} />
<Table.Col width={200} />
<Table.Col width={120} />
<Table.Col width={100} />
<Table.Col width={120} />
<Table.Col width={80} />
</Table.Colgroup>
<Table.Header>
<Table.HeaderRow>
<Table.HeaderCell align="center">No</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
<Table.HeaderCell align="right"></Table.HeaderCell>
<Table.HeaderCell align="center"></Table.HeaderCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{isLoading ? (
<Table.Loading colSpan={7} />
) : data.length === 0 ? (
<Table.Empty colSpan={7}> .</Table.Empty>
) : (
data.map((item, index) => (
<Table.Row
key={item.mapId}
isSelected={selectedId === item.mapId}
onClick={() => setSelectedId(item.mapId)}
>
<Table.Cell align="center">{index + 1}</Table.Cell>
<Table.Cell>{item.fileName}</Table.Cell>
<Table.Cell>{item.region}</Table.Cell>
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
<Table.Cell align="center">{item.scale}</Table.Cell>
<Table.Cell align="right">{item.fileSize}</Table.Cell>
<Table.Cell align="center">
{item.status}
</Table.Cell>
</Table.Row>
))
)}
</Table.Body>
</Table.Container>
</Table>
</div>
</Section>
{isRegisterModalOpen && <AerialRegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)} />}
</>
);
}

View File

@@ -0,0 +1,423 @@
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Button } from '~/shared/components/button/Button';
import { Modal, ModalRegion } from '~/shared/components/modal';
import { Select, type SelectOption } from '~/shared/components/select/Select';
import { Tree, type TreeItemType, type TreeRef } from '~/shared/components/tree/Tree';
import { fetchFolderList, fetchRegions } from '../api/imagery';
import { useAerialChunkUpload, type UploadingFile } from '../hooks/useAerialChunkUpload';
type AerialRegisterModalProps = {
isOpen: boolean;
onClose: () => void;
onUploadSuccess?: () => void;
};
type FormValues = {
type: string;
region: string;
folderPath: string;
};
const TYPE_OPTIONS: SelectOption[] = [
{ value: 'mapFrame', label: '도곽' },
];
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
};
const ProgressBar = ({ progress }: { progress: number }) => {
return (
<div className="flex-1 h-2 bg-dabeeo-gray-eb overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
);
};
const FileItem = ({
file,
folderPath,
onRemove,
}: {
file: UploadingFile;
folderPath: string;
onRemove: (id: string) => void;
}) => {
const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName;
return (
<div className="flex items-center gap-2 py-1">
<span className="text-sm text-dabeeo-black-34 truncate flex-1">{fullPath}</span>
<button
type="button"
onClick={() => onRemove(file.id)}
className="text-dabeeo-gray-99 hover:text-dabeeo-black-34 cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12.5 4L4 12.5M4 4L12.5 12.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
};
export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => {
const treeRef = useRef<TreeRef | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { control, setValue, watch, reset: resetForm } = useForm<FormValues>({
defaultValues: {
type: 'mapFrame',
region: '',
folderPath: '',
},
});
const selectedRegion = watch('region');
const selectedFolder = watch('folderPath');
const [regions, setRegions] = useState<SelectOption[]>([]);
const [treeItems, setTreeItems] = useState<TreeItemType[]>([]);
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
const {
files,
addFiles,
startUpload,
removeFile,
reset: resetUpload,
isUploading,
isAllCompleted,
hasFiles,
totalSize,
uploadedSize,
overallProgress,
} = useAerialChunkUpload();
const memorizedSelectedKeys = useMemo(
() => new Set(selectedFolder ? [selectedFolder] : []),
[selectedFolder]
);
// 지역 목록 조회
useEffect(() => {
const loadRegions = async () => {
try {
const data = await fetchRegions();
setRegions(data.map((r) => ({ value: r.code, label: r.name })));
} catch (error) {
console.error('Failed to load regions:', error);
}
};
if (isOpen) {
loadRegions();
}
}, [isOpen]);
// 지역 선택 시 폴더 목록 조회
useEffect(() => {
if (!selectedRegion) {
setTreeItems([]);
return;
}
const loadFolders = async () => {
setIsLoadingFolders(true);
try {
const data = await fetchFolderList(selectedRegion);
const items: TreeItemType[] = data.folders.map((folder) => ({
id: folder.fullPath,
title: folder.folderNm,
type: 'directory',
}));
setTreeItems(items);
} catch (error) {
console.error('Failed to load folders:', error);
} finally {
setIsLoadingFolders(false);
}
};
loadFolders();
}, [selectedRegion]);
const handleExpandFolder = async (key: string | number) => {
const folderPath = String(key);
try {
const data = await fetchFolderList(folderPath);
data.folders.forEach((folder) => {
const newItem: TreeItemType = {
id: folder.fullPath,
title: folder.folderNm,
type: 'directory',
};
treeRef.current?.addItem(newItem, folderPath);
});
} catch (error) {
console.error('Failed to load subfolders:', error);
}
};
const handleCollapseFolder = (key: string | number) => {
treeRef.current?.clearItemsByParentKey(key);
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
const fileArray = Array.from(selectedFiles);
const addedFiles = addFiles(fileArray);
// 파일 선택 즉시 업로드 시작
startUpload(addedFiles);
// input 초기화 (같은 파일 재선택 가능하도록)
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUpload = () => {
if (!selectedFolder) {
ModalRegion.alert({ content: '업로드할 폴더를 선택해주세요.' });
return;
}
if (!hasFiles) {
ModalRegion.alert({ content: '업로드할 파일을 선택해주세요.' });
return;
}
if (isUploading) {
ModalRegion.alert({ content: '파일 업로드가 진행 중입니다.' });
return;
}
if (isAllCompleted) {
ModalRegion.alert({
content: '파일 업로드가 완료되었습니다.',
onCancel: () => {
onUploadSuccess?.();
handleClose();
},
});
}
};
const handleClose = () => {
resetUpload();
resetForm();
setTreeItems([]);
onClose();
};
const handleCancel = () => {
if (isUploading || hasFiles) {
ModalRegion.confirm({
content: '업로드를 취소하시겠습니까?\n진행 중인 업로드가 중단됩니다.',
confirmText: '예',
cancelText: '아니오',
onConfirm: (close) => {
close();
handleClose();
},
});
} else {
handleClose();
}
};
const currentUploadingFile = files.find((f) => f.status === 'uploading');
return (
<Modal isOpen={isOpen} onOpenChange={handleCancel} isKeyboardDismissDisabled>
{() => (
<form onSubmit={(e) => { e.preventDefault(); handleUpload(); }}>
<Modal.Header hasCloseButton={false}> </Modal.Header>
<Modal.Body>
<div className="flex flex-col gap-4 py-2 w-140">
{/* 타입 선택 */}
<div className="flex items-center gap-4">
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
</label>
<Controller
name="type"
control={control}
render={({ field: { value, onChange } }) => (
<Select
items={TYPE_OPTIONS}
selectedKey={value}
onChange={onChange}
className="w-40"
/>
)}
/>
</div>
{/* 지역 선택 */}
<div className="flex items-center gap-4">
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
</label>
<Controller
name="region"
control={control}
render={({ field: { value, onChange } }) => (
<Select
items={regions}
selectedKey={value}
onChange={(v) => {
onChange(v);
setValue('folderPath', '');
}}
placeholder="지역선택"
className="w-40"
/>
)}
/>
</div>
{/* 폴더 선택 */}
<div className="flex items-start gap-4">
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
</label>
<div className="flex-1 border border-dabeeo-gray-be max-h-60 min-h-40 overflow-y-auto">
{isLoadingFolders ? (
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
...
</div>
) : treeItems.length > 0 ? (
<Controller
name="folderPath"
control={control}
render={({ field: { onChange } }) => (
<Tree
ref={treeRef}
items={treeItems}
selectedKeys={memorizedSelectedKeys}
onExpand={(key, item) => {
handleExpandFolder(key);
onChange(String(key));
}}
onCollapse={(key) => {
handleCollapseFolder(key);
}}
onSelect={(key) => {
if (key) onChange(String(key));
}}
enableDragAndDrop={false}
persistSelectionOnCollapse
/>
)}
/>
) : selectedRegion ? (
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
.
</div>
) : (
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
.
</div>
)}
</div>
</div>
<p className="text-xs text-dabeeo-orange-main ml-24">
.
</p>
{/* 파일명 */}
<div className="flex items-start gap-4">
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
<span className="text-dabeeo-red">*</span>
</label>
<div className="flex-1 border border-dabeeo-gray-be p-2 min-h-10 max-h-24 overflow-y-auto">
{files.length > 0 ? (
files.map((file) => (
<FileItem key={file.id} file={file} folderPath={selectedFolder} onRemove={removeFile} />
))
) : (
<span className="text-sm text-dabeeo-gray-99"> .</span>
)}
</div>
</div>
{/* 파일 선택 */}
<div className="flex items-center gap-4">
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
<span className="text-dabeeo-red">*</span>
</label>
<div className="flex-1 flex items-center gap-2">
<div className="flex-1 h-9 border border-dabeeo-gray-be px-3 flex items-center">
<span className="text-sm text-dabeeo-gray-99 truncate">
{currentUploadingFile?.fileName || ''}
</span>
</div>
<label
htmlFor="aerial-file-input"
className="h-9 px-4 bg-dabeeo-black-34 text-white text-sm font-medium flex items-center justify-center cursor-pointer hover:bg-dabeeo-black-47"
>
</label>
<input
id="aerial-file-input"
ref={fileInputRef}
type="file"
accept=".tif,.tiff"
multiple
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
{/* 진행률 표시 */}
{hasFiles && (
<div className="flex items-center gap-4 ml-24">
<ProgressBar progress={overallProgress} />
<span className="text-xs text-dabeeo-black-34 w-10 text-right">{overallProgress}%</span>
</div>
)}
{hasFiles && (
<div className="text-xs text-dabeeo-gray-99 ml-24">
Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)}
</div>
)}
</div>
</Modal.Body>
<Modal.Footer>
<Button type="button" color="gray" size="large" onClick={handleCancel}>
</Button>
<Button
type="submit"
color="primary"
size="large"
isDisabled={!hasFiles || isUploading}
isPending={isUploading}
>
</Button>
</Modal.Footer>
</form>
)}
</Modal>
);
};

View File

@@ -0,0 +1,164 @@
import { useCallback, useRef, useState } from 'react';
import { completeChunkUpload, uploadFileChunk } from '../api/imagery';
export type FileUploadStatus = 'idle' | 'uploading' | 'completed' | 'error'
export type UploadingFile = {
id: string;
file: File;
fileName: string;
progress: number;
status: FileUploadStatus;
totalSize: number;
uploadedSize: number;
uuid?: string;
}
const CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
const MAX_CONCURRENT_UPLOADS = 3
export const useAerialChunkUpload = () => {
const [files, setFiles] = useState<UploadingFile[]>([])
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
const updateFileState = useCallback((id: string, updates: Partial<UploadingFile>) => {
setFiles((prev) => prev.map((f) => f.id === id ? { ...f, ...updates } : f))
}, [])
const uploadSingleFile = useCallback(async (uploadFile: UploadingFile) => {
const { id, file } = uploadFile
const abortController = new AbortController()
abortControllersRef.current.set(id, abortController)
const { signal } = abortController
const fileSize = file.size
const fileName = file.name
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
updateFileState(id, { status: 'uploading', progress: 0 })
let uuid = ''
try {
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (signal.aborted) {
return
}
const start = chunkIndex * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, fileSize)
const chunkFile = file.slice(start, end)
const response = await uploadFileChunk({
fileName,
fileSize,
chunkIndex,
chunkTotalIndex: totalChunks - 1,
chunkFile,
})
if (signal.aborted) {
return
}
// 첫 번째 청크 응답에서 uuid 저장
if (chunkIndex === 0 && response.uuid) {
uuid = response.uuid
updateFileState(id, { uuid })
}
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100)
const uploadedSize = end
updateFileState(id, {
progress,
uploadedSize,
})
}
// 청크 업로드 완료 후 completeChunkUpload 호출
if (uuid) {
await completeChunkUpload(uuid)
}
updateFileState(id, { status: 'completed', progress: 100 })
} catch (error) {
if (!signal.aborted) {
console.error('Upload error:', error)
updateFileState(id, { status: 'error' })
}
} finally {
abortControllersRef.current.delete(id)
}
}, [updateFileState])
const addFiles = useCallback((newFiles: File[]) => {
const uploadFiles: UploadingFile[] = newFiles.map((file) => ({
id: crypto.randomUUID(),
file,
fileName: file.name,
progress: 0,
status: 'idle' as FileUploadStatus,
totalSize: file.size,
uploadedSize: 0,
}))
setFiles((prev) => [...prev, ...uploadFiles])
return uploadFiles
}, [])
const startUpload = useCallback(async (filesToUpload?: UploadingFile[]) => {
const targetFiles = filesToUpload || files.filter((f) => f.status === 'idle')
// 병렬 업로드 (최대 MAX_CONCURRENT_UPLOADS개씩)
for (let i = 0; i < targetFiles.length; i += MAX_CONCURRENT_UPLOADS) {
const batch = targetFiles.slice(i, i + MAX_CONCURRENT_UPLOADS)
await Promise.all(batch.map((file) => uploadSingleFile(file)))
}
}, [files, uploadSingleFile])
const removeFile = useCallback((id: string) => {
// 업로드 중이면 취소
const controller = abortControllersRef.current.get(id)
if (controller) {
controller.abort()
abortControllersRef.current.delete(id)
}
setFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const cancelAllUploads = useCallback(() => {
abortControllersRef.current.forEach((controller) => controller.abort())
abortControllersRef.current.clear()
setFiles([])
}, [])
const reset = useCallback(() => {
cancelAllUploads()
}, [cancelAllUploads])
const isUploading = files.some((f) => f.status === 'uploading')
const isAllCompleted = files.length > 0 && files.every((f) => f.status === 'completed')
const hasFiles = files.length > 0
const totalSize = files.reduce((acc, f) => acc + f.totalSize, 0)
const uploadedSize = files.reduce((acc, f) => acc + f.uploadedSize, 0)
const overallProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
return {
files,
addFiles,
startUpload,
removeFile,
cancelAllUploads,
reset,
isUploading,
isAllCompleted,
hasFiles,
totalSize,
uploadedSize,
overallProgress,
}
}

View File

@@ -0,0 +1,46 @@
// 목록 아이템
export type AerialItem = {
mapId: string;
fileName: string;
region: string;
scale: string;
capturedDttm: string;
fileSize: string;
createdDttm: string;
createdBy: string;
status: string;
};
// 상세 정보
export type AerialDetail = {
uuid: string;
fileName: string;
fileSize: number;
region: string;
category: string;
createdName: string;
createdDttm: string;
capturedDttm: string;
latitude: number;
longitude: number;
};
// 목록 조회 파라미터
export type AerialListParams = {
dateRangeType: 'capturedDttm' | 'createdDttm';
strtDttm: string;
endDttm: string;
page?: number;
size?: number;
};
// 항공영상 데이터 등록
export type AerialData = {
fileName: string;
region: string;
scale: string;
capturedDttm: string;
fileSize: string;
status: string;
type: '도곽';
};

View File

@@ -0,0 +1,40 @@
// 지역 (시/도)
export type Region = {
code: string;
name: string;
};
// 폴더
export type Folder = {
folderNm: string;
parentFolderNm: string;
parentPath: string;
fullPath: string;
depth: number;
childCnt: number;
lastModified: string;
isValid: boolean;
};
export type FolderListResponse = {
dirPath: string;
folders: Folder[];
};
// 청크 업로드
export type ChunkUploadParams = {
fileName: string;
fileSize: number;
chunkIndex: number;
chunkTotalIndex: number;
chunkFile: Blob;
};
export type ChunkUploadResponse = {
uuid: string;
filePath: string;
fileName: string;
chunkIndex: number;
chunkTotalIndex: number;
uploadId?: string;
};

View File

@@ -7,6 +7,7 @@ export default [
route('login', './routes/login/page.tsx'),
]),
layout('./routes/layout.tsx', [
index('./routes/page.tsx'),
...prefix('imagery', [
...prefix('aerial', [
index('./routes/imagery/aerial/page.tsx'),

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G1 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G2 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G3 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G1 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G2 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G3 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -1,5 +1,7 @@
import { AerialList } from '~/features/imagery/components/AerialList';
export default function Page() {
return (
<div> </div>
<AerialList></AerialList>
);
}

View File

@@ -0,0 +1,5 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return <div> id: {params.inferenceId}</div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -1,10 +1,33 @@
import { Outlet } from 'react-router';
import { LayoutMenu } from '~/shared/components/menu/LayoutMenu';
import { MENU_ITEMS } from '~/shared/constants/menu';
export default function Layout() {
return (
<div>
<Outlet />
<div className="flex h-screen w-screen">
<aside className="bg-primary-tertiary01 z-10 h-full w-[260px] flex-none flex flex-col shadow-[4px_0_5px_0_rgba(0,0,0,0.1)]">
<div className="flex flex-col items-center bg-white pt-6 pb-4">
<div className="flex items-center justify-center pb-7">
<span className="text-xl font-bold text-primary">DABEEO</span>
</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="bg-primary flex h-12 flex-none items-center px-5 font-bold text-white">
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<LayoutMenu items={MENU_ITEMS} />
</div>
</aside>
<main className="bg-primary-tertiary02 flex flex-1 min-w-0 flex-col">
<div className="bg-primary w-full h-4" />
<div className="flex min-h-0 flex-1 flex-col gap-6 px-8 pt-6 pb-8">
<div className="flex min-h-0 flex-1">
<Outlet />
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,5 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return <div> id: {params.modelId}</div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

View File

@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function Page() {
return <Navigate to="/imagery/aerial" replace />;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

Some files were not shown because too many files have changed in this diff Show More