Project/project-board

[Project] 게시글, 댓글 비즈니스 로직, 페이지 구현

수수한개발자 2022. 7. 14.
728x90

지금까지 Entity, API, QueryDsl, DTO 까지 구현을 했습니다. 이제 이에 맞게 게시판, 댓글 컨트롤러, 뷰, 서비스클래스를 만들어 테스트까지 해보도록하겠습니다.

 

ArticleController

 

RequiredArgsConstructor
@RequestMapping("/articles")
@Controller
public class ArticleController {

    private final ArticleService articleService;

    @GetMapping
    public String articles(@RequestParam(required = false) SearchType searchType,
                           @RequestParam(required = false) String searchValue,
                           @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
                           ModelMap modelMap) {
        modelMap.addAttribute("articles", articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::from));
        return "articles/index";
    }

    @GetMapping("/{articleId}")
    public String article(@PathVariable Long articleId, ModelMap modelMap) {
        ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticle(articleId));
        modelMap.addAttribute("article", article);
        modelMap.addAttribute("articleComments", article.getArticleCommentResponse());
        return "articles/detail";
    }
}

 

ArticleRepository

 

@RepositoryRestResource
public interface ArticleRepository extends JpaRepository<Article, Long>, QuerydslPredicateExecutor<Article>, QuerydslBinderCustomizer<QArticle> {

    Page<Article> findByTitleContaining(String title, Pageable pageable);
    Page<Article> findByContentContaining(String content, Pageable pageable);
    Page<Article> findByUserAccount_UserIdContaining(String userId, Pageable pageable);
    Page<Article> findByUserAccount_NicknameContaining(String nickname, Pageable pageable);
    Page<Article> findByHashtag(String hashtag, Pageable pageable);
}

이렇게 검색을 통해서 결과값을 받았을때 Page타입으로 리턴받을 수 있게 메소드를 만들어 줍니다.

 

 

ArticleService

@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {

    private final ArticleRepository articleRepository;
    private Page<ArticleDto> map;

    @Transactional(readOnly = true)
    public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
        if (searchKeyword == null || searchKeyword.isBlank()) {
            return articleRepository.findAll(pageable).map(ArticleDto::from);
        }

        switch (searchType) {
            case TITLE:
                map = articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from); break;
            case CONTENT:
                map = articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from); break;
            case ID:
                map = articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from); break;
            case NICKNAME:
                map = articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from); break;
            case HASHTAG:
                map = articleRepository.findByHashtag("#" + searchKeyword, pageable).map(ArticleDto::from); break;

        }

        return map;
    }

    @Transactional(readOnly = true)
    public ArticleWithCommentsDto getArticle(Long articleId) {
        return articleRepository.findById(articleId)
                .map(ArticleWithCommentsDto::from)
                .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
    }

    public void saveArticle(ArticleDto dto) {
        articleRepository.save(dto.toEntity());

    }

    public void updateArticle(ArticleDto dto) {
        try {
            Article article = articleRepository.getReferenceById(dto.getId());
            if (dto.getTitle() != null) {
                article.setTitle(dto.getTitle());
            }
            if (dto.getContent() != null) {
                article.setContent(dto.getContent());
            }
            article.setHashtag(dto.getHashtag());
        } catch (EntityNotFoundException e) {
            log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다. -dto: {}", dto);
        }
    }

    public void deleteArticle(long articleId) {
        articleRepository.deleteById(articleId);

    }
}

searchArticles :  enum 타입의 searchType을 받아서 검색에 따라 조회를 해와서 map을 리턴해주는 구조입니다.

getArticle : @Transactional(readonly=true) 속성을 줘서 조회전용이다.게시글을 id로 하나 가져온다.

saveArticle : 게시글을 등록,저장하는 메서드

updatArticle : 게시글을 업데이트하는 메서드

deleteArticle : 삭제하는 메서드

 

Index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="Uno Kim">
    <title>게시판 페이지</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
    <link href="/css/search-bar.css" rel="stylesheet">
</head>

<body>

    <header id="header">
        헤더 삽입부
        <hr>
    </header>

