Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
44187ad
ì임시 커밋
sungchaelee Aug 27, 2025
a78069b
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 27, 2025
edc4da9
[#1] feat: Comment 기능 개발
sungchaelee Aug 28, 2025
81c826e
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 28, 2025
1623197
[#1] Feat: Comment API 구현
sungchaelee Aug 28, 2025
3875582
[#1] Feat: Comment API 구현
sungchaelee Aug 28, 2025
c872b0e
[#1] Feat: Comment API 구현
sungchaelee Aug 28, 2025
99ce0b2
[#1] fix: 댓글 조회 기능 및 엔티티 버그 수정
sungchaelee Aug 28, 2025
8d9a6ef
[#1] feat: Comment 테스트코드 추가
sungchaelee Aug 29, 2025
33bb301
[#1] feat: Comment 테스트 코드 추가
sungchaelee Aug 29, 2025
3d8dc6a
[#1] feat: Comment 테스트 코드 추가
sungchaelee Aug 29, 2025
e1e7217
[#34] fix: CommentResponse 오류 수정
sungchaelee Aug 29, 2025
2bfc036
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 29, 2025
2f24770
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 29, 2025
05ce675
feat: comment API 개발
sungchaelee Aug 29, 2025
342f491
[#1] feat: Comment API 개발
sungchaelee Aug 29, 2025
64e2983
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 29, 2025
b258019
[#1] feat: Comment API 개발
sungchaelee Aug 29, 2025
5cd7b34
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 29, 2025
1d59a0b
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 29, 2025
34800a9
Merge branch 'dev' of https://github.com/sungchaelee/IssueDive into f…
sungchaelee Aug 29, 2025
4d4d3a8
[#1] fix: GlobalExceptionHandler 수정
sungchaelee Aug 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .gradle/8.14.3/checksums/checksums.lock
Binary file not shown.
Binary file not shown.
Binary file added .gradle/8.14.3/fileChanges/last-build.bin
Binary file not shown.
Binary file added .gradle/8.14.3/fileHashes/fileHashes.lock
Binary file not shown.
Empty file added .gradle/8.14.3/gc.properties
Empty file.
Binary file added .gradle/buildOutputCleanup/buildOutputCleanup.lock
Binary file not shown.
2 changes: 2 additions & 0 deletions .gradle/buildOutputCleanup/cache.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#Tue Aug 26 15:38:41 KST 2025
gradle.version=8.14.3
Empty file added .gradle/vcs-1/gc.properties
Empty file.
47 changes: 47 additions & 0 deletions HELP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Getting Started

### Reference Documentation
For further reference, please consider the following sections:

* [Official Gradle documentation](https://docs.gradle.org)
* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.5/gradle-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.5/gradle-plugin/packaging-oci-image.html)
* [Spring Boot Testcontainers support](https://docs.spring.io/spring-boot/3.5.5/reference/testing/testcontainers.html#testing.testcontainers)
* [Testcontainers MySQL Module Reference Guide](https://java.testcontainers.org/modules/databases/mysql/)
* [Spring Web](https://docs.spring.io/spring-boot/3.5.5/reference/web/servlet.html)
* [Validation](https://docs.spring.io/spring-boot/3.5.5/reference/io/validation.html)
* [Spring Security](https://docs.spring.io/spring-boot/3.5.5/reference/web/spring-security.html)
* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.5/reference/data/sql.html#data.sql.jpa-and-spring-data)
* [Flyway Migration](https://docs.spring.io/spring-boot/3.5.5/how-to/data-initialization.html#howto.data-initialization.migration-tool.flyway)
* [Spring Boot Actuator](https://docs.spring.io/spring-boot/3.5.5/reference/actuator/index.html)
* [Testcontainers](https://java.testcontainers.org/)

### Guides
The following guides illustrate how to use some features concretely:

* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
* [Validation](https://spring.io/guides/gs/validating-form-input/)
* [Securing a Web Application](https://spring.io/guides/gs/securing-web/)
* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/)
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
* [Building a RESTful Web Service with Spring Boot Actuator](https://spring.io/guides/gs/actuator-service/)
* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/)

### Additional Links
These additional references should also help you:

* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle)

### Testcontainers support

This project uses [Testcontainers at development time](https://docs.spring.io/spring-boot/3.5.5/reference/features/dev-services.html#features.dev-services.testcontainers).

Testcontainers has been configured to use the following Docker images:

* [`mysql:latest`](https://hub.docker.com/_/mysql)

Please review the tags of the used images and set them to the same as you're running in production.

Empty file modified gradlew
100755 → 100644
Empty file.
32 changes: 0 additions & 32 deletions src/main/java/com/example/issueDive/config/SecurityConfig.java

This file was deleted.

50 changes: 50 additions & 0 deletions src/main/java/com/issueDive/controller/CommentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.issueDive.controller;

import com.issueDive.dto.*;
import com.issueDive.service.CommentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/issues/{issueId}/comments")
public class CommentController {
private final CommentService commentService;

@GetMapping
public ResponseEntity<ApiResponse<List<CommentResponse>>> getComment(@PathVariable Long issueId){
List<CommentResponse> tree = commentService.getTreeByIssue(issueId);
return ResponseEntity.ok(ApiResponse.ok(tree));
}

@PostMapping
public ResponseEntity<ApiResponse<CommentResponse>> createComment(@PathVariable Long issueId, @RequestBody @Valid CreateCommentRequest request
,@RequestHeader("X-USER-ID") Long userId){

CommentResponse created = commentService.createComment(issueId, request, userId);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(created));
}

@PatchMapping("/{commentId}")
public ResponseEntity<ApiResponse<CommentResponse>> updateComment(@PathVariable Long issueId, @PathVariable Long commentId, @RequestBody @Valid UpdateCommentRequest request, @RequestHeader("X-USER-ID") Long userId){
CommentResponse updated = commentService.updateComment(issueId, commentId, request, userId);
return ResponseEntity.ok(ApiResponse.ok(updated));
}

@DeleteMapping("/{commentId}")
public ResponseEntity<?> deleteComment(@PathVariable Long issueId, @PathVariable Long commentId, @RequestHeader("X-USER-ID") Long userId){
commentService.deleteComment(issueId, commentId, userId);
return ResponseEntity.noContent().build();
}

@GetMapping("/count")
public ResponseEntity<ApiResponse<CountCommentResponse>> countComment(@PathVariable Long issueId){
CountCommentResponse response = commentService.countByIssue(issueId);
return ResponseEntity.ok(ApiResponse.ok(response));
}
}
81 changes: 81 additions & 0 deletions src/main/java/com/issueDive/dto/CommentResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.issueDive.dto;

import com.issueDive.entity.Comment;
import lombok.*;

import java.time.LocalDateTime;
import java.util.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CommentResponse {

private Long id;
private Long issueId;
private Long userId;
private String author;
private String description;
private Long parentId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

@Builder.Default
private List<CommentResponse> children = new ArrayList<>();

// 방어적 추가 헬퍼
public void addChild(CommentResponse child) {
if (children == null) children = new ArrayList<>();
children.add(child);
}

// 널-세이프 getter
public List<CommentResponse> getChildren() {
if (children == null) children = new ArrayList<>();
return children;
}

public static CommentResponse from(Comment comment){
return CommentResponse.builder()
.id(comment.getId())
.issueId(comment.getIssue().getId())
.userId(comment.getUser().getId())
.author(comment.getUser().getUsername())
.description(comment.getDescription())
.parentId(comment.getParent() != null ? comment.getParent().getId() : null)
.createdAt(comment.getCreatedAt())
.updatedAt(comment.getUpdatedAt())
.build();
}

public static List<CommentResponse> fromAllToTree(List<Comment> comments){
Map<Long, CommentResponse> map = new LinkedHashMap<>();
List<CommentResponse> roots = new ArrayList<>();

// 1) 엔티티 → DTO 변환 & 인덱싱
for (Comment c : comments) {
map.put(c.getId(), from(c));
}

// 2) 부모-자식 연결
for (Comment c : comments) {
Long pId = (c.getParent() != null) ? c.getParent().getId() : null;
CommentResponse dto = map.get(c.getId());

if (pId == null) {
roots.add(dto);
} else {
CommentResponse parent = map.get(pId);
if (parent != null) {
parent.addChild(dto);
} else {
// 부모 누락 방어: 루트에 승격(정책에 맞게 조정 가능)
roots.add(dto);
}
}
}
return roots;
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/issueDive/dto/CountCommentResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.issueDive.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@AllArgsConstructor
public class CountCommentResponse {
private Long issueId;
private Long count;
}
12 changes: 12 additions & 0 deletions src/main/java/com/issueDive/dto/CreateCommentRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.issueDive.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CreateCommentRequest {
@NotBlank
private String description;
private Long parentId;
}
13 changes: 13 additions & 0 deletions src/main/java/com/issueDive/dto/UpdateCommentRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.issueDive.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UpdateCommentRequest {

@NotBlank
private String description;
}
52 changes: 52 additions & 0 deletions src/main/java/com/issueDive/entity/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.issueDive.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "comments")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Comment {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "issue_id", nullable = false)
private Issue issue;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Lob
@Column(nullable = false)
private String description;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;

@Builder.Default
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> children = new ArrayList<>();

@Column(name = "created_at", updatable = false, insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime createdAt;

@Column(name = "updated_at", insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
private LocalDateTime updatedAt;

public void changeDescription(String description) { this.description = description; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.issueDive.exception;

public class CommentNotFoundException extends RuntimeException{

public CommentNotFoundException(String message) {
super(message);
}

public CommentNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
2 changes: 1 addition & 1 deletion src/main/java/com/issueDive/exception/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.issueDive.exception;

public enum ErrorCode {
ValidationError, InvalidQueryParam, Unauthorized, Forbidden, InternalServerError,
ValidationError, InvalidQueryParam, Unauthorized, Forbidden, BadRequest, InternalServerError,
IssueNotFound, InvalidStatus,

LabelNotFound, IssueLabelNotFound, DuplicateLabel,
Expand Down
35 changes: 30 additions & 5 deletions src/main/java/com/issueDive/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,40 @@ public ResponseEntity<ErrorResponse> handleBaseException(BaseException ex) {
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage()));
}

@ExceptionHandler(CommentNotFoundException.class)
public ResponseEntity<ErrorResponse> handleCommentNotFound(CommentNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.CommentNotFound, e.getMessage()));
}

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.UserNotFound, e.getMessage()));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(ErrorCode.BadRequest, e.getMessage()));
}

@ExceptionHandler(SecurityException.class)
public ResponseEntity<ErrorResponse> handleForbidden(SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(ErrorCode.Forbidden, e.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(ErrorCode.BadRequest, e.getMessage()));
}

@ExceptionHandler(IssueLabelNotFoundException.class)
public ResponseEntity<ErrorResponse> handleIssueLabelNotFound(IssueLabelNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.IssueLabelNotFound, e.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
return ResponseEntity.badRequest()
.body(ErrorResponse.of(ErrorCode.ValidationError, "입력 값이 올바르지 않습니다."));
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/issueDive/repository/CommentRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.issueDive.repository;
import com.issueDive.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface CommentRepository extends JpaRepository<Comment, Long> {

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.issue.id = :issueId")
List<Comment> findAllByIssueIdWithUser(@Param("issueId") Long issueId);

long countByIssueId(Long issueId);


}
Loading
Loading