Skip to content

Commit

Permalink
feat: 게시글 목록 조회 구현 (#158)
Browse files Browse the repository at this point in the history
* feat: 골격 작성

* feat: 엔티티 작성

* feat: repository 작성

* feat: service 작성

* feat: response 작성

* feat: DB 조회 로직 작성

* feat: Jsoup 의존성 추가

* feat: 응답 내용 추가

1. article.is_notice
2. article.contentSummary
3. board.is_notice
4. board.children (must null)

* feat: 예외처리 작성

* refactor: 페이징 관련 정보 클래스 분리

* fix: soft delete된 게시판 or 게시글은 조회 안되도록 수정

* fix: 1페이지 조회 안되는 버그 수정

* fix: 누락된 응답 추가 (summary)

* fix: 최신글부터 조회하도록 수정

* test: 테스트 코드 작성

* move: ErrorResponseWrapper 위치 변경

* refactor: 코딩 컨벤션 준수

* refactor: 문자열 파싱 메서드 리팩토링

* test: 예외상황 테스트 추가
  • Loading branch information
songsunkook authored and Choi-JJunho committed May 9, 2024
1 parent d1c2332 commit 5e7a842
Show file tree
Hide file tree
Showing 13 changed files with 865 additions and 3 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'com.mysql:mysql-connector-j'
implementation 'org.jsoup:jsoup:1.15.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
runtimeOnly 'com.h2database:h2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package in.koreatech.koin.domain.community.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import in.koreatech.koin.domain.community.dto.ArticlesResponse;
import in.koreatech.koin.domain.community.service.CommunityService;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class CommunityController {

private final CommunityService communityService;

@GetMapping("/articles")
public ResponseEntity<ArticlesResponse> getArticles(
@RequestParam Long boardId,
@RequestParam(required = false) Long page,
@RequestParam(required = false) Long limit
) {
ArticlesResponse foundArticles = communityService.getArticles(boardId, page, limit);
return ResponseEntity.ok().body(foundArticles);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package in.koreatech.koin.domain.community.dto;

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

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

import in.koreatech.koin.domain.community.model.Article;
import in.koreatech.koin.domain.community.model.Board;

public record ArticlesResponse(
List<InnerArticleResponse> articles,
InnerBoardResponse board,
Long totalPage
) {

public static ArticlesResponse of(List<Article> articles, Board board, Long totalPage) {
return new ArticlesResponse(
articles.stream()
.map(InnerArticleResponse::from)
.toList(),
InnerBoardResponse.from(board),
totalPage
);
}

@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
private record InnerArticleResponse(
Long id,
Long boardId,
String title,
String content,
Long userId,
String nickname,
Long hit, String ip,
Boolean isSolved,
Boolean isDeleted,
Byte commentCount,
String meta,
Boolean isNotice,
Long noticeArticleId,
String summary,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt,
@JsonProperty("contentSummary") String contentSummary
) {

public static InnerArticleResponse from(Article article) {
return new InnerArticleResponse(
article.getId(),
article.getBoardId(),
article.getTitle(),
article.getContent(),
article.getUserId(),
article.getNickname(),
article.getHit(),
article.getIp(),
article.getIsSolved(),
article.getIsDeleted(),
article.getCommentCount(),
article.getMeta(),
article.getIsNotice(),
article.getNoticeArticleId(),
article.getSummary(),
article.getCreatedAt(),
article.getUpdatedAt(),
article.getContentSummary()
);
}
}

@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public record InnerBoardResponse(
Long id,
String tag,
String name,
Boolean isAnonymous,
Long articleCount,
Boolean isDeleted,
Boolean isNotice,
Long parentId,
Long seq,
List<InnerBoardResponse> children,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt
) {

public static InnerBoardResponse from(Board board) {
return new InnerBoardResponse(
board.getId(),
board.getTag(),
board.getName(),
board.getIsAnonymous(),
board.getArticleCount(),
board.getIsDeleted(),
board.getIsNotice(),
board.getParentId(),
board.getSeq(),
board.getChildren().isEmpty()
? null : board.getChildren().stream().map(InnerBoardResponse::from).toList(),
board.getCreatedAt(),
board.getUpdatedAt()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package in.koreatech.koin.domain.community.exception;

public class ArticleNotFoundException extends RuntimeException {
private static final String DEFAULT_MESSAGE = "게시글이 존재하지 않습니다.";

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

public static ArticleNotFoundException withDetail(String detail) {
String message = String.format("%s %s", DEFAULT_MESSAGE, detail);
return new ArticleNotFoundException(message);
}
}
127 changes: 127 additions & 0 deletions src/main/java/in/koreatech/koin/domain/community/model/Article.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package in.koreatech.koin.domain.community.model;

import org.hibernate.annotations.Where;
import org.jsoup.Jsoup;

import in.koreatech.koin.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(name = "articles")
@Where(clause = "is_deleted=0")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article extends BaseEntity {

private static final int SUMMARY_MIN_LENGTH = 0;
private static final int SUMMARY_MAX_LENGTH = 100;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;

@NotNull
@Column(name = "board_id", nullable = false)
private Long boardId;

@Size(max = 255)
@NotNull
@Column(name = "title", nullable = false)
private String title;

@NotNull
@Lob
@Column(name = "content", nullable = false)
private String content;

@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;

@Size(max = 50)
@NotNull
@Column(name = "nickname", nullable = false, length = 50)
private String nickname;

@NotNull
@Column(name = "hit", nullable = false)
private Long hit;

@Size(max = 45)
@NotNull
@Column(name = "ip", nullable = false, length = 45)
private String ip;

@NotNull
@Column(name = "is_solved", nullable = false)
private Boolean isSolved = false;

@NotNull
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;

@NotNull
@Column(name = "comment_count", nullable = false)
private Byte commentCount;

@Lob
@Column(name = "meta")
private String meta;

@NotNull
@Column(name = "is_notice", nullable = false)
private Boolean isNotice = false;

@Column(name = "notice_article_id")
private Long noticeArticleId;

@Transient
private String summary;

public String getContentSummary() {
if (content == null) {
return "";
}
String contentSummary = Jsoup.parse(content).text();
contentSummary = contentSummary.replace("&nbsp", "").strip();
if (contentSummary.length() < SUMMARY_MAX_LENGTH) {
return contentSummary;
}
return contentSummary.substring(SUMMARY_MIN_LENGTH, SUMMARY_MAX_LENGTH);
}

@Builder
private Article(Long boardId, String title, String content, Long userId, String nickname, Long hit,
String ip, Boolean isSolved, Boolean isDeleted, Byte commentCount, String meta, Boolean isNotice,
Long noticeArticleId) {
this.boardId = boardId;
this.title = title;
this.content = content;
this.userId = userId;
this.nickname = nickname;
this.hit = hit;
this.ip = ip;
this.isSolved = isSolved;
this.isDeleted = isDeleted;
this.commentCount = commentCount;
this.meta = meta;
this.isNotice = isNotice;
this.noticeArticleId = noticeArticleId;
}
}
84 changes: 84 additions & 0 deletions src/main/java/in/koreatech/koin/domain/community/model/Board.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package in.koreatech.koin.domain.community.model;

import java.util.ArrayList;
import java.util.List;

import org.hibernate.annotations.Where;

import in.koreatech.koin.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(name = "boards")
@Where(clause = "is_deleted=0")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;

@Size(max = 10)
@NotNull
@Column(name = "tag", nullable = false, length = 10)
private String tag;

@Size(max = 50)
@NotNull
@Column(name = "name", nullable = false, length = 50)
private String name;

@NotNull
@Column(name = "is_anonymous", nullable = false)
private Boolean isAnonymous = false;

@NotNull
@Column(name = "article_count", nullable = false)
private Long articleCount;

@NotNull
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;

@NotNull
@Column(name = "is_notice", nullable = false)
private Boolean isNotice = false;

@Column(name = "parent_id")
private Long parentId;

@NotNull
@Column(name = "seq", nullable = false)
private Long seq;

public List<Board> getChildren() {
return new ArrayList<>();
}

@Builder
private Board(String tag, String name, Boolean isAnonymous, Long articleCount, Boolean isDeleted,
Boolean isNotice, Long parentId, Long seq) {
this.tag = tag;
this.name = name;
this.isAnonymous = isAnonymous;
this.articleCount = articleCount;
this.isDeleted = isDeleted;
this.isNotice = isNotice;
this.parentId = parentId;
this.seq = seq;
}
}
Loading

0 comments on commit 5e7a842

Please sign in to comment.