<main class="container">

    <div class="row">
        <div class="card card-margin search-form">
            <div class="card-body p-0">
                <form id="card search-form">
                    <div class="row">
                        <div class="col-12">
                            <div class="row no-gutters">
                                <div class="col-lg-3 col-md-3 col-sm-12 p-0">
                                    <label for="search-type" hidden>검색 유형</label>
                                    <select class="form-control" id="search-type">
                                        <option>제목</option>
                                        <option>본문</option>
                                        <option>id</option>
                                        <option>닉네임</option>
                                        <option>해시태그</option>
                                    </select>
                                </div>
                                <div class="col-lg-8 col-md-6 col-sm-12 p-0">
                                    <label for="search-value" hidden>검색어</label>
                                    <input type="text" placeholder="검색어..." class="form-control" id="search-value" name="search-value">
                                </div>
                                <div class="col-lg-1 col-md-3 col-sm-12 p-0">
                                    <button type="submit" class="btn btn-base">
                                        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search">
                                            <circle cx="11" cy="11" r="8"></circle>
                                            <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
                                        </svg>
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    </div>

    <table class="table" id="article-table">
        <thead>
        <tr>
            <th class="title col-6">제목</th>
            <th class="hashtag col-2">해시태그</th>
            <th class="user-id col">작성자</th>
            <th class="created-by col">작성일</th>
        </tr>
        </thead>
        <tbody>
        <tr>
            <td class="title"><a>첫글</a></td>
            <td class="hashtag">#java</td>
            <td class="user-id">jisu</td>
            <td class="created-at"><time>2022-01-01</time></td>
        </tr>
        <tr>
            <td>두번째글</td>
            <td>#spring</td>
            <td>jisu</td>
            <td><time>2022-01-02</time></td>
        </tr>
        <tr>
            <td>세번째글</td>
            <td>#java</td>
            <td>jisu</td>
            <td><time>2022-01-03</time></td>
        </tr>
        </tbody>
    </table>

    <nav aria-label="Page navigation example">
        <ul class="pagination justify-content-center">
            <li class="page-item"><a class="page-link" href="#">Previous</a></li>
            <li class="page-item"><a class="page-link" href="#">1</a></li>
            <li class="page-item"><a class="page-link" href="#">2</a></li>
            <li class="page-item"><a class="page-link" href="#">3</a></li>
            <li class="page-item"><a class="page-link" href="#">Next</a></li>
        </ul>
    </nav>
</main>

    <footer id="footer">
        <hr>
        푸터 삽입부
    </footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>

 

Index.th.html

<?xml version="1.0"?>
<thlogic>
    <attr sel="#header" th:replace="header :: header"></attr>
    <attr sel="#footer" th:replace="footer :: footer"></attr>

    <attr sel="#article-table">
        <attr sel="tbody" th:remove="all-but-first">
            <attr sel="tr[0]" th:each="article : ${articles}">
                <attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
                <attr sel="td.hashtag" th:text="${article.hashtag}" />
                <attr sel="td.user-id" th:text="${article.nickname}" />
                <attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
            </attr>
        </attr>
    </attr>
</thlogic>

index.th.html에서 id = #article-table 인 속성을 찾아서 tbody를 지웁니다. 그리고 th:each를 통해 반복하면 컨트롤러에서 넘긴 articles를 가져와 그에 맞게 title, hashtag, nickname 속성을 찾아 넣어줍니다.

 

ArticleCommentRepository

@RepositoryRestResource
public interface ArticleCommentRepository extends JpaRepository<ArticleComment, Long>, QuerydslPredicateExecutor<ArticleComment>, QuerydslBinderCustomizer<QArticleComment> {

    List<ArticleComment> findByArticle_Id(Long articleId);

    @Override
    default void customize(QuerydslBindings bindings, QArticleComment root) {
        bindings.excludeUnlistedProperties(true);
        bindings.including(root.content, root.createdAt, root.createdBy);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}

 

 

ArticleCommentService

@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {

    private final ArticleRepository articleRepository;
    private final ArticleCommentRepository articleCommentRepository;

    @Transactional(readOnly = true)
    public List<ArticleCommentDto> searchArticleComments(Long articleId) {
        return articleCommentRepository.findByArticle_Id(articleId)
                .stream()
                .map(ArticleCommentDto::from).collect(toList());
    }
    public void saveArticleComment(ArticleCommentDto dto) {
        try {
            articleCommentRepository.save(dto.toEntity(articleRepository.getReferenceById(dto.getArticleId())));
        } catch (EntityNotFoundException e) {
            log.warn("댓글 저장 실패. 댓글의 게시글을 찾을 수 없습니다 - dto: {}", dto);
        }
    }

    public void updateArticleComment(ArticleCommentDto dto) {
        try {
            ArticleComment articleComment = articleCommentRepository.getReferenceById(dto.getId());
            if (dto.getContent() != null) { articleComment.setContent(dto.getContent()); }
        } catch (EntityNotFoundException e) {
            log.warn("댓글 업데이트 실패. 댓글을 찾을 수 없습니다 - dto: {}", dto);
        }
    }

    public void deleteArticleComment(Long articleCommentId) {
        articleCommentRepository.deleteById(articleCommentId);
    }

}

 

 

detail.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="Uno Kim">
    <title>게시글 페이지</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
    <link href="/css/articles/article-content.css" rel="stylesheet">
</head>

<body>

