From 5d239afade1634eec71405d3537aaa1d6049afd6 Mon Sep 17 00:00:00 2001 From: meraki6512 Date: Fri, 5 Sep 2025 19:12:59 +0900 Subject: [PATCH] =?UTF-8?q?[#81]=20feat:=20=EC=9D=B4=EC=8A=88=20=EB=8B=B4?= =?UTF-8?q?=EB=8B=B9=EC=9E=90=20=EB=8B=A4=EC=A4=91=20=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 1:1 또는 N:1 관계였던 이슈와 담당자(Assignee)를 N:M 관계로 변경하여, 하나의 이슈에 여러 명의 담당자를 지정할 수 있도록 기능을 확장합니다. -DB 스키마 및 마이그레이션 (V4) : 수정 스크립트 추가 - JPA 엔티티 수정: IssueAssignee, IssueAssigneeId 생성 및 Issue 엔티티 수정 - 관련 DTO 수정 - IssueService 수정 - 관련 테스트 코드 수정 --- .../issueDive/controller/IssueController.java | 2 +- .../com/issueDive/dto/CreateIssueRequest.java | 2 +- .../com/issueDive/dto/IssueFilterRequest.java | 2 +- .../java/com/issueDive/dto/IssueResponse.java | 2 +- .../com/issueDive/dto/UpdateIssueRequest.java | 2 +- src/main/java/com/issueDive/entity/Issue.java | 21 ++-- .../com/issueDive/entity/IssueAssignee.java | 32 +++++ .../com/issueDive/entity/IssueAssigneeId.java | 22 ++++ .../java/com/issueDive/entity/IssueLabel.java | 1 + .../repository/IssueAssigneeRepository.java | 15 +++ .../issueDive/repository/IssueRepository.java | 13 ++ .../com/issueDive/service/IssueService.java | 112 +++++++++--------- .../V4__create_issue_assignee_table.sql | 14 +++ .../issueDive/IssueDiveApplicationTests.java | 14 ++- .../controller/IssueControllerTest.java | 16 ++- .../issueDive/service/IssueServiceTest.java | 39 +++--- 16 files changed, 211 insertions(+), 98 deletions(-) create mode 100644 src/main/java/com/issueDive/entity/IssueAssignee.java create mode 100644 src/main/java/com/issueDive/entity/IssueAssigneeId.java create mode 100644 src/main/java/com/issueDive/repository/IssueAssigneeRepository.java create mode 100644 src/main/resources/db/migration/V4__create_issue_assignee_table.sql diff --git a/src/main/java/com/issueDive/controller/IssueController.java b/src/main/java/com/issueDive/controller/IssueController.java index a6526ac..f2b6ebd 100644 --- a/src/main/java/com/issueDive/controller/IssueController.java +++ b/src/main/java/com/issueDive/controller/IssueController.java @@ -68,7 +68,7 @@ public ResponseEntity>> getIssues( new IssueFilterRequest( filter.status(), filter.authorId(), - filter.assigneeId(), + filter.assigneeIds(), filter.labelIds(), filter.page() != null ? filter.page() : 0, filter.size() != null ? filter.size() : 10, diff --git a/src/main/java/com/issueDive/dto/CreateIssueRequest.java b/src/main/java/com/issueDive/dto/CreateIssueRequest.java index dae02d0..ebec0e2 100644 --- a/src/main/java/com/issueDive/dto/CreateIssueRequest.java +++ b/src/main/java/com/issueDive/dto/CreateIssueRequest.java @@ -5,7 +5,7 @@ public record CreateIssueRequest( String title, String description, - Long assigneeId, + List assigneeIds, List labels ) { } diff --git a/src/main/java/com/issueDive/dto/IssueFilterRequest.java b/src/main/java/com/issueDive/dto/IssueFilterRequest.java index 19f5455..8b608a9 100644 --- a/src/main/java/com/issueDive/dto/IssueFilterRequest.java +++ b/src/main/java/com/issueDive/dto/IssueFilterRequest.java @@ -8,7 +8,7 @@ public record IssueFilterRequest( @Pattern(regexp = "open|closed|in_progress|OPEN|CLOSED|IN_PROGRESS", message = "상태 값은 open, closed, in_progress 중 하나여야 합니다.") String status, Long authorId, - Long assigneeId, + List assigneeIds, List labelIds, @Min(0) Integer page, @Min(1) Integer size, diff --git a/src/main/java/com/issueDive/dto/IssueResponse.java b/src/main/java/com/issueDive/dto/IssueResponse.java index 0f9c58e..7b39c24 100644 --- a/src/main/java/com/issueDive/dto/IssueResponse.java +++ b/src/main/java/com/issueDive/dto/IssueResponse.java @@ -9,7 +9,7 @@ public record IssueResponse( String description, String status, Long authorId, - Long assigneeId, + List assigneeIds, List labelIds, LocalDateTime createdAt, LocalDateTime updatedAt diff --git a/src/main/java/com/issueDive/dto/UpdateIssueRequest.java b/src/main/java/com/issueDive/dto/UpdateIssueRequest.java index f34eee1..a6b174c 100644 --- a/src/main/java/com/issueDive/dto/UpdateIssueRequest.java +++ b/src/main/java/com/issueDive/dto/UpdateIssueRequest.java @@ -5,7 +5,7 @@ public record UpdateIssueRequest( String title, String description, - Long assigneeId, + List assigneeIds, List labelIds ) {} diff --git a/src/main/java/com/issueDive/entity/Issue.java b/src/main/java/com/issueDive/entity/Issue.java index 7f0edcf..26436d6 100644 --- a/src/main/java/com/issueDive/entity/Issue.java +++ b/src/main/java/com/issueDive/entity/Issue.java @@ -39,9 +39,17 @@ public class Issue { @JoinColumn(name = "author_id", nullable = false) private User author; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "assignee_id") - private User assignee; + @Builder.Default + @OneToMany(mappedBy = "issue", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set issueAssignees = new HashSet<>(); + + @Builder.Default + @OneToMany(mappedBy = "issue", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set issueLabels = new HashSet<>(); + + @Builder.Default + @OneToMany(mappedBy = "issue", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + private List comments = new ArrayList<>(); @Column(name = "created_at", updatable = false, insertable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") @@ -51,10 +59,6 @@ public class Issue { columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP") private LocalDateTime updatedAt; - @Builder.Default - @OneToMany(mappedBy = "issue", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List issueLabels = new ArrayList<>(); - @PrePersist public void prePersist() { LocalDateTime now = LocalDateTime.now(); @@ -67,7 +71,4 @@ public void preUpdate() { this.updatedAt = LocalDateTime.now(); } - @OneToMany(mappedBy = "issue", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) - private List comments = new ArrayList<>(); - } diff --git a/src/main/java/com/issueDive/entity/IssueAssignee.java b/src/main/java/com/issueDive/entity/IssueAssignee.java new file mode 100644 index 0000000..efff4e4 --- /dev/null +++ b/src/main/java/com/issueDive/entity/IssueAssignee.java @@ -0,0 +1,32 @@ +package com.issueDive.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "issue_assignee") +@Getter +@Setter +@NoArgsConstructor +public class IssueAssignee { + + @EmbeddedId + private IssueAssigneeId id = new IssueAssigneeId(); + + @MapsId("issueId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "issue_id") + private Issue issue; + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + public IssueAssignee(Issue issue, User user) { + this.issue = issue; + this.user = user; + } +} diff --git a/src/main/java/com/issueDive/entity/IssueAssigneeId.java b/src/main/java/com/issueDive/entity/IssueAssigneeId.java new file mode 100644 index 0000000..d57a6fb --- /dev/null +++ b/src/main/java/com/issueDive/entity/IssueAssigneeId.java @@ -0,0 +1,22 @@ +package com.issueDive.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class IssueAssigneeId implements Serializable { + + @Column(name = "issue_id") + private Long issueId; + + @Column(name = "user_id") + private Long userId; +} diff --git a/src/main/java/com/issueDive/entity/IssueLabel.java b/src/main/java/com/issueDive/entity/IssueLabel.java index a21fb88..1178b37 100644 --- a/src/main/java/com/issueDive/entity/IssueLabel.java +++ b/src/main/java/com/issueDive/entity/IssueLabel.java @@ -13,6 +13,7 @@ @Table(name = "issue_label") public class IssueLabel { + @Builder.Default @EmbeddedId private IssueLabelId id = new IssueLabelId(); diff --git a/src/main/java/com/issueDive/repository/IssueAssigneeRepository.java b/src/main/java/com/issueDive/repository/IssueAssigneeRepository.java new file mode 100644 index 0000000..c79ee1d --- /dev/null +++ b/src/main/java/com/issueDive/repository/IssueAssigneeRepository.java @@ -0,0 +1,15 @@ +package com.issueDive.repository; + +import com.issueDive.entity.IssueAssignee; +import com.issueDive.entity.IssueAssigneeId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface IssueAssigneeRepository extends JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM IssueAssignee ia WHERE ia.issue.id = :issueId") + void deleteByIssueId(@Param("issueId") Long issueId); +} diff --git a/src/main/java/com/issueDive/repository/IssueRepository.java b/src/main/java/com/issueDive/repository/IssueRepository.java index 930a968..79816aa 100644 --- a/src/main/java/com/issueDive/repository/IssueRepository.java +++ b/src/main/java/com/issueDive/repository/IssueRepository.java @@ -5,9 +5,22 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface IssueRepository extends JpaRepository { @Query("SELECT DISTINCT i FROM Issue i LEFT JOIN FETCH i.issueLabels il LEFT JOIN FETCH il.label WHERE i.id = :id") Optional findWithLabelsById(@Param("id") Long id); + + @Query("SELECT DISTINCT i FROM Issue i " + + "LEFT JOIN FETCH i.issueAssignees ia LEFT JOIN FETCH ia.user " + + "LEFT JOIN FETCH i.issueLabels il LEFT JOIN FETCH il.label " + + "WHERE i.id = :id") + Optional findWithDetailsById(@Param("id") Long id); + + @Query("SELECT DISTINCT i FROM Issue i " + + "LEFT JOIN FETCH i.issueAssignees ia LEFT JOIN FETCH ia.user " + + "LEFT JOIN FETCH i.issueLabels il LEFT JOIN FETCH il.label " + + "WHERE i.id IN :ids") + List findAllByIdInWithDetails(@Param("ids") List ids); } diff --git a/src/main/java/com/issueDive/service/IssueService.java b/src/main/java/com/issueDive/service/IssueService.java index ff30b39..5c90b53 100644 --- a/src/main/java/com/issueDive/service/IssueService.java +++ b/src/main/java/com/issueDive/service/IssueService.java @@ -8,10 +8,7 @@ import com.issueDive.exception.ErrorCode; import com.issueDive.exception.NotFoundException; import com.issueDive.exception.ValidationException; -import com.issueDive.repository.IssueLabelRepository; -import com.issueDive.repository.IssueRepository; -import com.issueDive.repository.LabelRepository; -import com.issueDive.repository.UserRepository; +import com.issueDive.repository.*; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; @@ -24,6 +21,7 @@ import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -36,6 +34,7 @@ public class IssueService { private final UserRepository userRepository; // 작성자/담당자 유효성 검증용 private final LabelRepository labelRepository; private final IssueLabelRepository issueLabelRepository; + private final IssueAssigneeRepository issueAssigneeRepository; private final QIssueLabel qIssueLabel = QIssueLabel.issueLabel; /** @@ -47,26 +46,31 @@ public class IssueService { public IssueResponse createIssue(CreateIssueRequest request, Long authorId) { User author = userRepository.findById(authorId) .orElseThrow(() -> new NotFoundException("User not found")); - User assignee = null; - if (request.assigneeId() != null) { - assignee = userRepository.findById(request.assigneeId()) - .orElseThrow(() -> new NotFoundException("Assignee not found")); - } + Issue issue = new Issue(); issue.setTitle(request.title()); issue.setDescription(request.description()); issue.setAuthor(author); - issue.setAssignee(assignee); issue.setStatus(IssueStatus.OPEN); - //라벨 매핑 추가 + // 다중 담당자 매핑 + if (request.assigneeIds() != null && !request.assigneeIds().isEmpty()) { + List assignees = userRepository.findAllById(request.assigneeIds()); + Set issueAssignees = assignees.stream() + .map(assignee -> new IssueAssignee(issue, assignee)) + .collect(Collectors.toSet()); + issue.setIssueAssignees(issueAssignees); + } + + // 다중 라벨 매핑 if (request.labels() != null && !request.labels().isEmpty()) { List