diff --git a/build.gradle b/build.gradle index 9adf091..53875e7 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' runtimeOnly 'com.h2database:h2' + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { @@ -39,5 +40,6 @@ tasks.named('test') { } springBoot { - mainClass = 'com.example.demo.DemoApplication' + mainClass = 'com.example.demo1.Demo1Application' } + diff --git a/src/main/java/com/example/demo1/Demo1Application.java b/src/main/java/com/example/demo1/Demo1Application.java index de3082e..bd3ba0f 100644 --- a/src/main/java/com/example/demo1/Demo1Application.java +++ b/src/main/java/com/example/demo1/Demo1Application.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -@EnableJpaAuditing // 생성일/수정일 자동 생성을 위해 꼭 필요해요! -@SpringBootApplication // (exclude = ...) 부분을 지워서 DB 자동 설정을 활성화합니다. +@EnableJpaAuditing // 생성일/수정일 자동 기록을 위해 꼭 필요합니다! +@SpringBootApplication +@EnableJpaRepositories(basePackages = "com.example.demo1") +@EntityScan(basePackages = "com.example.demo1") public class Demo1Application { public static void main(String[] args) { SpringApplication.run(Demo1Application.class, args); diff --git a/src/main/java/com/example/demo1/domain/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/demo1/domain/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0fb9a67 --- /dev/null +++ b/src/main/java/com/example/demo1/domain/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,34 @@ +package com.example.demo1.domain.global.exception; + +import com.example.demo1.domain.global.response.BaseResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 1. 유효성 검사 실패 처리 (제목/내용 빈 값 등 - 코드 4002) [cite: 449, 479] + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationException(MethodArgumentNotValidException e) { + Map errors = new HashMap<>(); + e.getBindingResult().getFieldErrors().forEach(error -> + errors.put(error.getField(), error.getDefaultMessage())); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(BaseResponse.onFailure("4002", "잘못된 요청입니다.", errors)); + } + + // 2. 리소스를 찾을 수 없을 때 처리 (게시글 없음 - 코드 4040) [cite: 436, 480] + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity>> handleNotFoundException(NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(BaseResponse.onFailure("4040", e.getMessage(), null)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/global/response/BaseResponse.java b/src/main/java/com/example/demo1/domain/global/response/BaseResponse.java new file mode 100644 index 0000000..211a4f7 --- /dev/null +++ b/src/main/java/com/example/demo1/domain/global/response/BaseResponse.java @@ -0,0 +1,26 @@ +package com.example.demo1.domain.global.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class BaseResponse { + private final Boolean isSuccess; + private final String code; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) // 결과값이 null이면 JSON에 포함하지 않음 + private T result; + + // 성공 응답 정적 팩토리 메서드 + public static BaseResponse onSuccess(String code, String message, T result) { + return new BaseResponse<>(true, code, message, result); + } + + // 실패 응답 정적 팩토리 메서드 + public static BaseResponse onFailure(String code, String message, T result) { + return new BaseResponse<>(false, code, message, result); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/controller/PostController.java b/src/main/java/com/example/demo1/domain/post/controller/PostController.java new file mode 100644 index 0000000..65f50dd --- /dev/null +++ b/src/main/java/com/example/demo1/domain/post/controller/PostController.java @@ -0,0 +1,50 @@ +package com.example.demo1.domain.post.controller; + +import com.example.demo1.domain.global.response.BaseResponse; +import com.example.demo1.domain.post.dto.PostRequestDto; +import com.example.demo1.domain.post.dto.PostResponseDto; +import com.example.demo1.domain.post.service.PostService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + // [API 1] 목록 조회 + @GetMapping + public BaseResponse> getPosts() { + return BaseResponse.onSuccess("2000", "게시글 목록 조회 성공", postService.findAll()); + } + + // [API 2] 상세 조회 (findById 대신 getPostDetail로 수정) + @GetMapping("/{postId}") + public BaseResponse getPost(@PathVariable Long postId) { + return BaseResponse.onSuccess("2001", "게시글 조회 성공", postService.getPostDetail(postId)); + } + + // [API 3] 작성 + @PostMapping + public BaseResponse createPost(@Valid @RequestBody PostRequestDto requestDto) { + return BaseResponse.onSuccess("2010", "게시글 생성 성공", postService.save(requestDto)); + } + + // [API 4] 수정 + @PutMapping("/{postId}") + public BaseResponse updatePost(@PathVariable Long postId, @Valid @RequestBody PostRequestDto requestDto) { + return BaseResponse.onSuccess("2002", "게시글 수정 성공", postService.update(postId, requestDto)); + } + + // [API 5] 삭제 + @DeleteMapping("/{postId}") + public BaseResponse deletePost(@PathVariable Long postId) { + postService.delete(postId); + return BaseResponse.onSuccess("2003", "게시글 삭제 성공", "삭제된 게시글 ID: " + postId); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/dto/PostBlockDto.java b/src/main/java/com/example/demo1/domain/post/dto/PostBlockDto.java new file mode 100644 index 0000000..190616d --- /dev/null +++ b/src/main/java/com/example/demo1/domain/post/dto/PostBlockDto.java @@ -0,0 +1,16 @@ +package com.example.demo1.domain.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostBlockDto { + private String blockType; + private String textContent; + private String imageUrl; +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/dto/PostRequestDto.java b/src/main/java/com/example/demo1/domain/post/dto/PostRequestDto.java new file mode 100644 index 0000000..5f77ef1 --- /dev/null +++ b/src/main/java/com/example/demo1/domain/post/dto/PostRequestDto.java @@ -0,0 +1,25 @@ +package com.example.demo1.domain.post.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class PostRequestDto { + + private Long userId; // 작성 시 필요 + + @NotBlank(message = "제목은 비어 있을 수 없습니다.") + private String title; + + @NotBlank(message = "내용은 비어 있을 수 없습니다.") + private String content; + + @NotBlank(message = "설명은 비어 있을 수 없습니다.") + private String description; + + private List blocks; +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/dto/PostResponseDto.java b/src/main/java/com/example/demo1/domain/post/dto/PostResponseDto.java new file mode 100644 index 0000000..c099162 --- /dev/null +++ b/src/main/java/com/example/demo1/domain/post/dto/PostResponseDto.java @@ -0,0 +1,24 @@ +package com.example.demo1.domain.post.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import lombok.Getter; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class PostResponseDto { + private Long postId; + private String title; + private String content; + private String description; + private String authorNickname; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") // 명세서 포맷 준수 + private LocalDateTime createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime updatedAt; + private List blocks; +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/entity/Post.java b/src/main/java/com/example/demo1/domain/post/entity/Post.java index 843442b..ba452b1 100644 --- a/src/main/java/com/example/demo1/domain/post/entity/Post.java +++ b/src/main/java/com/example/demo1/domain/post/entity/Post.java @@ -19,10 +19,10 @@ public class Post extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; + private Long id; // [★체크] Service와 타입을 맞추기 위해 Integer 대신 Long 사용을 권장합니다. @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) + @JoinColumn(name = "user_id", nullable = true) // [★체크] 테스트 편의를 위해 일단 nullable = true로 수정했습니다. private User user; @Column(length = 255) @@ -37,4 +37,12 @@ public class Post extends BaseEntity { @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) private List comments = new ArrayList<>(); + + // [★추가] 게시글 수정을 위한 핵심 메서드입니다. + // 이 메서드가 있어야 PostService의 50라인 에러가 사라집니다. + public void update(String title, String content, String description) { + this.title = title; + this.content = content; + this.description = description; + } } \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/repository/PostRepository.java b/src/main/java/com/example/demo1/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..c0a2136 --- /dev/null +++ b/src/main/java/com/example/demo1/domain/post/repository/PostRepository.java @@ -0,0 +1,10 @@ +package com.example.demo1.domain.post.repository; + +import com.example.demo1.domain.post.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository // [★필수] 스프링이 이 인터페이스를 데이터 저장소로 인식하게 합니다. +public interface PostRepository extends JpaRepository { + // 아무 내용도 적지 않아도 기본 CRUD 기능이 작동합니다. +} \ No newline at end of file diff --git a/src/main/java/com/example/demo1/domain/post/service/PostService.java b/src/main/java/com/example/demo1/domain/post/service/PostService.java new file mode 100644 index 0000000..fecadd0 --- /dev/null +++ b/src/main/java/com/example/demo1/domain/post/service/PostService.java @@ -0,0 +1,109 @@ +package com.example.demo1.domain.post.service; + +import com.example.demo1.domain.post.dto.PostBlockDto; +import com.example.demo1.domain.post.dto.PostRequestDto; +import com.example.demo1.domain.post.dto.PostResponseDto; +import com.example.demo1.domain.post.entity.Post; +import com.example.demo1.domain.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + + // 1. 목록 조회 로직 (엔티티 리스트를 DTO 리스트로 변환해서 반환하는 것이 정석입니다) + public List findAll() { + return postRepository.findAll().stream() + .map(post -> PostResponseDto.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .description(post.getDescription()) + .authorNickname("jae") + .createdAt(post.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + // 2. 상세 조회 로직 + public PostResponseDto getPostDetail(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NoSuchElementException("해당 게시글을 찾을 수 없습니다.")); + + List mockBlocks = List.of( + PostBlockDto.builder() + .blockType("TEXT") + .textContent("본문 내용입니다.") + .imageUrl(null) + .build() + ); + + return PostResponseDto.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .description(post.getDescription()) + .authorNickname("jae") + .createdAt(post.getCreatedAt()) + .build(); + } + + // 3. 작성 로직 (하나로 통합함!) + @Transactional + public PostResponseDto save(PostRequestDto dto) { + Post post = Post.builder() + .title(dto.getTitle()) + .content(dto.getContent()) + .description(dto.getDescription()) + .build(); + + Post savedPost = postRepository.save(post); + + return PostResponseDto.builder() + .postId(savedPost.getId()) + .title(savedPost.getTitle()) + .content(savedPost.getContent()) + .description(savedPost.getDescription()) + .authorNickname("yuchan") + .createdAt(savedPost.getCreatedAt() != null ? savedPost.getCreatedAt() : LocalDateTime.now()) + .blocks(dto.getBlocks()) + .build(); + } + + // 4. 수정 로직 + @Transactional + public PostResponseDto update(Long postId, PostRequestDto dto) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NoSuchElementException("해당 게시글을 찾을 수 없습니다.")); + + post.update(dto.getTitle(), dto.getContent(), dto.getDescription()); + + return PostResponseDto.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .description(post.getDescription()) + .authorNickname("jae") + .createdAt(post.getCreatedAt()) + .build(); + } + + // 5. 삭제 로직 + @Transactional + public void delete(Long postId) { + if (!postRepository.existsById(postId)) { + throw new NoSuchElementException("해당 게시글을 찾을 수 없습니다."); + } + postRepository.deleteById(postId); + } +} \ No newline at end of file