    <header id="header">
        헤더 삽입부
        <hr>
    </header>

<main id="article-main" class="container">
    <header id="article-header" class="py-5 text-center">
        <h1>첫번째 글</h1>
    </header>

    <div class="row g-5">
        <section class="col-md-5 col-lg-4 order-md-last">
            <aside>
                <p><span id="nickname">jisu</span></p>
                <p><a id="email" href="mailto:djkehh@gmail.com">jisu3316@naver.com</a></p>
                <p><time id="created-at" datetime="2022-01-01T00:00:00">2022-01-01</time></p>
                <p><span id="hashtag">#java</span></p>
            </aside>
        </section>

        <article id="article-content" class="col-md-7 col-lg-8">
            <pre>본문</pre>
        </article>
    </div>

    <div class="row g-5">
        <section>
            <form class="row g-3">
                <div class="col-8">
                    <label for="comment-textbox" hidden>댓글</label>
                    <textarea class="form-control" id="comment-textbox" placeholder="댓글 쓰기.." rows="3"></textarea>
                </div>
                <div class="col-auto">
                    <label for="comment-submit" hidden>댓글 쓰기</label>
                    <button class="btn btn-primary" id="comment-submit" type="submit">쓰기</button>
                </div>
            </form>

            <ul id="article-comments" class="row col-7">
                <li>
                    <div>
                        <strong>jisu</strong>
                        <small><time>2022-01-01</time></small>
                        <p>
                            Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
                            Lorem ipsum dolor sit amet
                        </p>
                    </div>
                </li>
                <li>
                    <div>
                        <strong>jisu</strong>
                        <small><time>2022-01-01</time></small>
                        <p>
                            Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
                            Lorem ipsum dolor sit amet
                        </p>
                    </div>
                </li>
            </ul>

        </section>
    </div>

    <div class="row g-5">
        <nav id="pagination" aria-label="Page navigation">
            <ul class="pagination">
                <li class="page-item">
                    <a class="page-link" href="#" aria-label="Previous">
                        <span aria-hidden="true">&laquo; prev</span>
                    </a>
                </li>
                <li class="page-item">
                    <a class="page-link" href="#" aria-label="Next">
                        <span aria-hidden="true">next &raquo;</span>
                    </a>
                </li>
            </ul>
        </nav>
    </div>
</main>

    <footer id="footer">
        <hr>
        푸터 삽입부
    </footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
</html>

 

detail.th.xml

<?xml version="1.0"?>
<thlogic>
    <attr sel="#header" th:replace="header :: header"></attr>
    <attr sel="#footer" th:replace="footer :: footer"></attr>

    <attr sel="#article-main" th:object="${article}">
        <attr sel="#article-header/h1" th:text="*{title}" />
        <attr sel="#nickname" th:text="*{nickname}" />
        <attr sel="#email" th:text="*{email}" />
        <attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
        <attr sel="#hashtag" th:text="*{hashtag}" />
        <attr sel="#article-content/pre" th:text="*{content}" />

    <attr sel="#article-comments" th:remove="all-but-first">
        <attr sel="li[0]" th:each="articleComment : ${articleComments}">
            <attr sel="div/strong" th:text="${articleComment.nickname}" />
            <attr sel="div/small/time" th:datetime="${articleComment.createdAt}" th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
            <attr sel="div/p" th:text="${articleComment.content}" />
        </attr>
    </attr>

    <attr sel="#pagination">
    <attr sel="ul">
        <attr sel="li[0]/a"
              th:href="*{id} - 1 <= 0 ? '#' : |/articles/*{id - 1}|"
              th:class="'page-link' + (*{id} - 1 <= 0 ? ' disabled' : '')"/>
        <attr sel="li[1]/a"
              th:href="*{id} + 1 > ${totalCount} ? '#' : |/articles/*{id + 1}|"
              th:class="'page-link' + (*{id} + 1 > ${totalCount} ? ' disabled' : '')"/>
    </attr>
    </attr>
</attr>
</thlogic>

detail.th.xml 에서 위의는 게시글과 댓글의 대한 내용이고 밑에는 이전 게시글 다음 게시글에 대한 설정만 잡아놓은 상태이다.  이제 다음글인 페이지네이션에서 구현을 하면된다.

 

게시판이라 비즈니스로직이라고 해봤자 별게 없다.... 

728x90

'Project > project-board' 카테고리의 다른 글

[Project] 게시판 검색기능  (0) 2022.07.15
[Project] 게시글 페이징 처리 구현, 정렬 및 테스트  (0) 2022.07.14
[Project] DTO 설계  (0) 2022.07.14
[Project] QueryDSL 적용  (0) 2022.07.11
[Project] API 설계  (0) 2022.07.11

댓글