diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 2d54df105..31f6c5ed8 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -3,7 +3,7 @@ name: Commit Message Lint on: pull_request: - branches: [ main, develop ] + branches: [ main ] jobs: commitlint: @@ -32,4 +32,4 @@ jobs: exit 1 fi - echo "모든 커밋 메시지 검사 통과!" \ No newline at end of file + echo "모든 커밋 메시지 검사 통과!" diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml deleted file mode 100644 index 6749bb32d..000000000 --- a/.github/workflows/deploy-dev.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Docker Compose CI/CD (Develop) - -on: - pull_request: - branches: [ "develop" ] - types: [ closed ] - -jobs: - deploy: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Build with Gradle - run: ./gradlew clean build -x test - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and Push Docker Image - env: - IMAGE_TAG: ${{ github.sha }} - run: | - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -t ${{ secrets.DEVELOP_DOCKER_IMAGE }}:${IMAGE_TAG} \ - --push \ - . - - - name: Deploy to Dev Server (only clash-db, clash-server) - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.DEVELOP_SERVER_IP }} - username: ${{ secrets.DEVELOP_SSH_USER }} - key: ${{ secrets.DEVELOP_SSH_PRIVATE_KEY }} - port: ${{ secrets.DEVELOP_SSH_PORT }} - script_stop: true - script: | - set -eu - cd ~/app - - echo "DOCKER_IMAGE=${{ secrets.DEVELOP_DOCKER_IMAGE }}" > .deploy.env - echo "IMAGE_TAG=${{ github.sha }}" >> .deploy.env - - export $(cat .deploy.env | xargs) - - docker compose pull clash-db clash-server - docker compose up -d --no-deps clash-db clash-server diff --git a/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaMapper.java index 01876f584..5f7627e7c 100644 --- a/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaMapper.java +++ b/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaMapper.java @@ -20,54 +20,12 @@ public class SectionJpaMapper { private final ChapterJpaMapper chapterJpaMapper; private final SectionKeyPointJpaMapper sectionKeyPointJpaMapper; private final CategoryJpaMapper categoryJpaMapper; - private final SectionJpaRepository sectionJpaRepository; - - public SectionJpaEntity toJpaEntity(Section section) { - SectionJpaEntity sectionEntity = new SectionJpaEntity( - section.getId(), - section.getMajor(), - section.getTitle(), - section.getDescription(), - categoryJpaMapper.toJpaEntity(section.getCategory()), - section.getOrderIndex(), - new ArrayList<>(), - new ArrayList<>(), - new HashSet<>(), - section.getCreatedAt(), // createdAt - section.getUpdatedAt() // updatedAt - ); - - // null 안전성: section.getChapters()가 null이면 빈 리스트로 처리 - List chapters = (section.getChapters() != null) - ? section.getChapters().stream() - .map(c -> chapterJpaMapper.toEntity(c, sectionEntity)).toList() : - new ArrayList<>(); - - List keyPoints = (section.getKeyPoints() != null) - ? section.getKeyPoints().stream().map(k -> sectionKeyPointJpaMapper.toJpaEntity(k, sectionEntity)).toList() : - new ArrayList<>(); - - sectionEntity.getChapters().addAll(chapters); - sectionEntity.getKeyPoints().addAll(keyPoints); - - if (section.getPrerequisites() != null && !section.getPrerequisites().isEmpty()) { - // DB에서 managed 엔티티를 가져와서 사용 (transient 엔티티 생성 방지) - List prerequisiteIds = section.getPrerequisites().stream() - .map(Section::getId) - .toList(); - List managedPrerequisites = sectionJpaRepository.findAllById(prerequisiteIds); - sectionEntity.getPrerequisites().addAll(managedPrerequisites); - } - - return sectionEntity; - } - - public SectionJpaEntity toJpaEntity(Section section, Map categoryMap) { - CategoryJpaEntity categoryEntity = categoryMap.get(section.getCategory().getId()); - if (categoryEntity == null) { - throw new RuntimeException("Category not found: " + section.getCategory().getId()); - } + public SectionJpaEntity toJpaEntity( + Section section, + CategoryJpaEntity categoryEntity, + Set prerequisites + ) { SectionJpaEntity sectionEntity = new SectionJpaEntity( section.getId(), section.getMajor(), @@ -77,7 +35,7 @@ public SectionJpaEntity toJpaEntity(Section section, Map(), new ArrayList<>(), - new HashSet<>(), + new HashSet<>(prerequisites), section.getCreatedAt(), // createdAt section.getUpdatedAt() // updatedAt ); @@ -95,15 +53,6 @@ public SectionJpaEntity toJpaEntity(Section section, Map prerequisiteIds = section.getPrerequisites().stream() - .map(Section::getId) - .toList(); - List managedPrerequisites = sectionJpaRepository.findAllById(prerequisiteIds); - sectionEntity.getPrerequisites().addAll(managedPrerequisites); - } - return sectionEntity; } @@ -149,4 +98,4 @@ private Section toPrerequisiteDomain(SectionJpaEntity entity) { entity.getUpdatedAt() ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaRepository.java index 69d73dd28..0ce8d4121 100644 --- a/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionJpaRepository.java @@ -3,15 +3,17 @@ import com.process.clash.domain.common.enums.Major; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @Repository public interface SectionJpaRepository extends JpaRepository { - @EntityGraph(attributePaths = {"chapters", "keyPoints", "prerequisites"}) + @EntityGraph(attributePaths = {"category", "prerequisites"}) Optional findById(Long id); @EntityGraph(attributePaths = {"chapters", "keyPoints", "prerequisites"}) @@ -20,6 +22,9 @@ public interface SectionJpaRepository extends JpaRepository findAllById(Iterable ids); + @Query("SELECT section FROM SectionJpaEntity section WHERE section.id IN :ids") + List findAllReferencesById(Collection ids); + @EntityGraph(attributePaths = {"category"}) List findAllByMajorOrderByOrderIndexAsc(Major major); diff --git a/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionPersistenceAdapter.java index c2ebfae45..c96db9bea 100644 --- a/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionPersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/roadmap/section/SectionPersistenceAdapter.java @@ -12,6 +12,7 @@ import com.process.clash.domain.roadmap.entity.Chapter; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; @@ -31,9 +32,12 @@ public Section save(Section section) { // 카테고리 조회 CategoryJpaEntity categoryEntity = categoryJpaRepository.findById(section.getCategory().getId()) .orElseThrow(CategoryNotFoundException::new); - Map categoryMap = Map.of(section.getCategory().getId(), categoryEntity); - SectionJpaEntity newEntity = sectionJpaMapper.toJpaEntity(section, categoryMap); + SectionJpaEntity newEntity = sectionJpaMapper.toJpaEntity( + section, + categoryEntity, + resolveManagedPrerequisites(section.getPrerequisites()) + ); SectionJpaEntity saved = sectionJpaRepository.save(newEntity); return sectionJpaMapper.toDomain(saved); } @@ -88,7 +92,15 @@ public List
saveAll(List
sections) { allEntities.add(entity); } else { // 신규 객체만 saveAll로 저장 - SectionJpaEntity newEntity = sectionJpaMapper.toJpaEntity(domain, categoryMap); + CategoryJpaEntity categoryEntity = categoryMap.get(domain.getCategory().getId()); + if (categoryEntity == null) { + throw new CategoryNotFoundException(); + } + SectionJpaEntity newEntity = sectionJpaMapper.toJpaEntity( + domain, + categoryEntity, + resolveManagedPrerequisites(domain.getPrerequisites()) + ); newEntities.add(newEntity); allEntities.add(newEntity); } @@ -112,8 +124,11 @@ public Optional
findById(Long id) { } @Override + @Transactional(readOnly = true) public List
findAllById(List ids) { - return sectionJpaRepository.findAllById(ids).stream().map(sectionJpaMapper::toDomain).toList(); + return sectionJpaRepository.findAllReferencesById(ids).stream() + .map(sectionJpaMapper::toDomain) + .toList(); } @Override @@ -194,19 +209,32 @@ private void updateSectionDetails(SectionJpaEntity entity, Section domain, Map prereqIds = domain.getPrerequisites().stream() - .map(Section::getId) - .toList(); - - if (!prereqIds.isEmpty()) { - // DB에서 실제 엔티티를 조회하여 영속성 컨텍스트가 관리하는 객체로 세팅 - Set managedPrereqs = new HashSet<>(sectionJpaRepository.findAllById(prereqIds)); - entity.updatePrerequisites(managedPrereqs); - } else { - entity.updatePrerequisites(new HashSet<>()); - } + entity.updatePrerequisites(resolveManagedPrerequisites(domain.getPrerequisites())); + } + } + + private Set resolveManagedPrerequisites(Set
prerequisites) { + if (prerequisites == null || prerequisites.isEmpty()) { + return Set.of(); + } + + Set prerequisiteIds = prerequisites.stream() + .map(Section::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (prerequisiteIds.size() != prerequisites.size()) { + throw new SectionNotFoundException(); } + + List managedPrerequisites = sectionJpaRepository + .findAllReferencesById(prerequisiteIds); + + if (managedPrerequisites.size() != prerequisiteIds.size()) { + throw new SectionNotFoundException(); + } + + return new HashSet<>(managedPrerequisites); } -} \ No newline at end of file +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3ed7acdae..e240bd071 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -163,8 +163,6 @@ cors: - https://api.clash.kr - ${PRODUCTION_WEB_SERVER:} - ${PRODUCTION_ELECTRON_CUSTOM_PROTOCOL:} - - ${DEVELOP_ELECTRON_SERVER:} - - ${DEVELOP_WEB_SERVER:} management: endpoint: diff --git a/src/test/java/com/process/clash/application/roadmap/section/service/UpdateSectionServiceIntegrationTest.java b/src/test/java/com/process/clash/application/roadmap/section/service/UpdateSectionServiceIntegrationTest.java index 9156619dd..d310f6092 100644 --- a/src/test/java/com/process/clash/application/roadmap/section/service/UpdateSectionServiceIntegrationTest.java +++ b/src/test/java/com/process/clash/application/roadmap/section/service/UpdateSectionServiceIntegrationTest.java @@ -5,10 +5,14 @@ import com.process.clash.application.roadmap.category.port.in.CreateCategoryUseCase; import com.process.clash.application.roadmap.section.data.CreateSectionData; import com.process.clash.application.roadmap.section.data.UpdateSectionData; +import com.process.clash.application.roadmap.section.exception.exception.notfound.SectionNotFoundException; import com.process.clash.application.roadmap.section.port.in.CreateSectionUseCase; import com.process.clash.application.roadmap.section.port.in.UpdateSectionUseCase; import com.process.clash.application.roadmap.section.port.out.SectionKeyPointRepositoryPort; +import com.process.clash.application.roadmap.section.port.out.SectionRepositoryPort; import com.process.clash.domain.common.enums.Major; +import com.process.clash.domain.roadmap.entity.Category; +import com.process.clash.domain.roadmap.entity.Section; import com.process.clash.domain.roadmap.entity.SectionKeyPoint; import com.process.clash.domain.user.user.enums.Role; import jakarta.persistence.EntityManager; @@ -24,8 +28,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * UpdateSectionService의 keyPoints 업데이트 시 JPA Cascade와의 충돌 여부를 확인하는 통합 테스트 @@ -52,6 +58,9 @@ public class UpdateSectionServiceIntegrationTest { @Autowired private SectionKeyPointRepositoryPort keyPointRepository; + @Autowired + private SectionRepositoryPort sectionRepository; + @PersistenceContext private EntityManager entityManager; @@ -138,6 +147,53 @@ void updateKeyPoints_shouldNotConflictWithOrphanRemoval() { System.out.println("==========================="); } + @Test + @DisplayName("선수 Section은 영속 엔티티로 연결하여 저장한다") + void saveSection_withPrerequisite_shouldPersistRelationship() { + CreateCategoryData.Result categoryResult = createCategoryUseCase.execute( + new CreateCategoryData.Command(adminActor, "BASIC") + ); + CreateSectionData.Result prerequisiteResult = createSectionUseCase.execute(new CreateSectionData.Command( + adminActor, Major.SERVER, "Prerequisite", categoryResult.categoryId(), "", List.of() + )); + CreateSectionData.Result targetResult = createSectionUseCase.execute(new CreateSectionData.Command( + adminActor, Major.SERVER, "Target", categoryResult.categoryId(), "", List.of() + )); + + updateSectionUseCase.execute(new UpdateSectionData.Command( + adminActor, targetResult.sectionId(), null, null, null, null, null, + List.of(prerequisiteResult.sectionId()) + )); + + entityManager.flush(); + entityManager.clear(); + + Section savedTarget = sectionRepository.findById(targetResult.sectionId()).orElseThrow(); + assertThat(savedTarget.getPrerequisites()) + .extracting(Section::getId) + .containsExactly(prerequisiteResult.sectionId()); + } + + @Test + @DisplayName("존재하지 않는 선수 Section은 저장하지 않는다") + void saveSection_withMissingPrerequisite_shouldFail() { + CreateCategoryData.Result categoryResult = createCategoryUseCase.execute( + new CreateCategoryData.Command(adminActor, "BASIC") + ); + Category category = new Category(categoryResult.categoryId(), "BASIC", null, null, null); + Section missingPrerequisite = new Section( + Long.MAX_VALUE, Major.SERVER, "Missing", "", category, 0, + List.of(), List.of(), Set.of(), null, null + ); + Section newSection = new Section( + null, Major.SERVER, "Target", "", category, 0, + List.of(), List.of(), Set.of(missingPrerequisite), null, null + ); + + assertThatThrownBy(() -> sectionRepository.save(newSection)) + .isInstanceOf(SectionNotFoundException.class); + } + @Test @DisplayName("KeyPoints를 빈 리스트로 업데이트하면 모든 keyPoints가 삭제되는지 확인") void updateKeyPoints_withEmptyList_shouldDeleteAllKeyPoints() {