Compare commits

77 Commits
dev ... main

Author SHA1 Message Date
jinia91
41e0043222 메타 태그 수정 2022-07-01 22:25:47 +09:00
jinia91
bd0a8a8ed1 api어노테이션 등록 2022-06-29 01:01:08 +09:00
jinia91
0d34fb8e7a 배포 설정 변경 2022-06-09 23:15:11 +09:00
jinia91
ee0638fd3c 배포 테스트 2022-06-09 23:05:42 +09:00
jinia91
2f39e0cae3 깃 커밋 오류 해결 2022-06-06 22:52:33 +09:00
Wonjin-Choi
b1639ce59d git action 미작동 확인 2022-06-03 21:17:50 +09:00
Wonjin-Choi
98dc605da6 test 2022-06-03 21:12:01 +09:00
Wonjin-Choi
63d570613a badge test 2022-06-03 21:05:27 +09:00
Wonjin-Choi
b663b398a8 about page test 2022-06-03 14:20:15 +09:00
Wonjin-Choi
ddf39edf21 빌드 깨지는 테스트코드 수정 2022-06-03 11:06:16 +09:00
Wonjin-Choi
c997853456 git 계정 변경 test 2022-06-02 19:59:07 +09:00
jinia91
05ddace884 dto 매퍼 분리 2022-05-27 20:19:31 +09:00
jinia91
b9a5196972 테스트코드 보완, 데이터 validation 보완 2022-05-26 23:00:00 +09:00
jinia91
e777d6f99f 리드미 갱신 2022-05-19 01:40:13 +09:00
jinia91
7bcdfe8c21 회원가입 로직 가독성 리팩토링 2022-05-18 23:02:45 +09:00
jinia91
f5b55d03d8 쉘스크립트 롤백 2022-05-15 21:48:11 +09:00
jinia91
2b78cb1205 스크립트 수정 2022-05-14 23:45:05 +09:00
jinia91
8ebe8f89b0 코드 정리 및 무중단 테스트 2022-05-14 23:36:53 +09:00
jinia91
62ac5ce43a 캐시 기능 롤백 2022-05-13 03:28:54 +09:00
jinia91
efa8b4fbd4 모델 매퍼 맵 스트럭쳐로 스택 마이그레이션 2022-05-07 23:49:51 +09:00
jinia91
b25425afee 캐시 설정 제거 2022-05-02 01:19:47 +09:00
jinia91
5c4aaace00 캐시 제거 2022-05-02 01:17:32 +09:00
jinia91
f7476e96ef 캐시 정책이 없을때 메모리 누수 발생하는지 테스트 2022-05-02 01:14:11 +09:00
jinia91
1a037d021e 메모리 누수 추적을 위한 캐시 설정 변경 2022-05-02 01:07:00 +09:00
jinia91
3d390dd672 MD 수정 2022-04-30 15:42:46 +09:00
jinia91
a45924474d 적용되지 않는 인덱스 생성 어노테이션 제거 2022-04-30 14:00:29 +09:00
jinia91
351fa14579 코드 정리 2022-04-25 21:56:07 +09:00
jinia91
ea604f4c5c 예외정리 2022-04-23 21:15:12 +09:00
jinia91
ec08a08d55 테스트 코드 수정: 2022-04-22 00:01:00 +09:00
jinia91
fffd4c60a3 예외 정의, 모니터링을 위한 시스템 구축작업 2022-04-21 23:08:52 +09:00
jinia91
95d995694e 리팩토링 2022-04-16 22:49:10 +09:00
jinia91
92ecb456e2 리팩토링 2022-04-15 00:59:25 +09:00
jinia91
6e1ec4c1d8 리팩토링 2022-04-14 22:56:36 +09:00
jinia91
c3218a0b8a cicd 최종 테스트 2022-04-09 00:11:10 +09:00
jinia91
07623323cb cd 2022-04-08 23:58:13 +09:00
jinia91
52cd14e1cf cd 2022-04-08 23:50:41 +09:00
jinia91
6cd4556f5e cd try 2022-04-08 23:37:04 +09:00
jinia91
85e8a54ff3 cicd cd try 2022-04-08 23:28:58 +09:00
jinia91
d5f9867f64 cicd 2022-04-08 23:22:25 +09:00
jinia91
2bae29b841 cicd 2022-04-08 23:20:26 +09:00
jinia91
0d946cd400 cicd 2022-04-08 23:18:50 +09:00
jinia91
bfba55e75d cicd 2022-04-08 23:18:12 +09:00
jinia91
1c36c22a14 cicd 2022-04-08 23:15:34 +09:00
jinia91
5f69d3eca5 cicd 2022-04-08 23:14:32 +09:00
jinia91
26b0cc4dcb ci 2nd 2022-04-08 23:12:10 +09:00
jinia91
62abd6fc4c 깃 액션 ci 스택 마이그레이션 테스트 2022-04-08 23:07:09 +09:00
jinia91
ed0e29b2a5 hotfix 2022-04-08 21:29:07 +09:00
jinia91
3aa18022a1 리팩토링, 퍼사드 패턴 고려중 2022-04-08 20:20:37 +09:00
jinia91
917c21bbca infrastructure layer domain인 slice가 뷰단까지 침투하는 부분을 리팩토링, page에 대한 고민중 2022-04-07 22:14:26 +09:00
jinia91
a755438cc4 센트리 부착 2022-04-06 20:47:25 +09:00
jinia91
b3ca77e897 qdsl 작업중 2022-04-02 19:51:29 +09:00
jinia91
7a53589937 QDSL 적용, 기존 JPQL QDSL로 변경, 동적 쿼리 작성 2022-04-01 22:51:55 +09:00
jinia91
6912ca24ff 테스트코드 보강 2022-03-30 23:12:15 +09:00
jinia91
8bea12f6fd 동시성 취약코드 개선 2022-03-29 00:01:58 +09:00
jinia91
1a718b189b hotfix/ 검색이 안되는 문제 해결 2022-03-28 23:44:13 +09:00
jinia91
84850a8eb6 추가 리아키텍쳐링 2022-03-28 00:33:22 +09:00
jinia91
9cef2ddef3 리드미 업데이트 2022-03-28 00:04:21 +09:00
jinia91
5f73a8e4f3 리드미 업데이트 2022-03-27 23:56:00 +09:00
jinia91
a1e80852d4 modelmapper 불필요한 스프링 의존성 제거, 스레드 세이프한 객체이므로 static final로 상태를 가져도 안전 2022-03-27 01:16:39 +09:00
jinia91
2c480220ac cqs 아키텍쳐 적용 2022-03-27 01:03:02 +09:00
jinia91
2ff9132756 패키지명 변경 2022-03-27 00:36:18 +09:00
jinia91
76cd25e11f 헥사고날 아키텍쳐로 리아키텍쳐링 마무리, DDD를 적용하여 두꺼운 도메인, 얇은 서비스로 리팩토링 2022-03-27 00:30:18 +09:00
jinia91
527edda336 imgupload, comment 모듈 헥사고날 아키텍쳐로 리아키텍쳐링 2022-03-26 22:22:26 +09:00
jinia91
dc233daf71 유지보수를 위해 에러 콜스택 로깅 포맷 변경 2022-03-25 00:17:30 +09:00
jinia91
1f2efe2da3 카테고리 도메인 리아키텍쳐링 2022-03-24 23:54:34 +09:00
jinia91
47ef05cebf hotfix/ 카테고리별 아티클 데이터 퍼오는 로직 롤백 2022-03-24 00:33:56 +09:00
jinia91
a775d69af6 빌드 장애 해결 2022-03-24 00:06:58 +09:00
jinia91
8e76b351bf Merge branch 'dev' 2022-03-24 00:04:55 +09:00
jinia91
1a58449cd3 기존 이미지 업로드 서버를 깃헙 레포로 쓰던걸 aws s3로 스택 마이그레이션, 전략패턴을 사용해 유연한 확장 실천 2022-03-17 22:22:34 +09:00
jinia91
80450a16a7 이미지 업로드 서비스 고도화, 업로드 전략 유연한 변경을 위한 전략 패턴 사용 2022-03-16 23:15:54 +09:00
jinia91
4d968d1b7b 아이디 변경 실수로 다시 커밋 기입 2022-03-14 23:23:58 +09:00
jinia91
ce2109915d 로깅 모듈 개선 2022-03-14 23:16:09 +09:00
jinia91
4c9ab85c5c 장애 해결 2022-03-13 08:43:47 +09:00
jinia91
ca5572803e 배포가 안되는 장애 발생 2022-03-13 08:27:33 +09:00
jinia91
d6aa2c3da9 로그 테스트 2022-03-13 08:13:23 +09:00
jinia91
1afb52af3b 로그 기능 개선 2022-03-13 08:05:09 +09:00
jinia91
3ffe39a24f 구글 애널리틱스 추가 2022-03-12 23:41:17 +09:00
174 changed files with 3178 additions and 2115 deletions

49
.github/workflows/cicd-branch-main.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
name: cicd
jobs:
build:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: checkout src
uses: actions/checkout@v2
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod 777 gradlew
shell: bash
- name: Clean build test with Gradle # clean build test
run: ./gradlew clean build test
shell: bash
- name: Make zip file # zip 파일 생성
run: zip -r ./springboot-blog.zip .
shell: bash
- name: Configure AWS credentials # AWS 자격 증명
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Upload to S3 # S3 업로드
run: aws s3 cp --region ${{ secrets.AWS_REGION }} ./springboot-blog.zip s3://${{ secrets.AWS_S3_BUCKET }}/springboot-blog.zip
- name: Code Deploy # CodeDeploy에 배포 요청
run: aws deploy create-deployment --application-name ${{ secrets.AWS_CODEDEPLOY_NAME }} --deployment-config-name CodeDeployDefault.AllAtOnce --deployment-group-name ${{ secrets.AWS_CODEDEPLOY_GROUP }} --s3-location bucket=${{ secrets.AWS_S3_BUCKET }},bundleType=zip,key=springboot-blog.zip

View File

@@ -1,62 +0,0 @@
language: java
jdk:
- openjdk11
branches:
only:
- main
# travis CI 서버 home
cache:
directories:
- '$HOME/.m2/repository'
- '$HOME/.gradle'
script: "./gradlew clean build"
before_install:
- chmod +x gradlew
before_deploy:
- mkdir -p before-deploy
- cp scripts/*.sh before-deploy/
- cp appspec.yml before-deploy/
- chmod +x build/libs/*.jar
- cp build/libs/*.jar before-deploy/
- cd before-deploy && zip -r before-deploy ./*
- cd ../ && mkdir -p deploy
- mv before-deploy/before-deploy.zip deploy/springboot-blog.zip
deploy:
- provider: s3
access_key_id: $AWS_ACESS_KEY
secret_access_key: $AWS_SECRET_KEY
bucket: blog-build-bucket
region: ap-northeast-2
skip_cleanup: true
acl: private
local_dir: deploy
wait-until-deployed: true
on:
all_branches: true
- provider: codedeploy
access_key_id: $AWS_ACESS_KEY
secret_access_key: $AWS_SECRET_KEY
bucket: blog-build-bucket
key: springboot-blog.zip
bundle_type: zip
application: springboot-blog
deployment_group: springboot-blog-group
region: ap-northeast-2
wait-until-deployed: true
on:
all_branches: true
# CI 실행 완료 시 메일로 알람
notifications:
email:
recipients:
- creee77@gmail.com

130
README.md
View File

@@ -13,10 +13,12 @@ https://www.jiniaslog.co.kr/
* [기타 주요 라이브러리](#기타-주요-라이브러리)
- [핵심 키워드](#핵심-키워드)
- [시스템 아키텍쳐](#시스템-아키텍쳐)
- [WAS 아키텍처](#WAS-아키텍처)
- [E-R 다이어그램](#e-r-다이어그램)
- [프로젝트 목적](#프로젝트-목적)
* [블로그 프로젝트를 기획한 이유?](#블로그-프로젝트를-기획한-이유?)
- [핵심 기능](#핵심-기능)
* [헥사고날 아키텍처로 리아키텍처링](#헥사고날-아키텍처로-리아키텍처링)
* [소셜 로그인](#소셜-로그인)
* [로그 추적기](#로그-추적기)
* [반응형 웹](#반응형-웹)
@@ -61,6 +63,7 @@ https://www.jiniaslog.co.kr/
- Spring Data JPA
- Mybatis
- EhCache
- Qdsl
#### Build tool
- Gradle
@@ -71,7 +74,8 @@ https://www.jiniaslog.co.kr/
#### Infra
- AWS EC2
- AWS S3
- Travis CI
- ~~Travis CI~~
- Github Actions
- AWS CodeDeploy
- AWS Route53
@@ -85,6 +89,7 @@ https://www.jiniaslog.co.kr/
- Lombok
- Github-api
- Toast Ui Editor
- MapStruct
## 핵심 키워드
@@ -92,10 +97,21 @@ https://www.jiniaslog.co.kr/
- AWS / 리눅스 기반 CI/CD 무중단 배포 인프라 구축
- JPA, Hibernate를 사용한 도메인 설계
- MVC 프레임워크 기반 백엔드 서버 구축
- 헥사고날 아키텍처
## 시스템 아키텍쳐
![image](https://github.com/jinia91/blogBackUp/blob/main/img/57d1dfd7-22c1-4a5f-b6d5-ef635ae49307.png?raw=true)
## WAS 아키텍처
### 헥사고날 아키텍처
![image](https://jinia-img-bucket.s3.ap-northeast-2.amazonaws.com/424ffacf-ec59-477b-af1a-93ed3971bbb2.png)
### 패키징 구조 예시
![image](https://jinia-img-bucket.s3.ap-northeast-2.amazonaws.com/9672bd14-4f0f-463c-ae85-3acd4d14fe2e.png)
## E-R 다이어그램
![image](https://github.com/jinia91/blogBackUp/blob/main/img/ff867940-efae-4040-ad47-5707e51d8865.png?raw=true)
@@ -124,21 +140,75 @@ https://www.jiniaslog.co.kr/
앞으로 제 프로그래밍 공부와 개발 기록, 그리고 유지보수를 같이할 블로그를 만들어 보기로 결정했습니다.
## 핵심 기능
## 핵심 기능(릴리즈 후 추가 개선)
### 소셜 로그인
소셜 로그인 구현을 위해 스프링 시큐리티와 OAuth2 인증방식을 사용했으며,
### 메모리가 부족한 프리티어 환경에서 원활한 운영을 위해 Swap으로 가상 메모리 설정
소셜 인증 제공자 추가로 인한 확장을 대비해 엑세스 토큰으로 받아오는 유저 정보를 OAuth2UserInfo 인터페이스로 추상화하여 파싱하도록 설계했습니다.
(2022.05.12)
[Oauth2UserInfo인터페이스](https://github.com/jinia91/blog/blob/a1d9381d8675ef01fbe3cf7371fe642a1847a943/src/main/java/myblog/blog/member/auth/userinfo/Oauth2UserInfo.java#L8)
프리티어환경에서 서버를 운영하면서 주기적으로 서버가 죽는 이슈가 있었고, 해당 이슈를 트러블 슈팅하며 가상메모리 설정을 진행하였습니다.
또한 확장성 있는 객체 생성을 위해, 객체 생성을 담당하는 클래스를 익명 인터페이스를 사용한 팩토리 메서드 패턴으로 구현하였습니다.
해당 과정은 아래 링크 블로그 글을 통해 확인하실수 있습니다.
[UserInfoFactory 클래스](https://github.com/jinia91/blog/blob/a1d9381d8675ef01fbe3cf7371fe642a1847a943/src/main/java/myblog/blog/member/auth/UserInfoFactory.java#L18)
[[트러블 슈팅]프리티어 환경에서 서버가 주기적으로 죽는 문제](https://www.jiniaslog.co.kr/article/view?articleId=1602)
### 로그 추적기(2022.02.03 기능 추가)
### MapStruct를 사용해 보일러 플레이트 코드 제거
(2022.05.07)
기존에는 리플렉션을 통해 객체 매핑을 해주는 `ModelMapper` 를 사용하였으나,
런타임 시점에서 매번 리플렉션을 사용하며 객체에 접근하고 생성하는 메커니즘의한계, 커뮤니티들에서 report 되는 메모리 누수 이슈등을 보며 고민하다
인터페이스만 정의해주면 컴파일시점에서 매핑 코드를 자동으로 구현해주는 `MapStruct` 라이브러리로 스택 마이그레이션을 진행하였습니다.
[기술조사 결과`MapSturct` 라이브러리가 성능적으로 훨씬 뛰어나다고 판단하였으며(링크)](https://better-dev.netlify.app/java/2020/10/26/compare_objectmapper/),
해당 코드는 커밋내역으로 확인할 수 있습니다.
- [커밋내역](https://github.com/jinia91/blog/commit/efa8b4fbd41e7cdeccb56d959e356ad0ae1c935c)
### JPQL로 작성된 기존 쿼리 Qdsl로 스택 마이그레이션
(진행중)
기존에는 JPA 메서드 쿼리와 JPQL, 그리고 마이바티스를 사용해 영속성 계층을 구현했습니다. 그중 JPQL은 String 기반으로 작성되어
컴파일단계에서 에러검증이 되지 않으며, 동적 쿼리 작성에 어려움이 있던 단점이 있었고 실제로 동적 쿼리를 작성하지 못해
두가지 메서드 쿼리를 분기처리하는 방식으로 구현한 코드도 존재했습니다.
Qdsl을 학습후 기존 JPQL을 걷어내고 Qdsl로 스택 마이그레이션을 진행하는 중입니다.
### CI 툴 트래비스에서 github 액션으로 스택 마이그레이션
(2022.04.09)
트래비스가 org에서 com으로 변경된뒤 기존 무료 정책에서 구독형 유료 모델로 변경되었으며
무료 credit을 모두 사용한 후 월 요금 30달러를 지불해야 하는 상황을 맞이했습니다.
개인적으로 개인프로젝트에서 서버도 아니고 ci 툴을 사용하기 위해 비용을 지불하는것은 불필요하다고 판단했고, 코드 관리가 github에서 이루어지는 만큼
ci역시 같은 github내에서 진행되는것이 보다 바람직하다고 판단하여 github actions로 스택마이그레이션을 진행하였습니다.
### 헥사고날 아키텍처로 리아키텍처링
(2022-03-27 추가)
기존 아키텍처는 레이어드 아키텍처 기반에 어설프게 DDD를 적용한 난잡한 구조였습니다. 유지관리에 용이하고 확장에 유연한 보다 꺠끗한 아키텍처구축을 위해
헥사고날 아키텍처와 클린아키텍처의 개념을 공부하고 프로젝트 구조를 개선하는 작업을 진행했습니다.
해당과정에서 고민한점과 공부했던 부분들은 아래 블로그에 정리하였습니다.
[헥사고날 아키텍처 학습과 프로젝트 적용](https://www.jiniaslog.co.kr/article/view?articleId=1152)
### 로그 추적기
(2022.02.03 기능 추가)
스프링 AOP 기술을 사용하여 프로젝트의 *Controller, *Service, *Repository 에 포인트컷을 지정, 로그를 찍고
@@ -158,6 +228,9 @@ https://www.jiniaslog.co.kr/
AOP 학습과 해당 기능 개발을 위해 [인프런, 김영한님의 스프링 핵심 원리 - 고급편](https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard) 을 참고했습니다.
## 핵심 기능(릴리즈 전)
### 반응형 웹
부트스트랩을 이용하여 작은 모바일 환경은 물론 태블릿 대형 화면에서도 문제없이 작동하는 반응형 웹을 구현하였습니다.
@@ -165,6 +238,18 @@ AOP 학습과 해당 기능 개발을 위해 [인프런, 김영한님의 스프
![구글 서치콘솔 모바일 친화 인증](https://github.com/jinia91/blogBackUp/blob/main/img/bf4c2ba2-2446-47c1-bcdd-f69157bf4d29.png?raw=true)
[구글 서치 콘솔에서 모바일 친화페이지 인증]
### 소셜 로그인
소셜 로그인 구현을 위해 스프링 시큐리티와 OAuth2 인증방식을 사용했으며,
소셜 인증 제공자 추가로 인한 확장을 대비해 엑세스 토큰으로 받아오는 유저 정보를 OAuth2UserInfo 인터페이스로 추상화하여 파싱하도록 설계했습니다.
[Oauth2UserInfo인터페이스](https://github.com/jinia91/blog/blob/a1d9381d8675ef01fbe3cf7371fe642a1847a943/src/main/java/myblog/blog/member/auth/userinfo/Oauth2UserInfo.java#L8)
또한 확장성 있는 객체 생성을 위해, 객체 생성을 담당하는 클래스를 익명 인터페이스를 사용한 팩토리 메서드 패턴으로 구현하였습니다.
[UserInfoFactory 클래스](https://github.com/jinia91/blog/blob/a1d9381d8675ef01fbe3cf7371fe642a1847a943/src/main/java/myblog/blog/member/auth/UserInfoFactory.java#L18)
### Toast Ui editor
#### 글작성은 마크다운으로
@@ -173,7 +258,8 @@ AOP 학습과 해당 기능 개발을 위해 [인프런, 김영한님의 스프
![마크다운 편집](https://github.com/jinia91/blogBackUp/blob/main/img/080e9414-2691-461f-b0d1-7590bf562e20.png?raw=true)
#### 이미지와 썸네일 삽입시는 깃허브 이미지 서버로
#### 이미지와 썸네일 삽입시는 ~~깃허브~~ aws s3 이미지 서버로
(2022.03.21)
Toast Ui editor는 기본적으로 컨텐츠 내의 이미지 삽입을 blob으로 컨텐츠와 함께 병기하게 되는데 이경우 장황한 바이너리 코드로 DB에 부담이 되고
@@ -181,10 +267,17 @@ Toast Ui editor는 기본적으로 컨텐츠 내의 이미지 삽입을 blob으
1. 이미지 삽입시 후킹으로 blob 코드를 낚아챈 후
2. 아작스로 해당 이미지 blob을 앱 서버로 보내기
3. 앱서버에서 깃허브 api를 사용해 깃허브 레포지토리에 업로드
4. 그리고 이미지의 blob대신 업로드된 url을 반환
위 과정을 통해 깃 레포지토리를 이미지 서버로 활용하고 url만 사용하게끔 로직을 작성했습니다.
3. ~~3. 앱서버에서 깃허브 api를 사용해 깃허브 레포지토리에 업로드~~
4. was에서 aws s3로 업로드
5. 그리고 이미지의 blob대신 업로드된 url을 반환
깃헙 레포를 사용할경우 이미지 업로드시 무의미한 커밋이 찍혀 전략 패턴을 사용해 업로드 로직을 분리, 고도화한다음 aws S3로 업로드 서버를 변경하였습니다.
해당 과정은 아래 포스팅주소에서 확인할 수 있습니다.
[이미지 업로드를 전략패턴으로 재설계한뒤 aws s3로 전환하기](https://www.jiniaslog.co.kr/article/view?articleId=1103)
[아작스로 이미지 업로드](https://github.com/jinia91/blog/blob/a1d9381d8675ef01fbe3cf7371fe642a1847a943/src/main/resources/static/js/thumbnail.js#L13)
@@ -309,14 +402,10 @@ Validated로 유효성 빈검사를 수행하여 실패 원자성을 유지하
tagify 라이브러리를 사용하여 태그 기능을 구현하였고 태그나 게시물 컨텐츠의 특정 문자열에 대하여 검색하는 기능을 만들었습니다.
이때 보다 빠른검색을 위해 게시글의 내용 컨텐츠 타입을 varchar로 저장하고 인덱스를 걸었습니다.
검색 쿼리의 경우 "Like %s%" 를 사용하였기에 인덱스를 사용하지 못하기 때문에
검색 쿼리의 경우 "Like %s%" 를 사용하였기에 인덱스를 제대로 활용하여 레인지 스캔이 타지지는 않지만 인덱스 풀스캔은 타지기 때문에 인덱스가 없는것보다는 낫다고 판단했습니다.
차후 성능상 문제가 있을 경우 형태소 분석기를 설치하여 mysql의 FTS기능을 지원하도록 개선해볼 예정입니다.
차후 성능상 문제가 있을 경우 형태소 분석기를 설치하여 mysql의 FTS기능을 지원하도록 리팩토링할 예정입니다.
![](https://github.com/jinia91/blogBackUp/blob/main/img/8c600c59-fe7c-496a-9867-0d71288bb2b5.png?raw=true)
[검색 쿼리시 풀 인덱스 스캔이 타지는 모습]
### 오프셋 페이징을 사용한 페이징박스와 커서 페이징을 사용한 무한 스크롤
@@ -419,7 +508,10 @@ tagify 라이브러리를 사용하여 태그 기능을 구현하였고 태그
## 프로젝트 관련 추가 포스팅
- [기존 프로젝트 헥사고날 아키텍쳐로 리아키텍쳐링하기](https://www.jiniaslog.co.kr/article/view?articleId=1152)
- [전략패턴을 적용하여 이미지 업로드 모듈을 새로운 전략으로 스택 마이그레이션하기](https://www.jiniaslog.co.kr/article/view?articleId=1103)
- [스프링 JPA 환경에서 오프셋 페이징을 커서 페이징으로 개선하기](https://www.jiniaslog.co.kr/article/view?articleId=202)
- [스프링에서 캐시 사용하여 프로젝트 성능 개선해보기](https://www.jiniaslog.co.kr/article/view?articleId=254)
- [[CI/CD 무중단배포 프로젝트 적용하기] CI? CD? 기본 개념잡기 (1)](https://www.jiniaslog.co.kr/article/view?articleId=303)
- [운영환경에서 정적 리소스의 버전관리와 브라우저의 캐시 문제](https://www.jiniaslog.co.kr/article/view?articleId=402)
- [프리티어 환경에서 서버가 주기적으로 죽는 문제](https://www.jiniaslog.co.kr/article/view?articleId=1602)

View File

@@ -13,17 +13,16 @@ permissions:
hooks:
AfterInstall:
- location: stop.sh
- location: scripts/stop.sh
timeout: 600
runas: root
ApplicationStart:
- location: start.sh
- location: scripts/start.sh
timeout: 600
runas: root
ValidateService:
- location: health.sh
- location: scripts/health.sh
timeout: 600
runas: root
runas: root

View File

@@ -2,7 +2,7 @@ plugins {
id 'org.springframework.boot' version '2.5.6'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id "org.jetbrains.kotlin.jvm" version "1.5.0-RC"
// id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
id "com.github.node-gradle.node" version "3.1.0"
id 'java'
@@ -44,9 +44,8 @@ dependencies {
implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
implementation 'io.sentry:sentry-spring-boot-starter:5.6.1'
// implementation 'com.querydsl:querydsl-jpa'
implementation 'com.querydsl:querydsl-jpa'
implementation 'com.github.node-gradle:gradle-node-plugin:3.1.0'
implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.4.4'
implementation group: 'org.kohsuke', name: 'github-api', version: '1.133'
implementation group: 'org.apache.commons', name: 'commons-text', version: '1.9'
implementation group: 'com.atlassian.commonmark', name: 'commonmark', version: '0.17.0'
@@ -54,6 +53,12 @@ dependencies {
implementation 'org.commonmark:commonmark-ext-gfm-tables:0.18.0'
implementation group: 'org.jdom', name: 'jdom2', version: '2.0.6'
implementation group: 'net.sf.ehcache', name: 'ehcache', version: '2.10.9.2'
implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.152'
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
annotationProcessor'org.projectlombok:lombok-mapstruct-binding:0.2.0'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
@@ -78,22 +83,21 @@ test {
useJUnitPlatform()
}
// 쿼리DSL 설정
//def querydslDir = "$buildDir/generated/querydsl"
//
//querydsl {
// jpa = true
// querydslSourcesDir = querydslDir
//}
//sourceSets {
// main.java.srcDir querydslDir
//}
//configurations {
// querydsl.extendsFrom compileClasspath
//}
//compileQuerydsl {
// options.annotationProcessorPath = configurations.querydsl
//}
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
compileTestKotlin {

View File

@@ -24,6 +24,7 @@ then # $up_count >= 1 ("real" 문자열이 있는지 검증)
else
echo " > Health check의 응답을 알 수 없거나 혹은 실행상태가 아닙니다 "
echo "> Health check: ${RESPONSE}"
sleep 10
fi
if [ ${RETRY_COUNT} -eq 10 ]

View File

@@ -16,6 +16,6 @@ then
else
echo "> kill -15 $IDLE_PID"
kill -15 ${IDLE_PID}
sleep 5
sleep 50
fi

View File

@@ -0,0 +1,221 @@
package myblog.blog.article.adapter.incomming;
import myblog.blog.article.application.port.incomming.ArticleUseCase;
import myblog.blog.article.application.port.incomming.TempArticleUseCase;
import myblog.blog.article.application.port.incomming.ArticleQueriesUseCase;
import myblog.blog.article.application.port.incomming.TagsQueriesUseCase;
import myblog.blog.category.appliacation.port.incomming.CategoryQueriesUseCase;
import myblog.blog.category.domain.CategoryNotFoundException;
import myblog.blog.shared.application.port.incomming.LayoutRenderingUseCase;
import myblog.blog.article.application.port.incomming.request.ArticleCreateCommand;
import myblog.blog.article.application.port.incomming.request.ArticleEditCommand;
import myblog.blog.category.appliacation.port.incomming.response.CategoryViewForLayout;
import myblog.blog.member.application.port.incomming.response.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import myblog.blog.shared.utils.MetaTagBuildUtils;
import org.jsoup.Jsoup;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
@Controller
@RequiredArgsConstructor
public class ArticleController {
private final ArticleUseCase articleUseCase;
private final ArticleQueriesUseCase articleQueriesUseCase;
private final TempArticleUseCase tempArticleUseCase;
private final TagsQueriesUseCase tagsQueriesUseCase;
private final CategoryQueriesUseCase categoryQueriesUseCase;
private final LayoutRenderingUseCase layoutRenderingUseCase;
@GetMapping("article/write")
String getArticleWriteForm(Model model) {
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("categoryInput", categoryQueriesUseCase.findCategoryByTier(2));
model.addAttribute("tagsInput", tagsQueriesUseCase.findAllTagDtos());
model.addAttribute("articleDto", new ArticleForm());
return "article/articleWriteForm";
}
@PostMapping("article/write")
@Transactional
String writeArticle(@Validated ArticleForm articleForm,
@AuthenticationPrincipal PrincipalDetails principal,
Errors errors, Model model) {
if (errors.hasErrors()) getArticleWriteForm(model);
var command = ArticleCreateCommand.from(articleForm, principal.getMemberId());
var articleId = articleUseCase.writeArticle(command);
articleUseCase.backupArticle(articleId);
tempArticleUseCase.deleteTemp();
return "redirect:/article/view?articleId=" + articleId;
}
/*
- 아티클 수정 폼 조회
*/
@GetMapping("/article/edit")
String updateArticle(@RequestParam Long articleId, Model model) {
var articleDto = articleQueriesUseCase.getArticleForEdit(articleId);
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("categoryInput", categoryQueriesUseCase.findCategoryByTier(2));
model.addAttribute("tagsInput", tagsQueriesUseCase.findAllTagDtos());
model.addAttribute("articleDto", articleDto);
return "article/articleEditForm";
}
@PostMapping("/article/edit")
@Transactional
String editArticle(@RequestParam Long articleId,
@ModelAttribute ArticleForm articleForm) {
var command = ArticleEditCommand.from(articleId, articleForm);
articleUseCase.editArticle(command);
return "redirect:/article/view?articleId=" + articleId;
}
@PostMapping("/article/delete")
@Transactional
String deleteArticle(@RequestParam Long articleId) {
articleUseCase.deleteArticle(articleId);
return "redirect:/";
}
/*
- 카테고리별 게시물 조회하기
*/
@Transactional
@GetMapping("article/list")
String getArticlesListByCategory(@RequestParam String category,
@RequestParam int tier,
@RequestParam int page,
Model model) {
int totalArticleCnt = getTotalArticleCntByCategory(category, categoryQueriesUseCase.getCategoryViewForLayout());
var pagingBoxHandler =
PagingBoxHandler.createOf(page, totalArticleCnt);
var articleDtoList = articleQueriesUseCase.getArticlesByCategory(category, tier, pagingBoxHandler.getCurPageNum());
for(var articleDto : articleDtoList) articleDto.parseAndRenderForView();
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("pagingBox", pagingBoxHandler);
model.addAttribute("articleList", articleDtoList);
return "article/articleList";
}
private int getTotalArticleCntByCategory(String category, CategoryViewForLayout categorys) {
if (categorys.getTitle().equals(category)) return categorys.getCount();
for (var categoryCnt : categorys.getCategoryTCountList()) {
if (categoryCnt.getTitle().equals(category)) return categoryCnt.getCount();
for (var categoryCntSub : categoryCnt.getCategoryTCountList()) {
if (categoryCntSub.getTitle().equals(category)) return categoryCntSub.getCount();
}
}
throw new CategoryNotFoundException();
}
/*
- 태그별 게시물 조회하기
*/
@GetMapping("article/list/tag/")
String getArticlesListByTag(@RequestParam Integer page,
@RequestParam String tagName,
Model model) {
var articleList = articleQueriesUseCase.getArticlesByTag(tagName, page);
for(var article : articleList) article.parseAndRenderForView();
var pagingBoxHandler = PagingBoxHandler.createOf(page, (int)articleList.getTotalElements());
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("articleList", articleList);
model.addAttribute("pagingBox", pagingBoxHandler);
return "article/articleListByTag";
}
/*
- 검색어별 게시물 조회하기
*/
@GetMapping("article/list/search/")
String getArticlesListByKeyword(@RequestParam Integer page,
@RequestParam String keyword,
Model model) {
var articleList = articleQueriesUseCase.getArticlesByKeyword(keyword, page);
for(var article : articleList) article.parseAndRenderForView();
var pagingBoxHandler = PagingBoxHandler.createOf(page, (int)articleList.getTotalElements());
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("articleList", articleList);
model.addAttribute("pagingBox", pagingBoxHandler);
return "article/articleListByKeyword";
}
/*
- 아티클 상세 조회
1. 로그인여부 검토
2. 게시물 상세조회에 필요한 Dto 전처리
3. 메타태그 작성위한 Dto 전처리
4. Dto 담기
5. 조회수 증가 검토
*/
@Transactional
@GetMapping("/article/view")
String readArticle(@RequestParam Long articleId,
@AuthenticationPrincipal PrincipalDetails principal,
@CookieValue(required = false, name = "view") String cookie,
HttpServletResponse response, Model model) {
addMemberInfoToModel(principal, model);
var articleResponseForDetail = articleQueriesUseCase.getArticleForDetail(articleId);
articleResponseForDetail.parseAndRenderForView();
var articleTitlesSortByCategory = articleQueriesUseCase
.getArticlesByCategoryForDetailView(articleResponseForDetail.getCategory());
var metaTags = MetaTagBuildUtils.buildMetaTags(articleResponseForDetail.getTags());
var substringContents = getSubStringContentsFrom(articleResponseForDetail.getContent());
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("article", articleResponseForDetail);
model.addAttribute("metaTags",metaTags);
model.addAttribute("metaContents",Jsoup.parse(substringContents).text());
model.addAttribute("articlesSortBycategory", articleTitlesSortByCategory);
if(needToAddHitThroughCheckingCookie(articleId, cookie, response)) articleUseCase.addHit(articleId);
return "article/articleView";
}
private void addMemberInfoToModel(PrincipalDetails principal, Model model) {
if (principal != null) {
model.addAttribute("member", principal.getMember());
} else {
model.addAttribute("member", null);
}
}
private String getSubStringContentsFrom(String content) {
String substringContents = null;
if(content.length()>200) {
substringContents = content.substring(0, 200);
}
else substringContents = content;
return substringContents;
}
/*
- 쿠키 추가 / 조회수 증가 검토
*/
private boolean needToAddHitThroughCheckingCookie(Long articleId, String cookie, HttpServletResponse response) {
if (cookie == null) {
Cookie viewCookie = new Cookie("view", articleId + "/");
viewCookie.setComment("게시물 조회 확인용");
viewCookie.setMaxAge(60 * 60);
response.addCookie(viewCookie);
return true;
} else {
boolean addHitAvailable = false;
boolean isRead = false;
String[] viewCookieList = cookie.split("/");
for (String alreadyRead : viewCookieList) {
if (alreadyRead.equals(String.valueOf(articleId))) {
isRead = true;
break;
}
}
if (!isRead) {
cookie += articleId + "/";
addHitAvailable = true;
}
response.addCookie(new Cookie("view", cookie));
return addHitAvailable;
}
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.adapter.incomming.web;
package myblog.blog.article.adapter.incomming;
import lombok.Getter;
import lombok.Setter;

View File

@@ -0,0 +1,51 @@
package myblog.blog.article.adapter.incomming;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForCardBox;
import myblog.blog.article.application.port.incomming.ArticleQueriesUseCase;
import myblog.blog.shared.application.port.incomming.LayoutRenderingUseCase;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static myblog.blog.shared.utils.MarkdownUtils.*;
@Controller
@RequiredArgsConstructor
public class MainController {
private final ArticleQueriesUseCase articleQueriesUseCase;
private final LayoutRenderingUseCase layoutRenderingUseCase;
/*
- 메인 화면 제어용 컨트롤러
*/
@GetMapping("/")
String main(Model model) {
var popularArticles = articleQueriesUseCase.getPopularArticles();
layoutRenderingUseCase.AddLayoutTo(model);
model.addAttribute("popularArticles", popularArticles);
return "index";
}
/*
- 최신 아티클 무한스크롤로 조회
*/
@GetMapping("/main/article/{lastArticleId}")
@ResponseBody List<ArticleResponseForCardBox> mainNextPage(@PathVariable(required = false) Long lastArticleId) {
var articles = articleQueriesUseCase.getRecentArticles(lastArticleId);
for(var article : articles) article.parseAndRenderForView();
return articles;
}
/*
* - about me page
* */
@GetMapping("/aboutMe")
String aboutMe(Model model) {
layoutRenderingUseCase.AddLayoutTo(model);
return "aboutMe";
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.adapter.incomming.web;
package myblog.blog.article.adapter.incomming;
import lombok.Getter;
import lombok.Setter;

View File

@@ -0,0 +1,28 @@
package myblog.blog.article.adapter.incomming;
import myblog.blog.article.application.port.incomming.TempArticleUseCase;
import myblog.blog.article.application.port.incomming.response.TempArticleDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/*
- 임시 게시물 조회, 저장을 위한 rest 컨트롤러
*/
@RestController
@RequiredArgsConstructor
public class TempArticleController {
private final TempArticleUseCase tempArticleUseCase;
@PostMapping("/article/temp/autoSave")
public String autoSaveTemp(@RequestBody TempArticleDto tempArticleDto){
tempArticleUseCase.saveTemp(tempArticleDto.getContent());
return "저장성공";
}
@GetMapping("/article/temp/getTemp")
public @ResponseBody TempArticleDto getTempArticle(){
return tempArticleUseCase.getTempArticle();
}
}

View File

@@ -1,305 +0,0 @@
package myblog.blog.article.adapter.incomming.web;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.incomming.ArticleUseCase;
import myblog.blog.article.application.port.incomming.TempArticleUseCase;
import myblog.blog.article.application.port.incomming.ArticleQueriesUseCase;
import myblog.blog.article.application.port.incomming.TagsQueriesUseCase;
import myblog.blog.article.application.port.request.ArticleCreateRequest;
import myblog.blog.article.application.port.request.ArticleEditRequest;
import myblog.blog.article.application.port.response.ArticleResponseByCategory;
import myblog.blog.article.application.port.response.ArticleResponseForCardBox;
import myblog.blog.article.application.port.response.ArticleResponseForDetail;
import myblog.blog.article.application.port.response.ArticleResponseForEdit;
import myblog.blog.category.service.CategoryService;
import myblog.blog.category.dto.*;
import myblog.blog.member.auth.PrincipalDetails;
import myblog.blog.member.dto.MemberVo;
import myblog.blog.shared.queries.LayoutRenderingQueries;
import org.jsoup.Jsoup;
import org.modelmapper.ModelMapper;
import org.springframework.data.domain.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.stream.Collectors;
import static myblog.blog.shared.utils.MarkdownUtils.*;
@Controller
@RequiredArgsConstructor
public class ArticleController {
private final ArticleUseCase articleUseCase;
private final ArticleQueriesUseCase articleQueriesUseCase;
private final TempArticleUseCase tempArticleUseCase;
private final TagsQueriesUseCase tagsQueriesUseCase;
private final CategoryService categoryService;
private final LayoutRenderingQueries layoutRenderingQueries;
private final ModelMapper modelMapper;
@GetMapping("article/write")
String getArticleWriteForm(Model model) {
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("categoryInput", getCategoryDtosForForm());
model.addAttribute("tagsInput", tagsQueriesUseCase.findAllTagDtos());
model.addAttribute("articleDto", new ArticleForm());
return "article/articleWriteForm";
}
/*
- 아티클 작성 post 요청
*/
@PostMapping("article/write")
@Transactional
String writeArticle(@Validated ArticleForm articleForm,
@AuthenticationPrincipal PrincipalDetails principal,
Errors errors, Model model) {
if (errors.hasErrors()) {
getArticleWriteForm(model);
}
Long articleId = articleUseCase.writeArticle(ArticleCreateRequest.from(articleForm,principal.getMemberId()));
articleUseCase.backupArticle(articleId);
tempArticleUseCase.deleteTemp();
return "redirect:/article/view?articleId=" + articleId;
}
/*
- 아티클 수정 폼 조회
*/
@GetMapping("/article/edit")
String updateArticle(@RequestParam Long articleId, Model model) {
ArticleResponseForEdit articleDto = articleQueriesUseCase.getArticleForEdit(articleId);
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("categoryInput", getCategoryDtosForForm());
model.addAttribute("tagsInput", tagsQueriesUseCase.findAllTagDtos());
model.addAttribute("articleDto", articleDto);
return "article/articleEditForm";
}
/*
- 아티클 수정 요청
*/
@PostMapping("/article/edit")
@Transactional
String editArticle(@RequestParam Long articleId,
@ModelAttribute ArticleForm articleForm) {
articleUseCase.editArticle(ArticleEditRequest.from(articleId, articleForm));
return "redirect:/article/view?articleId=" + articleId;
}
/*
- 아티클 삭제 요청
*/
@PostMapping("/article/delete")
@Transactional
String deleteArticle(@RequestParam Long articleId) {
articleUseCase.deleteArticle(articleId);
return "redirect:/";
}
/*
- 카테고리별 게시물 조회하기
*/
@Transactional
@GetMapping("article/list")
String getArticlesListByCategory(@RequestParam String category,
@RequestParam Integer tier,
@RequestParam Integer page,
Model model) {
PagingBoxHandler pagingBoxHandler =
PagingBoxHandler.createOf(page, getTotalArticleCntByCategory(category, categoryService.getCategoryForView()));
Slice<ArticleResponseForCardBox> articleDtoList =
articleQueriesUseCase.getArticlesByCategory(category, tier, pagingBoxHandler.getCurPageNum());
for(ArticleResponseForCardBox articleDto : articleDtoList){
articleDto.setContent(Jsoup.parse(getHtmlRenderer().render(getParser().parse(articleDto.getContent()))).text());
}
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("pagingBox", pagingBoxHandler);
model.addAttribute("articleList", articleDtoList);
return "article/articleList";
}
/*
- 태그별 게시물 조회하기
*/
@Transactional
@GetMapping("article/list/tag/")
String getArticlesListByTag(@RequestParam Integer page,
@RequestParam String tagName,
Model model) {
Page<ArticleResponseForCardBox> articleList =
articleQueriesUseCase.getArticlesByTag(tagName, page);
for(ArticleResponseForCardBox article : articleList){
article.setContent(Jsoup.parse(getHtmlRenderer().render(getParser().parse(article.getContent()))).text());
}
PagingBoxHandler pagingBoxHandler =
PagingBoxHandler.createOf(page, (int)articleList.getTotalElements());
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("articleList", articleList);
model.addAttribute("pagingBox", pagingBoxHandler);
return "article/articleListByTag";
}
/*
- 검색어별 게시물 조회하기
*/
@Transactional
@GetMapping("article/list/search/")
String getArticlesListByKeyword(@RequestParam Integer page,
@RequestParam String keyword,
Model model) {
Page<ArticleResponseForCardBox> articleList =
articleQueriesUseCase.getArticlesByKeyword(keyword, page);
for(ArticleResponseForCardBox article : articleList){
article.setContent(Jsoup.parse(getHtmlRenderer().render(getParser().parse(article.getContent()))).text());
}
PagingBoxHandler pagingBoxHandler =
PagingBoxHandler.createOf(page, (int)articleList.getTotalElements());
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("articleList", articleList);
model.addAttribute("pagingBox", pagingBoxHandler);
return "article/articleListByKeyword";
}
/*
- 아티클 상세 조회
1. 로그인여부 검토
2. 게시물 상세조회에 필요한 Dto 전처리
3. 메타태그 작성위한 Dto 전처리
4. Dto 담기
5. 조회수 증가 검토
*/
@Transactional
@GetMapping("/article/view")
String readArticle(@RequestParam Long articleId,
@AuthenticationPrincipal PrincipalDetails principal,
@CookieValue(required = false, name = "view") String cookie,
HttpServletResponse response,
Model model) {
// 1. 로그인 여부에 따라 뷰단에 회원정보 출력 여부 결정
if (principal != null) {
model.addAttribute("member", MemberVo.from(principal.getMember()));
} else {
model.addAttribute("member", null);
}
/*
2.화면단을 위한 처리
*/
ArticleResponseForDetail articleResponseForDetail = articleQueriesUseCase.getArticleForDetail(articleId);
articleResponseForDetail.setContent(getHtmlRenderer().render(getParser().parse(articleResponseForDetail.getContent())));
List<ArticleResponseByCategory> articleTitlesSortByCategory =
articleQueriesUseCase
.getArticlesByCategoryForDetailView(articleResponseForDetail.getCategory());
// 3. 메타 태그용 Dto 전처리
StringBuilder metaTags = new StringBuilder();
for (String tag : articleResponseForDetail.getTags()) {
metaTags.append(tag).append(", ");
}
String substringContents = null;
if(articleResponseForDetail.getContent().length()>200) {
substringContents = articleResponseForDetail.getContent().substring(0, 200);
}
else substringContents = articleResponseForDetail.getContent();
// 4. 모델 담기
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("article", articleResponseForDetail);
model.addAttribute("metaTags",metaTags);
model.addAttribute("metaContents",Jsoup.parse(substringContents).text());
model.addAttribute("articlesSortBycategory", articleTitlesSortByCategory);
// 5. 조회수 증가 검토 및 증가
if(needToAddHitThroughCheckingCookie(articleId, cookie, response)) articleUseCase.addHit(articleId);
return "article/articleView";
}
/*
- 쿠키 추가 / 조회수 증가 검토
*/
private boolean needToAddHitThroughCheckingCookie(Long articleId, String cookie, HttpServletResponse response) {
if (cookie == null) {
Cookie viewCookie = new Cookie("view", articleId + "/");
viewCookie.setComment("게시물 조회 확인용");
viewCookie.setMaxAge(60 * 60);
response.addCookie(viewCookie);
return true;
} else {
boolean addHitAvailable = false;
boolean isRead = false;
String[] viewCookieList = cookie.split("/");
for (String alreadyRead : viewCookieList) {
if (alreadyRead.equals(String.valueOf(articleId))) {
isRead = true;
break;
}
}
if (!isRead) {
cookie += articleId + "/";
addHitAvailable = true;
}
response.addCookie(new Cookie("view", cookie));
return addHitAvailable;
}
}
/*
- 카테고리별 아티클 갯수 구하기
*/
private int getTotalArticleCntByCategory(String category, CategoryForView categorys) {
if (categorys.getTitle().equals(category)) {
return categorys.getCount();
} else {
for (CategoryForView categoryCnt :
categorys.getCategoryTCountList()) {
if (categoryCnt.getTitle().equals(category))
return categoryCnt.getCount();
for (CategoryForView categoryCntSub : categoryCnt.getCategoryTCountList()) {
if (categoryCntSub.getTitle().equals(category))
return categoryCntSub.getCount();
}
}
}
throw new IllegalArgumentException("'"+category+"' 라는 카테고리는 존재하지 않습니다.");
}
/*
- 아티클 폼에 필요한 카테고리 dtos
*/
private List<CategorySimpleView> getCategoryDtosForForm() {
return categoryService
.findCategoryByTier(2)
.stream()
.map(category -> modelMapper.map(category, CategorySimpleView.class))
.collect(Collectors.toList());
}
}

View File

@@ -1,58 +0,0 @@
package myblog.blog.article.adapter.incomming.web;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.response.ArticleResponseForCardBox;
import myblog.blog.article.application.port.incomming.ArticleQueriesUseCase;
import myblog.blog.shared.queries.LayoutRenderingQueries;
import org.jsoup.Jsoup;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static myblog.blog.shared.utils.MarkdownUtils.*;
@Controller
@RequiredArgsConstructor
public class MainController {
private final ArticleQueriesUseCase articleQueriesUseCase;
private final LayoutRenderingQueries layoutRenderingQueries;
/*
- 메인 화면 제어용 컨트롤러
*/
@GetMapping("/")
public String main(Model model) {
// Dto 전처리
List<ArticleResponseForCardBox> popularArticles = articleQueriesUseCase.getPopularArticles();
//
layoutRenderingQueries.AddLayoutTo(model);
model.addAttribute("popularArticles", popularArticles);
return "index";
}
/*
- 최신 아티클 무한스크롤로 조회
*/
@GetMapping("/main/article/{lastArticleId}")
public @ResponseBody
List<ArticleResponseForCardBox> mainNextPage(@PathVariable(required = false) Long lastArticleId) {
// Entity to Dto
List<ArticleResponseForCardBox> articles = articleQueriesUseCase.getRecentArticles(lastArticleId);
// 화면렌더링을 위한 파싱
for(ArticleResponseForCardBox article : articles){
String content = Jsoup.parse(getHtmlRenderer().render(getParser().parse(article.getContent()))).text();
if(content.length()>300) {
content = content.substring(0, 300);
}
article.setContent(content);
}
return articles;
}
}

View File

@@ -1,45 +0,0 @@
package myblog.blog.article.adapter.incomming.web;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.domain.TempArticle;
import myblog.blog.article.application.TempArticleService;
import myblog.blog.article.application.port.response.TempArticleResponse;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
/*
- 임시 게시물 조회, 저장을 위한 rest 컨트롤러
*/
@RestController
@RequiredArgsConstructor
public class TempArticleController {
private final TempArticleService tempArticleService;
/*
- 임시 아티클 저장 요청
*/
@PostMapping("/article/temp/autoSave")
public String autoSaveTemp(@RequestBody TempArticleResponse tempArticleResponse){
tempArticleService.saveTemp(new TempArticle(tempArticleResponse.getContent()));
return "저장성공";
}
/*
- 임시 아티클 조회
*/
@GetMapping("/article/temp/getTemp")
public @ResponseBody
TempArticleResponse getTempArticle(){
Optional<TempArticle> tempArticle = tempArticleService.getTempArticle();
TempArticleResponse tempArticleResponse = new TempArticleResponse();
tempArticleResponse.setContent(tempArticle.orElse(new TempArticle()).getContent());
return tempArticleResponse;
}
}

View File

@@ -0,0 +1,6 @@
package myblog.blog.article.adapter.outgoing.model;
import myblog.blog.shared.domain.ExternalErrorException;
public class GithubExternalErrorException extends ExternalErrorException {
}

View File

@@ -1,11 +1,11 @@
package myblog.blog.article.adapter.outgoing.persistence;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.outgoing.ArticleBackupRepositoryPort;
import myblog.blog.article.application.port.outgoing.ArticleRepositoryPort;
import myblog.blog.article.domain.Article;
import myblog.blog.category.domain.Category;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Component;
@@ -19,6 +19,7 @@ public class ArticleRepositoryAdapter implements ArticleRepositoryPort {
private final JpaArticleRepository jpaArticleRepository;
private final MybatisArticleRepository mybatisArticleRepository;
private final QdslArticleRepository qdslArticleRepository;
@Override
public List<Article> findTop6ByOrderByHitDesc() {
@@ -31,28 +32,23 @@ public class ArticleRepositoryAdapter implements ArticleRepositoryPort {
}
@Override
public Slice<Article> findByOrderByIdDesc(Pageable pageable) {
return jpaArticleRepository.findByOrderByIdDesc(pageable);
public List<Article> findByOrderByIdDesc(int page, int size) {
return jpaArticleRepository.findByOrderByIdDesc(PageRequest.of(page,size)).getContent();
}
@Override
public List<Article> findByOrderByIdDescWithList(Pageable pageable) {
return jpaArticleRepository.findByOrderByIdDescWithList(pageable);
public List<Article> findByOrderByIdDesc(Long articleId, int size) {
return qdslArticleRepository.findByOrderByIdDesc(articleId, size);
}
@Override
public List<Article> findByOrderByIdDesc(Long articleId, Pageable pageable) {
return jpaArticleRepository.findByOrderByIdDesc(articleId, pageable);
public List<Article> findBySubCategoryOrderByIdDesc(int page,int size, String category) {
return jpaArticleRepository.findBySubCategoryOrderByIdDesc(PageRequest.of(page, size),category).getContent();
}
@Override
public Slice<Article> findBySubCategoryOrderByIdDesc(Pageable pageable, String category) {
return jpaArticleRepository.findBySubCategoryOrderByIdDesc(pageable,category);
}
@Override
public Slice<Article> findBySupCategoryOrderByIdDesc(Pageable pageable, String category) {
return jpaArticleRepository.findBySupCategoryOrderByIdDesc(pageable,category);
public List<Article> findBySuperCategoryOrderByIdDesc(int page,int size, String category) {
return jpaArticleRepository.findBySupCategoryOrderByIdDesc(PageRequest.of(page, size),category).getContent();
}
@Override
@@ -67,7 +63,7 @@ public class ArticleRepositoryAdapter implements ArticleRepositoryPort {
@Override
public Page<Article> findAllByKeywordOrderById(Pageable pageable, String keyword) {
return jpaArticleRepository.findAllByArticleTagsOrderById(pageable, keyword);
return jpaArticleRepository.findAllByKeywordOrderById(pageable, keyword);
}
@Override

View File

@@ -1,6 +1,8 @@
package myblog.blog.article.adapter.outgoing.persistence;
import myblog.blog.article.adapter.outgoing.model.GithubExternalErrorException;
import myblog.blog.article.domain.Article;
import myblog.blog.shared.domain.ExternalErrorException;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;
@@ -29,6 +31,7 @@ public class GithubRepoArticleRepository {
.commit();
} catch (IOException e) {
e.printStackTrace();
throw new GithubExternalErrorException();
}
}

View File

@@ -28,25 +28,6 @@ public interface JpaArticleRepository extends JpaRepository<Article, Long> {
*/
Slice<Article> findByOrderByIdDesc(Pageable pageable);
/*
- 커서페이징으로 최신 게시물 가져오기
- 첫번째 페이지용 쿼리
*/
@Query("select a " +
"from Article a " +
"order by a.id desc ")
List<Article> findByOrderByIdDescWithList(Pageable pageable);
/*
- 커서페이징으로 최신 게시물 가져오기
- 커서 적용
*/
@Query("select a " +
"from Article a " +
"where a.id < :articleId " +
"order by a.id desc")
List<Article> findByOrderByIdDesc(@Param("articleId") Long articleId,Pageable pageable);
/*
- 카테고리별(하위 카테고리) 페이징 처리해서 최신게시물순으로 Slice 가져오기
*/

View File

@@ -6,15 +6,13 @@ 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;
import org.springframework.transaction.annotation.Transactional;
public interface ArticleTagListsRepository extends JpaRepository<ArticleTagList, Long> {
public interface JpaArticleTagListsRepository extends JpaRepository<ArticleTagList, Long> {
/*
- 아티클 연관 태그 삭제 쿼리
- cascade 필요시에는 아티클 삭제로 일괄 삭제하므로 해당쿼리는 연관태그 수정용
*/
@Transactional
@Modifying
@Query("delete from ArticleTagList t " +
"where t.article =:article")

View File

@@ -0,0 +1,33 @@
package myblog.blog.article.adapter.outgoing.persistence;
import com.querydsl.core.types.Predicate;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.domain.Article;
import org.springframework.stereotype.Repository;
import java.util.List;
import static myblog.blog.article.domain.QArticle.article;
@Repository
@RequiredArgsConstructor
public class QdslArticleRepository {
private final JPAQueryFactory queryFactory;
List<Article> findByOrderByIdDesc(Long articleId, int size) {
return queryFactory
.selectFrom(article)
.where(cursorLt(articleId))
.orderBy(article.id.desc())
.limit(size)
.fetch();
}
private Predicate cursorLt(Long articleId) {
return articleId == 0L ? null : article.id.lt(articleId);
}
}

View File

@@ -0,0 +1,29 @@
package myblog.blog.article.application;
import myblog.blog.article.application.port.incomming.response.*;
import myblog.blog.article.domain.Article;
import myblog.blog.article.domain.Tags;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.domain.Category;
import org.mapstruct.*;
@Mapper(
componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface ArticleDtoMapper {
ArticleResponseForCardBox cardBox(Article article);
@Mappings({
@Mapping(target = "articleTagList",ignore = true)
})
ArticleResponseForEdit edit(Article article);
@Mappings({
@Mapping(target = "tags",ignore = true),
@Mapping(source = "article.category.title", target = "category"),
@Mapping(source = "article.member.id", target = "memberId"),
})
ArticleResponseForDetail detail(Article article);
ArticleResponseByCategory category(Article article);
TagsResponse of(Tags tag);
}

View File

@@ -2,33 +2,38 @@ package myblog.blog.article.application;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.response.ArticleResponseForCardBox;
import myblog.blog.article.domain.Article;
import myblog.blog.article.domain.ArticleNotFoundException;
import myblog.blog.category.domain.Category;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForCardBox;
import myblog.blog.article.application.port.incomming.ArticleQueriesUseCase;
import myblog.blog.article.application.port.incomming.response.ArticleResponseByCategory;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForDetail;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForEdit;
import myblog.blog.category.appliacation.port.incomming.CategoryUseCase;
import myblog.blog.article.application.port.outgoing.ArticleRepositoryPort;
import myblog.blog.article.domain.Article;
import myblog.blog.category.domain.Category;
import myblog.blog.article.application.port.response.ArticleResponseByCategory;
import myblog.blog.article.application.port.response.ArticleResponseForDetail;
import myblog.blog.article.application.port.response.ArticleResponseForEdit;
import myblog.blog.category.service.CategoryService;
import org.modelmapper.ModelMapper;
import myblog.blog.category.domain.CategoryNotFoundException;
import myblog.blog.shared.utils.MapperUtils;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ArticleQueries implements ArticleQueriesUseCase {
private final ArticleRepositoryPort articleRepositoryPort;
private final CategoryService categoryService;
private final ModelMapper modelMapper;
private final CategoryUseCase categoryUseCase;
private final ArticleDtoMapper articleDtoMapper;
/*
- 메인화면 위한 인기 아티클 6개 목록 가져오기
@@ -41,50 +46,48 @@ public class ArticleQueries implements ArticleQueriesUseCase {
public List<ArticleResponseForCardBox> getPopularArticles() {
return articleRepositoryPort.findTop6ByOrderByHitDesc()
.stream()
.map(article -> modelMapper.map(article, ArticleResponseForCardBox.class))
.map(articleDtoMapper::cardBox)
.collect(Collectors.toList());
}
/*
- 메인화면 위한 최신 아티클 커서 페이징해서 가져오기
- 레이아웃 렌더링 성능 향상을 위해 캐싱작업
카테고리 변경 / 아티클 변경이 존재할경우 레이아웃 캐시 초기화
카테고리 변경 / 아티클 변경이 존재할경우 레이아웃 캐시 초기화 정책
*/
@Override
@Cacheable(value = "layoutRecentArticleCaching", key = "#lastArticleId")
public List<ArticleResponseForCardBox> getRecentArticles(Long lastArticleId) {
List<Article> articles = lastArticleId.equals(0L) ?
articleRepositoryPort
.findByOrderByIdDescWithList(PageRequest.of(0, 5))
:
articleRepositoryPort
.findByOrderByIdDesc(lastArticleId, PageRequest.of(0, 5));
List<Article> articles = articleRepositoryPort
.findByOrderByIdDesc(lastArticleId, 5);
return articles
.stream()
.map(article -> modelMapper.map(article, ArticleResponseForCardBox.class))
.map(articleDtoMapper::cardBox)
.collect(Collectors.toList());
}
/*
- 카테고리별 게시물 페이징 처리해서 가져오기
- tier 1은 super / tier 2는 sub
*/
@Override
public Slice<ArticleResponseForCardBox> getArticlesByCategory(String category, Integer tier, Integer page) {
Slice<Article> articles;
public List<ArticleResponseForCardBox> getArticlesByCategory(String category, int tier, int page) {
List<Article> articles = null;
page = pageResolve(page);
if (tier.equals(0)) {
if (tier == 0) {
articles = articleRepositoryPort
.findByOrderByIdDesc(
PageRequest.of(pageResolve(page), 5));
.findByOrderByIdDesc(page, 5);
}
else {
if (tier == 1) {
articles = articleRepositoryPort
.findBySupCategoryOrderByIdDesc(
PageRequest.of(pageResolve(page), 5), category);
.findBySuperCategoryOrderByIdDesc(page, 5, category);
}
if (tier == 2) {
articles = articleRepositoryPort
.findBySubCategoryOrderByIdDesc(page, 5, category);
}
if(articles == null) throw new ArticleNotFoundException();
if(articles == null) throw new IllegalArgumentException("NotFoundArticleException");
return articles.map(article -> modelMapper.map(article, ArticleResponseForCardBox.class));
return articles.stream().map(articleDtoMapper::cardBox).collect(Collectors.toList());
}
/*
@@ -93,7 +96,7 @@ public class ArticleQueries implements ArticleQueriesUseCase {
@Override
public ArticleResponseForEdit getArticleForEdit(Long id){
Article article = articleRepositoryPort.findArticleByIdFetchCategoryAndTags(id);
ArticleResponseForEdit articleDto = modelMapper.map(article, ArticleResponseForEdit.class);
ArticleResponseForEdit articleDto = articleDtoMapper.edit(article);
List<String> articleTagStrings = article.getArticleTagLists()
.stream()
.map(articleTag -> articleTag.getTags().getName())
@@ -107,8 +110,7 @@ public class ArticleQueries implements ArticleQueriesUseCase {
@Override
public ArticleResponseForDetail getArticleForDetail(Long id){
Article article = articleRepositoryPort.findArticleByIdFetchCategoryAndTags(id);
ArticleResponseForDetail articleResponseForDetail =
modelMapper.map(article, ArticleResponseForDetail.class);
ArticleResponseForDetail articleResponseForDetail = articleDtoMapper.detail(article);
List<String> tags =
article.getArticleTagLists()
@@ -125,10 +127,11 @@ public class ArticleQueries implements ArticleQueriesUseCase {
*/
@Override
public List<ArticleResponseByCategory> getArticlesByCategoryForDetailView(String categoryName){
Category category = categoryService.findCategory(categoryName);
Category category = categoryUseCase.findCategory(categoryName)
.orElseThrow(CategoryNotFoundException::new);
return articleRepositoryPort.findTop6ByCategoryOrderByIdDesc(category)
.stream()
.map(article -> modelMapper.map(article, ArticleResponseByCategory.class))
.map(articleDtoMapper::category)
.collect(Collectors.toList());
}
/*
@@ -138,8 +141,7 @@ public class ArticleQueries implements ArticleQueriesUseCase {
public Page<ArticleResponseForCardBox> getArticlesByTag(String tag, Integer page) {
return articleRepositoryPort
.findAllByArticleTagsOrderById(PageRequest.of(pageResolve(page), 5), tag)
.map(article ->
modelMapper.map(article, ArticleResponseForCardBox.class));
.map(articleDtoMapper::cardBox);
}
/*
- 검색어별 게시물 페이징 처리해서 가져오기
@@ -148,8 +150,7 @@ public class ArticleQueries implements ArticleQueriesUseCase {
public Page<ArticleResponseForCardBox> getArticlesByKeyword(String keyword, Integer page) {
return articleRepositoryPort
.findAllByKeywordOrderById(PageRequest.of(pageResolve(page),5), keyword)
.map(article ->
modelMapper.map(article, ArticleResponseForCardBox.class));
.map(articleDtoMapper::cardBox);
}
/*
- 페이지 시작점 0~1변경 메서드

View File

@@ -2,20 +2,21 @@ package myblog.blog.article.application;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.request.ArticleCreateRequest;
import myblog.blog.article.application.port.request.ArticleEditRequest;
import myblog.blog.article.application.port.incomming.request.ArticleCreateCommand;
import myblog.blog.article.application.port.incomming.request.ArticleEditCommand;
import myblog.blog.article.application.port.incomming.ArticleUseCase;
import myblog.blog.article.application.port.incomming.TagUseCase;
import myblog.blog.article.domain.ArticleNotFoundException;
import myblog.blog.category.appliacation.port.incomming.CategoryUseCase;
import myblog.blog.category.domain.CategoryNotFoundException;
import myblog.blog.member.application.port.incomming.MemberQueriesUseCase;
import myblog.blog.article.application.port.outgoing.ArticleBackupRepositoryPort;
import myblog.blog.article.application.port.outgoing.ArticleRepositoryPort;
import myblog.blog.article.domain.Article;
import myblog.blog.category.domain.Category;
import myblog.blog.member.doamin.Member;
import myblog.blog.category.service.CategoryService;
import myblog.blog.member.service.Oauth2MemberService;
import myblog.blog.member.doamin.NotFoundMemberException;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,39 +29,42 @@ import java.util.List;
public class ArticleService implements ArticleUseCase {
private final TagUseCase tagUseCase;
private final CategoryService categoryService;
private final Oauth2MemberService memberService;
private final CategoryUseCase categoryUseCase;
private final MemberQueriesUseCase memberQueriesUseCase;
private final ArticleRepositoryPort articleRepositoryPort;
private final ArticleBackupRepositoryPort articleBackupRepositoryPort;
@Override
@CacheEvict(value = {"layoutCaching", "layoutRecentArticleCaching","seoCaching"}, allEntries = true)
public Long writeArticle(ArticleCreateRequest articleCreateRequest) {
Member writer = memberService.findById(articleCreateRequest.getMemberId());
Category category = categoryService.findCategory(articleCreateRequest.getCategory());
Article newArticle = new Article(articleCreateRequest.getTitle(),
articleCreateRequest.getContent(),
articleCreateRequest.getToc(),
public Long writeArticle(ArticleCreateCommand articleCreateCommand) {
var writer = memberQueriesUseCase.findById(articleCreateCommand.getMemberId())
.orElseThrow(NotFoundMemberException::new);
var category = categoryUseCase.findCategory(articleCreateCommand.getCategory())
.orElseThrow(CategoryNotFoundException::new);
var newArticle = new Article(articleCreateCommand.getTitle(),
articleCreateCommand.getContent(),
articleCreateCommand.getToc(),
writer,
articleCreateRequest.getThumbnailUrl(),
articleCreateCommand.getThumbnailUrl(),
category);
articleRepositoryPort.save(newArticle);
tagUseCase.createNewTagsAndArticleTagList(articleCreateRequest.getTags(), newArticle);
tagUseCase.createNewTagsAndArticleTagList(articleCreateCommand.getTags(), newArticle);
return newArticle.getId();
}
@Override
@CacheEvict(value = {"layoutCaching", "layoutRecentArticleCaching","seoCaching"}, allEntries = true)
public void editArticle(ArticleEditRequest articleEditRequest) {
Article article = articleRepositoryPort.findById(articleEditRequest.getArticleId())
.orElseThrow(() -> new IllegalArgumentException("NotFoundArticleException"));
Category category = categoryService.findCategory(articleEditRequest.getCategoryName());
public void editArticle(ArticleEditCommand articleEditCommand) {
var article = articleRepositoryPort.findById(articleEditCommand.getArticleId())
.orElseThrow(ArticleNotFoundException::new);
var category = categoryUseCase.findCategory(articleEditCommand.getCategoryName())
.orElseThrow(CategoryNotFoundException::new);
tagUseCase.deleteAllTagsWith(article);
tagUseCase.createNewTagsAndArticleTagList(articleEditRequest.getTags(), article);
article.edit(articleEditRequest.getContent(),
articleEditRequest.getTitle(),
articleEditRequest.getToc(),
articleEditRequest.getThumbnailUrl(), category);
tagUseCase.createNewTagsAndArticleTagList(articleEditCommand.getTags(), article);
article.edit(articleEditCommand.getContent(),
articleEditCommand.getTitle(),
articleEditCommand.getToc(),
articleEditCommand.getThumbnailUrl(), category);
}
@Override
@@ -71,14 +75,14 @@ public class ArticleService implements ArticleUseCase {
@Override
public void backupArticle(Long articleId) {
Article article = articleRepositoryPort.findById(articleId)
var article = articleRepositoryPort.findById(articleId)
.orElseThrow(() -> new IllegalArgumentException("NotFoundArticle"));
articleBackupRepositoryPort.backup(article);
}
@Override
public void addHit(Long articleId) {
Article article = articleRepositoryPort.findById(articleId)
var article = articleRepositoryPort.findById(articleId)
.orElseThrow(() -> new IllegalArgumentException("NotFoundArticleException"));
article.addHit();
}

View File

@@ -5,7 +5,7 @@ import myblog.blog.article.application.port.outgoing.TagRepositoryPort;
import myblog.blog.article.domain.Tags;
import myblog.blog.article.application.port.incomming.TagsQueriesUseCase;
import myblog.blog.shared.utils.MapperUtils;
import myblog.blog.article.application.port.response.TagsResponse;
import myblog.blog.article.application.port.incomming.response.TagsResponse;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -13,15 +13,16 @@ import java.util.List;
import java.util.stream.Collectors;
@Component
@Transactional
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TagsQueries implements TagsQueriesUseCase {
private final TagRepositoryPort tagRepositoryPort;
private final ArticleDtoMapper articleDtoMapper;
public List<TagsResponse> findAllTagDtos(){
List<Tags> tags = tagRepositoryPort.findAll();
var tags = tagRepositoryPort.findAll();
return tags.stream()
.map(tag -> MapperUtils.getModelMapper().map(tag, TagsResponse.class))
.map(articleDtoMapper::of)
.collect(Collectors.toList());
}
}

View File

@@ -1,7 +1,7 @@
package myblog.blog.article.application;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.adapter.outgoing.persistence.ArticleTagListsRepository;
import myblog.blog.article.adapter.outgoing.persistence.JpaArticleTagListsRepository;
import myblog.blog.article.adapter.outgoing.persistence.JpaTagsRepository;
import myblog.blog.article.application.port.incomming.TagUseCase;
import org.springframework.stereotype.Service;
@@ -17,7 +17,7 @@ import java.util.*;
@RequiredArgsConstructor
public class TagsService implements TagUseCase {
private final JpaTagsRepository jpaTagsRepository;
private final ArticleTagListsRepository articleTagListsRepository;
private final JpaArticleTagListsRepository articleTagListsRepository;
/*
- Json 객체로 넘어온 태그들을 파싱해서 신규 태그인경우 저장
*/
@@ -25,7 +25,7 @@ public class TagsService implements TagUseCase {
public void createNewTagsAndArticleTagList(String names, Article article) {
List<Map<String,String>> tagsDtoArrayList = MapperUtils.getGson().fromJson(names, ArrayList.class);
for (var tagDto : tagsDtoArrayList) {
Tags tag = findOrCreateTagFrom(tagDto);
var tag = findOrCreateTagFrom(tagDto);
articleTagListsRepository.save(new ArticleTagList(article, tag));
}
}

View File

@@ -1,14 +1,13 @@
package myblog.blog.article.application;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.incomming.response.TempArticleDto;
import myblog.blog.article.application.port.incomming.TempArticleUseCase;
import myblog.blog.article.application.port.outgoing.TempArticleRepositoryPort;
import myblog.blog.article.domain.TempArticle;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
@@ -20,23 +19,28 @@ public class TempArticleService implements TempArticleUseCase {
- 자동 저장 로직
- ID값 고정으로 머지를 작동시켜 임시글 DB에 1개 유지
*/
public void saveTemp(TempArticle tempArticle){
tempArticleRepositoryPort.save(tempArticle);
@Override
public void saveTemp(String tempArticleContents){
tempArticleRepositoryPort.save(new TempArticle(tempArticleContents));
}
/*
- 임시글 가져오기
*/
public Optional<TempArticle> getTempArticle(){
return tempArticleRepositoryPort.findById(1L);
@Override
public TempArticleDto getTempArticle(){
var tempArticle = tempArticleRepositoryPort.findById(1L);
var tempArticleDto = new TempArticleDto();
tempArticleDto.setContent(tempArticle.orElse(new TempArticle()).getContent());
return tempArticleDto;
}
/*
- 임시글 삭제
*/
@Override
public void deleteTemp(){
Optional<TempArticle> deleteArticle = tempArticleRepositoryPort.findById(1L);
var deleteArticle = tempArticleRepositoryPort.findById(1L);
deleteArticle.ifPresent(tempArticleRepositoryPort::delete);
}
}

View File

@@ -1,9 +1,9 @@
package myblog.blog.article.application.port.incomming;
import myblog.blog.article.application.port.response.ArticleResponseForCardBox;
import myblog.blog.article.application.port.response.ArticleResponseByCategory;
import myblog.blog.article.application.port.response.ArticleResponseForDetail;
import myblog.blog.article.application.port.response.ArticleResponseForEdit;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForCardBox;
import myblog.blog.article.application.port.incomming.response.ArticleResponseByCategory;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForDetail;
import myblog.blog.article.application.port.incomming.response.ArticleResponseForEdit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Slice;
@@ -12,7 +12,7 @@ import java.util.List;
public interface ArticleQueriesUseCase {
List<ArticleResponseForCardBox> getPopularArticles();
List<ArticleResponseForCardBox> getRecentArticles(Long lastArticleId);
Slice<ArticleResponseForCardBox> getArticlesByCategory(String category, Integer tier, Integer page);
List<ArticleResponseForCardBox> getArticlesByCategory(String category, int tier, int page);
ArticleResponseForEdit getArticleForEdit(Long id);
ArticleResponseForDetail getArticleForDetail(Long id);
List<ArticleResponseByCategory> getArticlesByCategoryForDetailView(String category);

View File

@@ -1,14 +1,14 @@
package myblog.blog.article.application.port.incomming;
import myblog.blog.article.application.port.request.ArticleCreateRequest;
import myblog.blog.article.application.port.request.ArticleEditRequest;
import myblog.blog.article.application.port.incomming.request.ArticleCreateCommand;
import myblog.blog.article.application.port.incomming.request.ArticleEditCommand;
import myblog.blog.article.domain.Article;
import java.util.List;
public interface ArticleUseCase {
Long writeArticle(ArticleCreateRequest articleCreateRequest);
void editArticle(ArticleEditRequest articleEditRequest);
Long writeArticle(ArticleCreateCommand articleCreateCommand);
void editArticle(ArticleEditCommand articleEditCommand);
void deleteArticle(Long articleId);
void addHit(Long articleId);
void backupArticle(Long articleId);

View File

@@ -1,6 +1,6 @@
package myblog.blog.article.application.port.incomming;
import myblog.blog.article.application.port.response.TagsResponse;
import myblog.blog.article.application.port.incomming.response.TagsResponse;
import java.util.List;

View File

@@ -1,11 +1,9 @@
package myblog.blog.article.application.port.incomming;
import myblog.blog.article.domain.TempArticle;
import java.util.Optional;
import myblog.blog.article.application.port.incomming.response.TempArticleDto;
public interface TempArticleUseCase {
void saveTemp(TempArticle tempArticle);
Optional<TempArticle> getTempArticle();
void saveTemp(String tempArticleContents);
TempArticleDto getTempArticle();
void deleteTemp();
}

View File

@@ -1,12 +1,14 @@
package myblog.blog.article.application.port.request;
package myblog.blog.article.application.port.incomming.request;
import lombok.AllArgsConstructor;
import lombok.Getter;
import myblog.blog.article.adapter.incomming.web.ArticleForm;
import lombok.NoArgsConstructor;
import myblog.blog.article.adapter.incomming.ArticleForm;
@Getter
@AllArgsConstructor
public class ArticleCreateRequest {
@NoArgsConstructor
public class ArticleCreateCommand {
private Long memberId;
private String title;
private String content;
@@ -15,8 +17,8 @@ public class ArticleCreateRequest {
private String category;
private String tags;
static public ArticleCreateRequest from(ArticleForm articleForm, Long memberId){
return new ArticleCreateRequest(memberId,
static public ArticleCreateCommand from(ArticleForm articleForm, Long memberId){
return new ArticleCreateCommand(memberId,
articleForm.getTitle(),
articleForm.getContent(),
articleForm.getToc(),

View File

@@ -1,12 +1,12 @@
package myblog.blog.article.application.port.request;
package myblog.blog.article.application.port.incomming.request;
import lombok.AllArgsConstructor;
import lombok.Getter;
import myblog.blog.article.adapter.incomming.web.ArticleForm;
import myblog.blog.article.adapter.incomming.ArticleForm;
@Getter
@AllArgsConstructor
public class ArticleEditRequest {
public class ArticleEditCommand {
private Long articleId;
private String title;
@@ -16,8 +16,8 @@ public class ArticleEditRequest {
private String categoryName;
private String tags;
static public ArticleEditRequest from(Long articleId, ArticleForm articleForm){
return new ArticleEditRequest(articleId,
static public ArticleEditCommand from(Long articleId, ArticleForm articleForm){
return new ArticleEditCommand(articleId,
articleForm.getTitle(),
articleForm.getContent(),
articleForm.getToc(),

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.application.port.response;
package myblog.blog.article.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;

View File

@@ -0,0 +1,31 @@
package myblog.blog.article.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
import org.jsoup.Jsoup;
import java.time.LocalDateTime;
import static myblog.blog.shared.utils.MarkdownUtils.getHtmlRenderer;
import static myblog.blog.shared.utils.MarkdownUtils.getParser;
/*
- 메인 화면 렌더링용 아티클 DTO
*/
@Getter
@Setter
public class ArticleResponseForCardBox {
private Long id;
private String title;
private String content;
private String thumbnailUrl;
private LocalDateTime createdDate;
public void parseAndRenderForView(){
this.content = Jsoup.parse(getHtmlRenderer().render(getParser().parse(this.content))).text();
if(content.length()>300) {
content = content.substring(0, 300);
}
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.application.port.response;
package myblog.blog.article.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
@@ -6,6 +6,9 @@ import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
import static myblog.blog.shared.utils.MarkdownUtils.getHtmlRenderer;
import static myblog.blog.shared.utils.MarkdownUtils.getParser;
/*
- 아티클 상세조회용 DTO
*/
@@ -21,4 +24,8 @@ public class ArticleResponseForDetail {
private String category;
private List<String> tags;
private LocalDateTime createdDate;
public void parseAndRenderForView(){
this.content = getHtmlRenderer().render(getParser().parse(this.content));
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.application.port.response;
package myblog.blog.article.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
@@ -18,7 +18,6 @@ public class ArticleResponseForEdit {
private String content;
private String toc;
private String thumbnailUrl;
private List<String> articleTagList = new ArrayList<>();
private List<String> articleTagList;
private Category category;
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.application.port.response;
package myblog.blog.article.application.port.incomming.response;
import lombok.Data;

View File

@@ -1,4 +1,4 @@
package myblog.blog.article.application.port.response;
package myblog.blog.article.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
@@ -7,6 +7,6 @@ import lombok.Setter;
*/
@Getter
@Setter
public class TempArticleResponse {
public class TempArticleDto {
private String content;
}

View File

@@ -12,11 +12,10 @@ import java.util.Optional;
public interface ArticleRepositoryPort {
List<Article> findTop6ByOrderByHitDesc();
List<Article> findTop6ByCategoryOrderByIdDesc(Category category);
Slice<Article> findByOrderByIdDesc(Pageable pageable);
List<Article> findByOrderByIdDescWithList(Pageable pageable);
List<Article> findByOrderByIdDesc(Long articleId, Pageable pageable);
Slice<Article> findBySubCategoryOrderByIdDesc(Pageable pageable, String category);
Slice<Article> findBySupCategoryOrderByIdDesc(Pageable pageable, String category);
List<Article> findByOrderByIdDesc(int page, int size);
List<Article> findByOrderByIdDesc(Long articleId, int size);
List<Article> findBySubCategoryOrderByIdDesc(int page, int size, String category);
List<Article> findBySuperCategoryOrderByIdDesc(int page, int size, String category);
Article findArticleByIdFetchCategoryAndTags(Long articleId);
Page<Article> findAllByArticleTagsOrderById(Pageable pageable, String tag);
Page<Article> findAllByKeywordOrderById(Pageable pageable, String keyword);

View File

@@ -1,20 +0,0 @@
package myblog.blog.article.application.port.response;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/*
- 메인 화면 렌더링용 아티클 DTO
*/
@Getter
@Setter
public class ArticleResponseForCardBox {
private Long id;
private String title;
private String content;
private String thumbnailUrl;
private LocalDateTime createdDate;
}

View File

@@ -1,15 +1,18 @@
package myblog.blog.article.domain;
import myblog.blog.shared.BasicEntity;
import myblog.blog.shared.domain.BadRequestException;
import myblog.blog.shared.domain.BasicEntity;
import myblog.blog.category.domain.Category;
import myblog.blog.comment.domain.Comment;
import myblog.blog.member.doamin.Member;
import lombok.Builder;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.util.*;
/*
@@ -27,8 +30,7 @@ import java.util.*;
- fts 구현을 위한 인덱스 설정
*/
@Table(indexes = {
@Index(name="i_article_title", columnList = "title"),
@Index(name = "i_article_content", columnList = "content")
@Index(name="i_article_title", columnList = "title")
})
@Getter
public class Article extends BasicEntity {
@@ -74,6 +76,11 @@ public class Article extends BasicEntity {
@Builder
public Article(String title, String content, String toc, Member member, String thumbnailUrl, Category category) {
if(StringUtils.isEmpty(title)) throw new BadRequestException("Article.tittle");
if(StringUtils.isEmpty(content)) throw new BadRequestException("Article.content");
if(member == null) throw new BadRequestException("Article.member");
if(category == null) throw new BadRequestException("Article.category");
this.title = title;
this.content = content;
this.toc = toc;
@@ -83,6 +90,13 @@ public class Article extends BasicEntity {
this.hit = 0L;
}
private String makeDefaultThumbOf(String thumbnailUrl) {
var defaultThumbUrl = "https://cdn.pixabay.com/photo/2020/11/08/13/28/tree-5723734_1280.jpg";
if (thumbnailUrl == null || thumbnailUrl.equals("")) {
thumbnailUrl = defaultThumbUrl;
}
return thumbnailUrl;
}
/*
- 아티클 수정을 위한 로직
*/
@@ -95,22 +109,8 @@ public class Article extends BasicEntity {
this.thumbnailUrl = getThumbnailUrl();
}
}
/*
- 아티클 조회수 증가
*/
public void addHit(){
this.hit++;
}
/*
- 썸네일 기본 작성
*/
private String makeDefaultThumbOf(String thumbnailUrl) {
String defaultThumbUrl = "https://cdn.pixabay.com/photo/2020/11/08/13/28/tree-5723734_1280.jpg";
if (thumbnailUrl == null || thumbnailUrl.equals("")) {
thumbnailUrl = defaultThumbUrl;
}
return thumbnailUrl;
}
}

View File

@@ -0,0 +1,6 @@
package myblog.blog.article.domain;
import myblog.blog.shared.domain.ResourceNotFoundException;
public class ArticleNotFoundException extends ResourceNotFoundException {
}

View File

@@ -1,7 +1,7 @@
package myblog.blog.article.domain;
import lombok.Getter;
import myblog.blog.shared.BasicEntity;
import myblog.blog.shared.domain.BasicEntity;
import javax.persistence.*;

View File

@@ -1,7 +1,7 @@
package myblog.blog.article.domain;
import lombok.Getter;
import myblog.blog.shared.BasicEntity;
import myblog.blog.shared.domain.BasicEntity;
import javax.persistence.*;
import java.util.ArrayList;

View File

@@ -1,11 +1,9 @@
package myblog.blog.article.domain;
import lombok.Getter;
import myblog.blog.shared.BasicEntity;
import myblog.blog.shared.domain.BasicEntity;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
/*
- 임시 아티클 저장 Entity

View File

@@ -0,0 +1,16 @@
package myblog.blog.badge;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/open-api/v1")
public @interface APIController {
}

View File

@@ -0,0 +1,16 @@
package myblog.blog.badge;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@APIController
public class BadgeController {
@GetMapping("/badges")
String generateBadges() {
return "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" width=\"100\" height=\"100%\">" +"<circle cx=\"50\" cy=\"50\" r=\"30\" fill=\"red\">" +"</svg>";
}
}

View File

@@ -1,22 +0,0 @@
package myblog.blog.base.config;
import com.google.gson.Gson;
import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
/*
- DTO <-> 엔티티 매퍼 빈등록
*/
@Bean
public ModelMapper modelMapper(){
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
.setFieldMatchingEnabled(true);
return modelMapper;
}
}

View File

@@ -1,75 +0,0 @@
package myblog.blog.base.log;
import io.sentry.Sentry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class LogTracer {
private static final String START_PREFIX = "-->";
private static final String COMPLETE_PREFIX = "<--";
private static final String EX_PREFIX = "<X-";
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
public TraceStatus begin(String message){
syncTraceId();
TraceId traceId = traceIdHolder.get();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}",traceId.getId(), addSpace(START_PREFIX,
traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
private void syncTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId == null) {
traceIdHolder.set(new TraceId());
} else {
traceIdHolder.set(traceId.createNextId());
}
}
public void end(TraceStatus traceStatus){
complete(traceStatus, null);
}
public void exception(TraceStatus traceStatus, Exception ex){
complete(traceStatus, ex);
}
private void complete(TraceStatus traceStatus, Exception ex) {
Long stopTimeMs = System.currentTimeMillis();
Long resultTimeMs = stopTimeMs - traceStatus.getStartTimesMs();
TraceId traceId = traceStatus.getTraceId();
if(ex == null){
log.info("[{}] {} {} time = {}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()),
traceStatus.getMessage(), resultTimeMs);
} else {
log.info("[{}] {} {} time = {}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()),
traceStatus.getMessage(), resultTimeMs, ex.toString());
Sentry.captureMessage(String.format("[%s] %s %s time = %sms ex = %s",
traceId.getId(),addSpace(START_PREFIX, traceId.getLevel()),traceStatus.getMessage(), resultTimeMs, ex.toString()));
}
releaseTraceId();
}
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId.isFirstLevel()) {
traceIdHolder.remove(); //destroy
} else {
traceIdHolder.set(traceId.createPrevId());
}
}
private String addSpace(String prefix, int level) {
StringBuilder sb = new StringBuilder();
for(int i = 0; i< level; i++){
sb.append((i==level-1) ? "|" + prefix : "| ");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,55 @@
package myblog.blog.category.adapter.incomming;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.appliacation.port.incomming.CategoryQueriesUseCase;
import myblog.blog.category.appliacation.port.incomming.CategoryUseCase;
import myblog.blog.category.appliacation.port.incomming.response.CategoryViewForLayout;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.comment.application.port.incomming.CommentQueriesUseCase;
import myblog.blog.comment.application.port.incomming.response.CommentDtoForLayout;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@Controller
@RequiredArgsConstructor
public class CategoryController {
private final CategoryUseCase categoryUseCase;
private final CategoryQueriesUseCase categoryQueriesUseCase;
private final CommentQueriesUseCase commentQueriesUseCase;
private final CategoryListValidator categorylistValidator;
/*
- 카테고리 수정폼 조회
*/
@GetMapping("/category/edit")
String editCategoryForm(Model model) {
var categoryList = categoryQueriesUseCase.getCategorytCountList();
var copyList = new ArrayList<>(List.copyOf(categoryList));
copyList.remove(0);
var categoryViewForLayout = CategoryViewForLayout.from(categoryList);
var comments = commentQueriesUseCase.recentCommentListForLayout();
model.addAttribute("categoryForEdit", copyList);
model.addAttribute("category", categoryViewForLayout);
model.addAttribute("commentsList", comments);
return "admin/categoryEdit";
}
/*
- 카테고리 수정 요청
*/
@PostMapping("/category/edit")
@ResponseBody String editCategory(@RequestBody List<CategorySimpleDto> categoryList, Errors errors) {
categorylistValidator.validate(categoryList, errors);
categoryUseCase.changeCategory(categoryList);
return "변경 성공";
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.shared.exception;
package myblog.blog.category.adapter.incomming;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -7,6 +7,7 @@ import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
import java.util.List;
import java.util.Objects;
/*
@@ -14,7 +15,7 @@ import java.util.List;
*/
@Component
@RequiredArgsConstructor
public class ListValidator implements Validator {
public class CategoryListValidator implements Validator {
private final SpringValidatorAdapter springValidatorAdapter;
@@ -28,5 +29,8 @@ public class ListValidator implements Validator {
for(Object object : (List)target){
springValidatorAdapter.validate(object,errors);
}
if (errors.hasErrors()) {
throw new InvalidCategoryRequestException(Objects.requireNonNull(errors.getFieldError()).getDefaultMessage());
}
}
}

View File

@@ -0,0 +1,9 @@
package myblog.blog.category.adapter.incomming;
/*
- REST 컨트롤러 상태 메세지 전송용 커스텀 에러
*/
public class InvalidCategoryRequestException extends RuntimeException {
public InvalidCategoryRequestException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,54 @@
package myblog.blog.category.adapter.outgoing.persistence;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.appliacation.port.outgoing.CategoryRepositoryPort;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.domain.Category;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class CategoryRepositoryAdapter implements CategoryRepositoryPort {
private final JpaCategoryRepository jpaCategoryRepository;
private final MybatisCategoryRepository mybatisCategoryRepository;
@Override
public Optional<Category> findByTitle(String title) {
return jpaCategoryRepository.findByTitle(title);
}
@Override
public List<Category> findAll() {
return jpaCategoryRepository.findAll();
}
@Override
public List<CategorySimpleDto> getCategoryCount() {
return mybatisCategoryRepository.getCategoryCount();
}
@Override
public List<Category> findAllByTierIs(int tier) {
return jpaCategoryRepository.findAllByTierIs(tier);
}
@Override
public List<Category> findAllWithoutDummy() {
return jpaCategoryRepository.findAllWithoutDummy();
}
@Override
public void deleteAll(List<Category> categoryListFromDb) {
jpaCategoryRepository.deleteAll(categoryListFromDb);
}
@Override
public void save(Category category) {
jpaCategoryRepository.save(category);
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.category.repository;
package myblog.blog.category.adapter.outgoing.persistence;
import myblog.blog.category.domain.Category;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -7,7 +7,7 @@ import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category, Long> {
public interface JpaCategoryRepository extends JpaRepository<Category, Long> {
/*
- 카테고리 이름으로 카테고리 찾기

View File

@@ -1,6 +1,6 @@
package myblog.blog.category.repository;
package myblog.blog.category.adapter.outgoing.persistence;
import myblog.blog.category.dto.CategorySimpleView;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Repository;
@@ -9,7 +9,7 @@ import java.util.List;
@Mapper
@Repository
public interface NaCategoryRepository {
public interface MybatisCategoryRepository {
/*
- 카테고리별 아티클 갯수 통계 쿼리
@@ -23,6 +23,6 @@ public interface NaCategoryRepository {
" group by c.title, b.title with rollup) e\n" +
" right join category f on (e.title = f.title)\n" +
" order by pOrder, cOrder ")
List<CategorySimpleView> getCategoryCount();
List<CategorySimpleDto> getCategoryCount();
}

View File

@@ -0,0 +1,23 @@
package myblog.blog.category.appliacation;
import myblog.blog.article.application.port.incomming.response.*;
import myblog.blog.article.domain.Article;
import myblog.blog.article.domain.Tags;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.domain.Category;
import org.mapstruct.*;
@Mapper(
componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface CategoryDtoMapper {
ArticleResponseByCategory category(Article article);
@Mappings({
@Mapping(target = "count",ignore = true),
@Mapping(target = "POrder",ignore = true),
@Mapping(target = "COrder",ignore = true)
})
CategorySimpleDto categorySimpleDto(Category category);
}

View File

@@ -0,0 +1,52 @@
package myblog.blog.category.appliacation;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.appliacation.port.incomming.CategoryQueriesUseCase;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.appliacation.port.incomming.response.CategoryViewForLayout;
import myblog.blog.category.appliacation.port.outgoing.CategoryRepositoryPort;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CategoryQueries implements CategoryQueriesUseCase {
private final CategoryRepositoryPort categoryRepositoryPort;
private final CategoryDtoMapper categoryDtoMapper;
/*
- 카테고리와 카테고리별 아티클 수 찾기
*/
@Override
public List<CategorySimpleDto> getCategorytCountList() {
return categoryRepositoryPort.getCategoryCount();
}
/*
- getCategorytCountList()의 캐싱을 위한 전처리 매핑 로직
- 레이아웃 렌더링 성능 향상을 위해 캐싱작업
카테고리 변경 / 아티클 변경이 존재할경우 레이아웃 캐시 초기화
*/
@Cacheable(value = "layoutCaching", key = "0")
@Override
public CategoryViewForLayout getCategoryViewForLayout() {
return CategoryViewForLayout.from(categoryRepositoryPort.getCategoryCount());
}
/*
- 티어별 카테고리 목록 찾기
*/
@Override
public List<CategorySimpleDto> findCategoryByTier(int tier) {
return categoryRepositoryPort.findAllByTierIs(tier)
.stream()
.map(categoryDtoMapper::categorySimpleDto)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,134 @@
package myblog.blog.category.appliacation;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.domain.Category;
import myblog.blog.category.appliacation.port.incomming.CategoryUseCase;
import myblog.blog.category.appliacation.port.outgoing.CategoryRepositoryPort;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.domain.CategoryNotFoundException;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
public class CategoryService implements CategoryUseCase {
private final CategoryRepositoryPort categoryRepositoryPort;
/*
- 카테고리 이름으로 카테고리 찾기
*/
@Override
public Optional<Category> findCategory(String title) {
return categoryRepositoryPort.findByTitle(title);
}
/*
- 카테고리 이름으로 카테고리 찾기
*/
@Override
public List<Category> getAllCategories() {
return categoryRepositoryPort.findAll();
}
/*
- 카테고리 변경 로직
1. 카테고리 리스트의 순서 작성
2. 입력받은 카테고리리스트와 DB의 전체카테고리 리스트 두개를 큐로 처리하여 비교대조
3. 해당 카테고리 변경처리
3-1 해당 카테고리 존재시 더티체킹으로 업데이트 처리
3-2 DB에 존재하지 않는경우 새로 생성
3-3 DB에만 존재하는 카테고리는 삭제처리
*/
@Override
@Transactional
@CacheEvict(value = {"layoutCaching", "seoCaching"}, allEntries = true)
public void changeCategory(List<CategorySimpleDto> categoryList) {
// 1.카테고리 리스트 순서 작성
CategorySimpleDto.sortByOrder(categoryList);
// 2. 기존 DB 저장된 카테고리 리스트 불러오기
var categoryListFromDb = categoryRepositoryPort.findAllWithoutDummy();
// 3. 카테고리 변경
while (!categoryList.isEmpty()) {
var categorySimpleDto = categoryList.get(0);
categoryList.remove(0);
if (categorySimpleDto.isSuperCategory()) {
Category pCategory = null;
if (categorySimpleDto.isNewCategory()) pCategory = createNewCategory(categorySimpleDto, null);
else {
pCategory = findMatchingCategory(categoryListFromDb, categorySimpleDto, pCategory);
pCategory.updateCategory(categorySimpleDto.getTitle(), categorySimpleDto.getTier(), categorySimpleDto.getPOrder(), categorySimpleDto.getCOrder(), null);
}
while (!categoryList.isEmpty()) {
var subCategorySimpleDto = categoryList.get(0);
if (subCategorySimpleDto.isSuperCategory()) break;
categoryList.remove(0);
Category cCategory = null;
if (subCategorySimpleDto.isNewCategory()) cCategory = createNewCategory(subCategorySimpleDto, pCategory.getTitle());
else {
cCategory = findMatchingCategory(categoryListFromDb, subCategorySimpleDto, cCategory);
cCategory.updateCategory(subCategorySimpleDto.getTitle(), subCategorySimpleDto.getTier(), subCategorySimpleDto.getPOrder(), subCategorySimpleDto.getCOrder(), pCategory);
}
}
}
}
// 3-3 불일치 카테고리 전부 삭제
categoryRepositoryPort.deleteAll(categoryListFromDb);
}
private Category findMatchingCategory(List<Category> categoryListFromDb, CategorySimpleDto categorySimpleDto, Category category) {
for (int i = 0; i < categoryListFromDb.size(); i++) {
if (categoryListFromDb.get(i).getId().equals(categorySimpleDto.getId())) {
category = categoryListFromDb.get(i);
categoryListFromDb.remove(i);
break;
}
}
return category;
}
/*
- 새로운 카테고리 생성하기
- 상위 카테고리 존재 유무 분기
*/
private Category createNewCategory(CategorySimpleDto categorySimpleDto, String parent) {
Category parentCategory = null;
if (parent != null) {
parentCategory = categoryRepositoryPort.findByTitle(parent)
.orElseThrow(CategoryNotFoundException::new);
}
Category category = Category.builder()
.title(categorySimpleDto.getTitle())
.pSortNum(categorySimpleDto.getPOrder())
.cSortNum(categorySimpleDto.getCOrder())
.tier(categorySimpleDto.getTier())
.parents(parentCategory)
.build();
categoryRepositoryPort.save(category);
return category;
}
// /*
// - 최초 필수 더미 카테고리 추가 코드
// */
// @PostConstruct
// private void insertDummyCategory() {
// if(categoryRepositoryPort.findByTitle("total")==null) {
// Category category0 = Category.builder()
// .tier(0)
// .title("total")
// .pSortNum(0)
// .cSortNum(0)
// .build();
// categoryRepositoryPort.save(category0);
// }
// }
}

View File

@@ -0,0 +1,13 @@
package myblog.blog.category.appliacation.port.incomming;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.appliacation.port.incomming.response.CategoryViewForLayout;
import org.springframework.cache.annotation.Cacheable;
import java.util.List;
public interface CategoryQueriesUseCase {
List<CategorySimpleDto> getCategorytCountList();
CategoryViewForLayout getCategoryViewForLayout();
List<CategorySimpleDto> findCategoryByTier(int tier);
}

View File

@@ -0,0 +1,14 @@
package myblog.blog.category.appliacation.port.incomming;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.appliacation.port.incomming.response.CategoryViewForLayout;
import myblog.blog.category.domain.Category;
import java.util.List;
import java.util.Optional;
public interface CategoryUseCase {
Optional<Category> findCategory(String title);
List<Category> getAllCategories();
void changeCategory(List<CategorySimpleDto> categoryList);
}

View File

@@ -0,0 +1,59 @@
package myblog.blog.category.appliacation.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.validation.constraints.NotBlank;
import java.util.List;
/*
- 범용 카테고리 DTO
*/
@Getter
@Setter
@ToString
public class CategorySimpleDto implements Cloneable {
private Long id;
@NotBlank(message = "카테고리명은 공백일 수 없습니다.")
private String title;
private int tier;
private int count;
private int pOrder;
private int cOrder;
@Override
public CategorySimpleDto clone() {
try {
return (CategorySimpleDto) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
/*
- 카테고리 변경을 위해 카테고리의 순번을 작성하는 로직
*/
static public void sortByOrder(List<CategorySimpleDto> categoryList) {
int pOrderIndex = 0;
int cOrderIndex = 0;
//티어별 트리구조로 순서 작성 로직
for (CategorySimpleDto categorySimpleDto : categoryList) {
if (categorySimpleDto.getTier() == 1) {
cOrderIndex = 0;
categorySimpleDto.setPOrder(++pOrderIndex);
categorySimpleDto.setCOrder(cOrderIndex);
} else {
categorySimpleDto.setPOrder(pOrderIndex);
categorySimpleDto.setCOrder(++cOrderIndex);
}
}
}
public boolean isSuperCategory(){
return tier == 1;
}
public boolean isNewCategory(){
return id == null;
}
}

View File

@@ -0,0 +1,67 @@
package myblog.blog.category.appliacation.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/*
- 레이아웃용 트리구조 카테고리 리스트
*/
@Getter
@Setter
public class CategoryViewForLayout {
private int count;
private String title;
private Long id;
private int pOrder;
private int cOrder;
// 트리구조를 갖기 위한 리스트
private List<CategoryViewForLayout> categoryTCountList = new ArrayList<>();
/*
- 스태틱 생성 메서드
*/
public static CategoryViewForLayout from(List<CategorySimpleDto> crList) {
return recursiveBuildFromCategoryDto(0, crList);
}
/*
- 재귀호출로 트리구조 생성
1. DTO객체 생성후 소스를 큐처리로 순차적 매핑
2. Depth 변화시 재귀 호출 / 재귀 탈출
3. 탈출시 상위 카테고리 list로 삽입하여 트리구조 작성
*/
private static CategoryViewForLayout recursiveBuildFromCategoryDto(int tier, List<CategorySimpleDto> source) {
CategoryViewForLayout categoryViewForLayout = new CategoryViewForLayout();
while (!source.isEmpty()) {
CategorySimpleDto cSource = source.get(0);
if (cSource.getTier() == tier) {
if(categoryViewForLayout.getTitle() != null
&& !categoryViewForLayout.getTitle().equals(cSource.getTitle())){
return categoryViewForLayout;
}
categoryViewForLayout.setTitle(cSource.getTitle());
categoryViewForLayout.setCount(cSource.getCount());
categoryViewForLayout.setId(cSource.getId());
categoryViewForLayout.setCOrder(cSource.getCOrder());
categoryViewForLayout.setPOrder(cSource.getPOrder());
source.remove(0);
} else if (cSource.getTier() > tier) {
CategoryViewForLayout sub = recursiveBuildFromCategoryDto(tier + 1, source);
categoryViewForLayout.getCategoryTCountList().add(sub);
} else {
return categoryViewForLayout;
}
}
return categoryViewForLayout;
}
private CategoryViewForLayout() {
}
}

View File

@@ -0,0 +1,17 @@
package myblog.blog.category.appliacation.port.outgoing;
import myblog.blog.category.appliacation.port.incomming.response.CategorySimpleDto;
import myblog.blog.category.domain.Category;
import java.util.List;
import java.util.Optional;
public interface CategoryRepositoryPort {
Optional<Category> findByTitle(String title);
List<Category> findAll();
List<CategorySimpleDto> getCategoryCount();
List<Category> findAllByTierIs(int tier);
List<Category> findAllWithoutDummy();
void deleteAll(List<Category> categoryListFromDb);
void save(Category category);
}

View File

@@ -1,77 +0,0 @@
package myblog.blog.category.controller;
import lombok.RequiredArgsConstructor;
import myblog.blog.shared.exception.CustomFormException;
import myblog.blog.shared.exception.ListValidator;
import myblog.blog.category.dto.CategoryForView;
import myblog.blog.category.dto.CategorySimpleView;
import myblog.blog.category.service.CategoryService;
import myblog.blog.comment.dto.CommentDtoForLayout;
import myblog.blog.comment.service.CommentService;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Controller
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
private final CommentService commentService;
private final ModelMapper modelMapper;
private final ListValidator listValidator;
/*
- 카테고리 수정폼 조회
*/
@GetMapping("/edit/category")
public String editCategoryForm(Model model) {
// DTO 매핑 전처리
List<CategorySimpleView> categoryList = categoryService.getCategorytCountList();
List<CategorySimpleView> copyList = cloneList(categoryList);
copyList.remove(0);
CategoryForView categoryForView = CategoryForView.createCategory(categoryList);
List<CommentDtoForLayout> comments = commentService.recentCommentList();
//
model.addAttribute("categoryForEdit", copyList);
model.addAttribute("category", categoryForView);
model.addAttribute("commentsList", comments);
return "admin/categoryEdit";
}
/*
- 카테고리 수정 요청
*/
@PostMapping("/category/edit")
public @ResponseBody
String editCategory(@RequestBody List<CategorySimpleView> categoryList, Errors errors) {
// List DTO 검증을 위한 커스텀 validator
listValidator.validate(categoryList, errors);
// 유효성 검사
if (errors.hasErrors()) {
throw new CustomFormException(Objects.requireNonNull(errors.getFieldError()).getDefaultMessage());
}
categoryService.changeCategory(categoryList);
return "변경 성공";
}
private List<CategorySimpleView> cloneList(List<CategorySimpleView> categoryList) {
return categoryList
.stream()
.map(categoryNormalDto ->
modelMapper.map(categoryNormalDto, CategorySimpleView.class))
.collect(Collectors.toList());
}
}

View File

@@ -3,8 +3,9 @@ package myblog.blog.category.domain;
import lombok.Builder;
import lombok.Getter;
import myblog.blog.shared.domain.BasicEntity;
import myblog.blog.article.domain.Article;
import myblog.blog.shared.BasicEntity;
import javax.persistence.*;
import java.util.ArrayList;
@@ -55,7 +56,7 @@ public class Category extends BasicEntity {
this.cSortNum = cSortNum;
}
protected Category() {
public Category() {
}
@Override

View File

@@ -0,0 +1,6 @@
package myblog.blog.category.domain;
import myblog.blog.shared.domain.ResourceNotFoundException;
public class CategoryNotFoundException extends ResourceNotFoundException {
}

View File

@@ -1,67 +0,0 @@
package myblog.blog.category.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/*
- 레이아웃용 트리구조 카테고리 리스트
*/
@Getter
@Setter
public class CategoryForView {
private int count;
private String title;
private Long id;
private int pOrder;
private int cOrder;
// 트리구조를 갖기 위한 리스트
private List<CategoryForView> categoryTCountList = new ArrayList<>();
/*
- 스태틱 생성 메서드
*/
public static CategoryForView createCategory(List<CategorySimpleView> crList) {
return recursiveBuildFromCategoryDto(0, crList);
}
/*
- 재귀호출로 트리구조 생성
1. DTO객체 생성후 소스를 큐처리로 순차적 매핑
2. Depth 변화시 재귀 호출 / 재귀 탈출
3. 탈출시 상위 카테고리 list로 삽입하여 트리구조 작성
*/
private static CategoryForView recursiveBuildFromCategoryDto(int tier, List<CategorySimpleView> source) {
CategoryForView categoryForView = new CategoryForView();
while (!source.isEmpty()) {
CategorySimpleView cSource = source.get(0);
if (cSource.getTier() == tier) {
if(categoryForView.getTitle() != null
&& !categoryForView.getTitle().equals(cSource.getTitle())){
return categoryForView;
}
categoryForView.setTitle(cSource.getTitle());
categoryForView.setCount(cSource.getCount());
categoryForView.setId(cSource.getId());
categoryForView.setCOrder(cSource.getCOrder());
categoryForView.setPOrder(cSource.getPOrder());
source.remove(0);
} else if (cSource.getTier() > tier) {
CategoryForView sub = recursiveBuildFromCategoryDto(tier + 1, source);
categoryForView.getCategoryTCountList().add(sub);
} else {
return categoryForView;
}
}
return categoryForView;
}
private CategoryForView() {
}
}

View File

@@ -1,25 +0,0 @@
package myblog.blog.category.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.validation.constraints.NotBlank;
/*
- 범용 카테고리 DTO
*/
@Getter
@Setter
@ToString
public class CategorySimpleView {
private Long id;
@NotBlank(message = "카테고리명은 공백일 수 없습니다.")
private String title;
private int tier;
private int count;
private int pOrder;
private int cOrder;
}

View File

@@ -1,208 +0,0 @@
package myblog.blog.category.service;
import lombok.RequiredArgsConstructor;
import myblog.blog.category.domain.Category;
import myblog.blog.category.dto.CategorySimpleView;
import myblog.blog.category.dto.CategoryForView;
import myblog.blog.category.repository.CategoryRepository;
import myblog.blog.category.repository.NaCategoryRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;
private final NaCategoryRepository naCategoryRepository;
/*
- 카테고리 이름으로 카테고리 찾기
*/
public Category findCategory(String title) {
return categoryRepository.findByTitle(title)
.orElseThrow(() -> new IllegalArgumentException("NotFoundCategoryException"));
}
/*
- 카테고리 이름으로 카테고리 찾기
*/
public List<Category> getAllCategories() {
return categoryRepository.findAll();
}
/*
- 카테고리와 카테고리별 아티클 수 찾기
*/
public List<CategorySimpleView> getCategorytCountList() {
return naCategoryRepository.getCategoryCount();
}
/*
- getCategorytCountList()의 캐싱을 위한 전처리 매핑 로직
- 본래는 컨트롤러단에서 존재해야할 dto 매핑코드지만 캐싱을 위해 서비스단으로 이동
- 레이아웃 렌더링 성능 향상을 위해 캐싱작업
카테고리 변경 / 아티클 변경이 존재할경우 레이아웃 캐시 초기화
*/
@Cacheable(value = "layoutCaching", key = "0")
public CategoryForView getCategoryForView() {
return CategoryForView.createCategory(naCategoryRepository.getCategoryCount());
}
/*
- 티어별 카테고리 목록 찾기
*/
public List<Category> findCategoryByTier(int tier) {
return categoryRepository.findAllByTierIs(tier);
}
/*
- 카테고리 변경 로직
1. 카테고리 리스트의 순서 작성
2. 입력받은 카테고리리스트와 DB의 전체카테고리 리스트 두개를 큐로 처리하여 비교대조
3. 해당 카테고리 변경처리
3-1 해당 카테고리 존재시 더티체킹으로 업데이트 처리
3-2 DB에 존재하지 않는경우 새로 생성
3-3 DB에만 존재하는 카테고리는 삭제처리
*/
@Transactional
@CacheEvict(value = {"layoutCaching", "seoCaching"}, allEntries = true)
public void changeCategory(List<CategorySimpleView> categoryList) {
// 1.카테고리 리스트 순서 작성
sortingOrder(categoryList);
// 2. 기존 DB 저장된 카테고리 리스트 불러오기
List<Category> categoryListFromDb = categoryRepository.findAllWithoutDummy();
// 3. 카테고리 변경 루프
while (!categoryList.isEmpty()) {
CategorySimpleView categorySimpleView = categoryList.get(0);
categoryList.remove(0);
// 부모카테고리인경우
if (categorySimpleView.getTier() == 1) {
Category pCategory = null;
// 부모카테고리가 기존에 존재 x
if (categorySimpleView.getId() == null) {
pCategory = createNewCategory(categorySimpleView, null);
}
// 부모카테고리가 기존에 존재 o
else {
for (int i = 0; i < categoryListFromDb.size(); i++) {
if (categoryListFromDb.get(i).getId().equals(categorySimpleView.getId())) {
pCategory = categoryListFromDb.get(i);
categoryListFromDb.remove(i);
break;
}
}
pCategory.updateCategory(
categorySimpleView.getTitle(),
categorySimpleView.getTier(),
categorySimpleView.getPOrder(),
categorySimpleView.getCOrder(),
null
);
}
while (!categoryList.isEmpty()) {
CategorySimpleView subCategorySimpleView = categoryList.get(0);
if (subCategorySimpleView.getTier() == 1) break;
categoryList.remove(0);
// 자식 카테고리인경우
Category cCategory = null;
// 카테고리가 기존에 존재 x
if (subCategorySimpleView.getId() == null) {
cCategory = createNewCategory(subCategorySimpleView, pCategory.getTitle());
}
// 카테고리가 기존에 존재 o
else {
for (int i = 0; i < categoryListFromDb.size(); i++) {
if (categoryListFromDb.get(i).getId().equals(subCategorySimpleView.getId())) {
cCategory = categoryListFromDb.get(i);
categoryListFromDb.remove(i);
break;
}
}
cCategory.updateCategory(
subCategorySimpleView.getTitle(),
subCategorySimpleView.getTier(),
subCategorySimpleView.getPOrder(),
subCategorySimpleView.getCOrder(),
pCategory);
}
}
}
}
// 3-3 불일치 카테고리 전부 삭제
categoryRepository.deleteAll(categoryListFromDb);
}
/*
- 새로운 카테고리 생성하기
- 상위 카테고리 존재 유무 분기
*/
private Category createNewCategory(CategorySimpleView categorySimpleView, String parent) {
Category parentCategory = null;
if (parent != null) {
parentCategory = categoryRepository.findByTitle(parent)
.orElseThrow(() -> new IllegalArgumentException("NotFoundCategoryException"));
}
Category category = Category.builder()
.title(categorySimpleView.getTitle())
.pSortNum(categorySimpleView.getPOrder())
.cSortNum(categorySimpleView.getCOrder())
.tier(categorySimpleView.getTier())
.parents(parentCategory)
.build();
categoryRepository.save(category);
return category;
}
/*
- 카테고리 변경을 위해 카테고리의 순번을 작성하는 로직
*/
private void sortingOrder(List<CategorySimpleView> categoryList) {
int pOrderIndex = 0;
int cOrderIndex = 0;
//티어별 트리구조로 순서 작성 로직
for (CategorySimpleView categoryDto : categoryList) {
if (categoryDto.getTier() == 1) {
cOrderIndex = 0;
categoryDto.setPOrder(++pOrderIndex);
categoryDto.setCOrder(cOrderIndex);
} else {
categoryDto.setPOrder(pOrderIndex);
categoryDto.setCOrder(++cOrderIndex);
}
}
}
/*
- 최초 필수 더미 카테고리 추가 코드
*/
// @PostConstruct
private void insertDummyCategory() {
if(categoryRepository.findByTitle("total")==null) {
Category category0 = Category.builder()
.tier(0)
.title("total")
.pSortNum(0)
.cSortNum(0)
.build();
categoryRepository.save(category0);
}
}
}

View File

@@ -0,0 +1,9 @@
package myblog.blog.comment.adapter.incomming;
import myblog.blog.shared.domain.BadRequestException;
/*
- REST 컨트롤러 상태 메세지 전송용 커스텀 에러
*/
public class CommentBadRequestException extends BadRequestException {
}

View File

@@ -0,0 +1,63 @@
package myblog.blog.comment.adapter.incomming;
import myblog.blog.comment.application.port.incomming.CommentQueriesUseCase;
import myblog.blog.comment.application.port.incomming.CommentUseCase;
import myblog.blog.comment.application.port.incomming.response.CommentDto;
import myblog.blog.member.application.port.incomming.response.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import myblog.blog.member.application.port.incomming.response.MemberVo;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequiredArgsConstructor
public class CommentController {
private final CommentUseCase commentUseCase;
private final CommentQueriesUseCase commentQueriesUseCase;
/*
- 아티클 조회시 아티클에 달린 댓글들 전체 조회
*/
@GetMapping("/comment/list/{articleId}")
List<CommentDto> getCommentList(@PathVariable Long articleId){
return commentQueriesUseCase.getCommentList(articleId);
}
/*
- 댓글 작성 요청
*/
@PostMapping("/comment/write")
List<CommentDto> getCommentList(@RequestParam Long articleId,
@RequestParam(required = false) Long parentId,
@Validated @RequestBody CommentForm commentForm, Errors errors,
@AuthenticationPrincipal PrincipalDetails principal){
if (errors.hasErrors()) {
throw new CommentBadRequestException();
}
MemberVo member = principal.getMember();
// 부모 댓글인지 자식댓글인지 분기로 저장
if(parentId != null){
commentUseCase.saveCComment(commentForm.getContent(), commentForm.isSecret(), member.getId(), articleId, parentId);
}
else {
commentUseCase.savePComment(commentForm.getContent(), commentForm.isSecret(), member.getId(), articleId);
}
return commentQueriesUseCase.getCommentList(articleId);
}
/*
- 댓글 삭제 요청
*/
@PostMapping("/comment/delete")
List<CommentDto> deleteComment(@RequestParam Long articleId,
@RequestParam Long commentId) {
commentUseCase.deleteComment(commentId);
return commentQueriesUseCase.getCommentList(articleId);
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.comment.dto;
package myblog.blog.comment.adapter.incomming;
import lombok.Data;

View File

@@ -0,0 +1,48 @@
package myblog.blog.comment.adapter.outgoing.persistence;
import lombok.RequiredArgsConstructor;
import myblog.blog.comment.application.port.outgoing.CommentRepositoryPort;
import myblog.blog.article.domain.Article;
import myblog.blog.comment.domain.Comment;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class CommentRepositoryAdapter implements CommentRepositoryPort {
private final JpaCommentRepository jpaCommentRepository;
private final MybatisCommentRepository mybatisCommentRepository;
@Override
public int countCommentsByArticleAndTier(Article article, int tier) {
return jpaCommentRepository.countCommentsByArticleAndTier(article, tier);
}
@Override
public void save(Comment comment) {
jpaCommentRepository.save(comment);
}
@Override
public List<Comment> findCommentsByArticleId(Long articleId) {
return jpaCommentRepository.findCommentsByArticleId(articleId);
}
@Override
public void deleteComment(Long commentId) {
mybatisCommentRepository.deleteComment(commentId);
}
@Override
public List<Comment> findTop5ByOrderByIdDesc() {
return jpaCommentRepository.findTop5ByOrderByIdDesc();
}
@Override
public Optional<Comment> findById(Long parentId) {
return jpaCommentRepository.findById(parentId);
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.comment.repository;
package myblog.blog.comment.adapter.outgoing.persistence;
import myblog.blog.article.domain.Article;
import myblog.blog.comment.domain.Comment;
@@ -8,7 +8,7 @@ import org.springframework.data.repository.query.Param;
import java.util.List;
public interface CommentRepository extends JpaRepository<Comment, Long> {
public interface JpaCommentRepository extends JpaRepository<Comment, Long> {
/*
- 특정 아티클에 해당하는 댓글 리스트 가져오기

View File

@@ -1,10 +1,10 @@
package myblog.blog.comment.repository;
package myblog.blog.comment.adapter.outgoing.persistence;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface NaCommentRepository {
public interface MybatisCommentRepository {
/*
- cascade 삭제처리

View File

@@ -0,0 +1,43 @@
package myblog.blog.comment.application;
import lombok.RequiredArgsConstructor;
import myblog.blog.comment.application.port.incomming.CommentQueriesUseCase;
import myblog.blog.comment.application.port.incomming.response.CommentDto;
import myblog.blog.comment.application.port.incomming.response.CommentDtoForLayout;
import myblog.blog.comment.application.port.outgoing.CommentRepositoryPort;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CommentQueries implements CommentQueriesUseCase {
private final CommentRepositoryPort commentRepositoryPort;
/*
- 아티클에 달린 댓글 전체 가져오기
*/
@Override
public List<CommentDto> getCommentList(Long articleId){
return CommentDto.createCommentListFrom(commentRepositoryPort.findCommentsByArticleId(articleId),0);
}
/*
- 최신 댓글 5개 가져오기
- 레이아웃 렌더링 성능 향상을 위해 캐싱작업
카테고리 변경 / 아티클 변경이 존재할경우 레이아웃 캐시 초기화
DTO 매핑 로직 서비스단에서 처리
*/
@Cacheable(value = "layoutRecentCommentCaching", key = "0")
@Override
public List<CommentDtoForLayout> recentCommentListForLayout(){
return commentRepositoryPort.findTop5ByOrderByIdDesc()
.stream()
.map(comment ->
new CommentDtoForLayout(comment.getId(), comment.getArticle().getId(), comment.getContent(), comment.isSecret()))
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,81 @@
package myblog.blog.comment.application;
import myblog.blog.article.application.port.incomming.ArticleUseCase;
import myblog.blog.comment.application.port.incomming.CommentUseCase;
import myblog.blog.comment.application.port.outgoing.CommentRepositoryPort;
import myblog.blog.comment.domain.Comment;
import myblog.blog.article.domain.Article;
import myblog.blog.comment.domain.NotFoundParentCommnetException;
import myblog.blog.member.doamin.Member;
import lombok.RequiredArgsConstructor;
import myblog.blog.member.application.port.incomming.MemberQueriesUseCase;
import myblog.blog.member.doamin.NotFoundMemberException;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
public class CommentService implements CommentUseCase {
private final ArticleUseCase articleUseCase;
private final MemberQueriesUseCase memberQueriesUseCase;
private final CommentRepositoryPort commentRepositoryPort;
/*
- 부모 댓글 저장
*/
@CacheEvict(value = "layoutRecentCommentCaching", allEntries = true)
@Override
public void savePComment(String content, boolean secret, Long memberId, Long articleId){
var member = memberQueriesUseCase.findById(memberId)
.orElseThrow(NotFoundMemberException::new);
var article = articleUseCase.getArticle(articleId);
Comment comment = Comment.builder()
.article(article)
.content(content)
.tier(0)
.pOrder(commentRepositoryPort.countCommentsByArticleAndTier(article,0)+1)
.member(member)
.secret(secret)
.build();
commentRepositoryPort.save(comment);
}
/*
- 자식 댓글 저장
*/
@CacheEvict(value = "layoutRecentCommentCaching", allEntries = true)
@Override
public void saveCComment(String content, boolean secret, Long memberId, Long articleId, Long parentId) {
var member = memberQueriesUseCase.findById(memberId)
.orElseThrow(NotFoundMemberException::new);;
var article = articleUseCase.getArticle(articleId);
var pComment = commentRepositoryPort.findById(parentId)
.orElseThrow(NotFoundParentCommnetException::new);
var comment = Comment.builder()
.article(article)
.content(content)
.tier(1)
.pOrder(pComment.getPOrder())
.member(member)
.parents(pComment)
.secret(secret)
.build();
commentRepositoryPort.save(comment);
}
/*
- 댓글 삭제
*/
@CacheEvict(value = "layoutRecentCommentCaching", allEntries = true)
@Override
public void deleteComment(Long commentId){
commentRepositoryPort.deleteComment(commentId);
}
}

View File

@@ -0,0 +1,11 @@
package myblog.blog.comment.application.port.incomming;
import myblog.blog.comment.application.port.incomming.response.CommentDto;
import myblog.blog.comment.application.port.incomming.response.CommentDtoForLayout;
import java.util.List;
public interface CommentQueriesUseCase {
List<CommentDto> getCommentList(Long articleId);
List<CommentDtoForLayout> recentCommentListForLayout();
}

View File

@@ -0,0 +1,12 @@
package myblog.blog.comment.application.port.incomming;
import myblog.blog.comment.application.port.incomming.response.CommentDto;
import myblog.blog.comment.application.port.incomming.response.CommentDtoForLayout;
import java.util.List;
public interface CommentUseCase {
void savePComment(String content, boolean secret, Long memberId, Long articleId);
void saveCComment(String content, boolean secret, Long memberId, Long articleId, Long parentId);
void deleteComment(Long commentId);
}

View File

@@ -1,13 +1,11 @@
package myblog.blog.comment.dto;
package myblog.blog.comment.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;
import myblog.blog.comment.domain.Comment;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.*;
/*
@@ -36,7 +34,7 @@ public class CommentDto {
2. Depth 변화시 재귀 호출 / 재귀 탈출
3. 탈출시 상위 카테고리 list로 삽입하여 트리구조 작성
*/
public static List<CommentDto> listCreateFrom(List<Comment> commentSource, int dept) {
public static List<CommentDto> createCommentListFrom(List<Comment> commentSource, int dept) {
ArrayList<CommentDto> commentDtoList = new ArrayList<>();
@@ -51,7 +49,7 @@ public class CommentDto {
commentDtoList.add(new CommentDto(comment));
commentSource.remove(0);
} else if (comment.getTier() > dept) {
List<CommentDto> childList = listCreateFrom(commentSource, dept + 1);
List<CommentDto> childList = createCommentListFrom(commentSource, dept + 1);
commentDtoList.get(commentDtoList.size() - 1)
.setCommentDtoList(childList);
} else {

View File

@@ -1,4 +1,4 @@
package myblog.blog.comment.dto;
package myblog.blog.comment.application.port.incomming.response;
import lombok.Getter;
import lombok.Setter;

View File

@@ -0,0 +1,16 @@
package myblog.blog.comment.application.port.outgoing;
import myblog.blog.article.domain.Article;
import myblog.blog.comment.domain.Comment;
import java.util.List;
import java.util.Optional;
public interface CommentRepositoryPort {
int countCommentsByArticleAndTier(Article article, int tier);
void save(Comment comment);
List<Comment> findCommentsByArticleId(Long articleId);
void deleteComment(Long commentId);
List<Comment> findTop5ByOrderByIdDesc();
Optional<Comment> findById(Long parentId);
}

View File

@@ -1,68 +0,0 @@
package myblog.blog.comment.controller;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.incomming.ArticleQueriesUseCase;
import myblog.blog.article.domain.Article;
import myblog.blog.article.application.ArticleService;
import myblog.blog.comment.dto.CommentDto;
import myblog.blog.comment.dto.CommentForm;
import myblog.blog.comment.service.CommentService;
import myblog.blog.shared.exception.CustomFormException;
import myblog.blog.member.auth.PrincipalDetails;
import myblog.blog.member.doamin.Member;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Objects;
@RestController
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
/*
- 아티클 조회시 아티클에 달린 댓글들 전체 조회
*/
@GetMapping("/comment/list/{articleId}")
public List<CommentDto> getCommentList(@PathVariable Long articleId){
return CommentDto.listCreateFrom(commentService.getCommentList(articleId),0);
}
/*
- 댓글 작성 요청
*/
@PostMapping("/comment/write")
public List<CommentDto> getCommentList(@RequestParam Long articleId,
@RequestParam(required = false) Long parentId,
@Validated @RequestBody CommentForm commentForm, Errors errors,
@AuthenticationPrincipal PrincipalDetails principal){
if (errors.hasErrors()) {
throw new CustomFormException(Objects.requireNonNull(errors.getFieldError()).getDefaultMessage());
}
Member member = principal.getMember();
// 부모 댓글인지 자식댓글인지 분기로 저장
if(parentId != null){
commentService.saveCComment(commentForm, member, articleId, parentId);
}
else {
commentService.savePComment(commentForm, member, articleId);
}
return CommentDto.listCreateFrom(commentService.getCommentList(articleId),0);
}
/*
- 댓글 삭제 요청
*/
@PostMapping("/comment/delete")
public List<CommentDto> deleteComment(@RequestParam Long articleId,
@RequestParam Long commentId) {
commentService.deleteComment(commentId);
return CommentDto.listCreateFrom(commentService.getCommentList(articleId),0);
}
}

View File

@@ -3,7 +3,8 @@ package myblog.blog.comment.domain;
import lombok.Builder;
import lombok.Getter;
import myblog.blog.article.domain.Article;
import myblog.blog.shared.BasicEntity;
import myblog.blog.comment.adapter.incomming.CommentBadRequestException;
import myblog.blog.shared.domain.BasicEntity;
import myblog.blog.member.doamin.Member;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
@@ -62,9 +63,40 @@ public class Comment extends BasicEntity {
this.tier = tier;
this.parents = parents;
this.member = member;
this.content = content;
this.content = removeDuplicatedEnter(content);
this.secret = secret;
}
protected Comment() {}
/*
- 중복 개행 개행 하나로 압축 알고리즘
*/
private String removeDuplicatedEnter(String content) {
if(content == null || content.isEmpty()){
throw new CommentBadRequestException();
}
char[] contentBox = new char[content.length()];
int idx = 0;
String zipWord = "\n\n";
for(int i = 0; i< content.length(); i++){
contentBox[idx] = content.charAt(i);
if(contentBox[idx] == '\n'&&idx >= 1){
int tempIdx = idx;
int length = 1;
boolean isRemove = true;
for(int j = 0; j<2; j++){
if(contentBox[tempIdx--] != zipWord.charAt(length--)){
isRemove = false;
break;
}
}
if(isRemove) idx -= 1;
}
idx++;
}
return String.valueOf(contentBox).trim();
}
}

View File

@@ -0,0 +1,6 @@
package myblog.blog.comment.domain;
import myblog.blog.shared.domain.ResourceNotFoundException;
public class NotFoundParentCommnetException extends ResourceNotFoundException {
}

View File

@@ -1,139 +0,0 @@
package myblog.blog.comment.service;
import lombok.RequiredArgsConstructor;
import myblog.blog.article.application.port.incomming.ArticleUseCase;
import myblog.blog.article.domain.Article;
import myblog.blog.comment.domain.Comment;
import myblog.blog.comment.dto.CommentDtoForLayout;
import myblog.blog.comment.dto.CommentForm;
import myblog.blog.comment.repository.CommentRepository;
import myblog.blog.comment.repository.NaCommentRepository;
import myblog.blog.member.doamin.Member;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional
@RequiredArgsConstructor
public class CommentService {
private final ArticleUseCase articleUseCase;
private final CommentRepository commentRepository;
private final NaCommentRepository naCommentRepository;
/*
- 아티클에 달린 댓글 전체 가져오기
*/
public List<Comment> getCommentList(Long articleId){
return commentRepository.findCommentsByArticleId(articleId);
}
/*
- 부모 댓글 저장
*/
@CacheEvict(value = "layoutRecentCommentCaching", allEntries = true)
public void savePComment(CommentForm commentForm, Member member, Long articleId){
Article article = articleUseCase.getArticle(articleId);
Comment comment = Comment.builder()
.article(article)
.content(removeDuplicatedEnter(commentForm))
.tier(0)
.pOrder(commentRepository.countCommentsByArticleAndTier(article,0)+1)
.member(member)
.secret(commentForm.isSecret())
.build();
commentRepository.save(comment);
}
/*
- 자식 댓글 저장
*/
@CacheEvict(value = "layoutRecentCommentCaching", allEntries = true)
public void saveCComment(CommentForm commentForm, Member member, Long articleId, Long parentId) {
Article article = articleUseCase.getArticle(articleId);
Comment pComment = commentRepository.findById(parentId).get();
Comment comment = Comment.builder()
.article(article)
.content(removeDuplicatedEnter(commentForm))
.tier(1)
.pOrder(pComment.getPOrder())
.member(member)
.parents(pComment)
.secret(commentForm.isSecret())
.build();
commentRepository.save(comment);
}
/*
- 댓글 삭제
*/
@CacheEvict(value = "layoutRecentCommentCaching", allEntries = true)
public void deleteComment(Long commentId){
naCommentRepository.deleteComment(commentId);
}
/*
- 최신 댓글 5개 가져오기
- 레이아웃 렌더링 성능 향상을 위해 캐싱작업
카테고리 변경 / 아티클 변경이 존재할경우 레이아웃 캐시 초기화
DTO 매핑 로직 서비스단에서 처리
*/
@Cacheable(value = "layoutRecentCommentCaching", key = "0")
public List<CommentDtoForLayout> recentCommentList(){
return commentRepository.findTop5ByOrderByIdDesc()
.stream()
.map(comment ->
new CommentDtoForLayout(comment.getId(), comment.getArticle().getId(), comment.getContent(), comment.isSecret()))
.collect(Collectors.toList());
}
/*
- 중복 개행 개행 하나로 압축 알고리즘
*/
private String removeDuplicatedEnter(CommentForm commentForm) {
char[] contentBox = new char[commentForm.getContent().length()];
int idx = 0;
String zipWord = "\n\n";
for(int i = 0; i< commentForm.getContent().length(); i++){
contentBox[idx] = commentForm.getContent().charAt(i);
if(contentBox[idx] == '\n'&&idx >= 1){
int tempIdx = idx;
int length = 1;
boolean isRemove = true;
for(int j = 0; j<2; j++){
if(contentBox[tempIdx--] != zipWord.charAt(length--)){
isRemove = false;
break;
}
}
if(isRemove) idx -= 1;
}
idx++;
}
return String.valueOf(contentBox).trim();
}
}

View File

@@ -1,27 +0,0 @@
package myblog.blog.img.controller;
import lombok.RequiredArgsConstructor;
import myblog.blog.img.dto.UploadImgDto;
import myblog.blog.img.service.UploadImgService;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
@RestController
@RequiredArgsConstructor
public class UploadImgController {
private final UploadImgService uploadImgService;
/*
- 썸네일 업로드 요청
*/
@PostMapping("/article/uploadImg")
public @ResponseBody
String imgUpload(@ModelAttribute UploadImgDto uploadImgDto) throws IOException {
return uploadImgService.storeImg(uploadImgDto.getImg());
}
}

View File

@@ -1,69 +0,0 @@
package myblog.blog.img.service;
import lombok.RequiredArgsConstructor;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.UUID;
@Service
@Transactional
@RequiredArgsConstructor
public class UploadImgService {
/*
- 설정 파일로 잡아놓은 깃헙 이미지 레포지토리와 토큰
*/
@Value("${git.gitToken}")
private String gitToken;
@Value("${git.imgRepo}")
private String gitRepo;
@Value("${git.imgUrl}")
private String imgUrl;
/*
- 이미지 저장 로직
1. 깃허브 Repo에 이미지 업로드
2. 업로드된 Url 반환
*/
public String storeImg(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
throw new IllegalArgumentException("이미지가 존재하지 않습니다.");
}
String storeFileName = createStoreFileName(multipartFile.getOriginalFilename());
GitHub gitHub = new GitHubBuilder().withOAuthToken(gitToken).build();
GHRepository repository = gitHub.getRepository(gitRepo);
repository.createContent().path("img/"+storeFileName)
.content(multipartFile.getBytes()).message("thumbnail").branch("main").commit();
return imgUrl + storeFileName + "?raw=true";
}
/*
- 이미지 중복 방지용 무작위 파일 이름 생성기
*/
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
/*
- 파일 이름 추출
*/
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}

View File

@@ -0,0 +1,18 @@
package myblog.blog.imgupload.adapter.incomming;
import myblog.blog.imgupload.application.port.incomming.ImgUploadUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
public class UploadImgController {
private final ImgUploadUseCase imgUploadUseCase;
@PostMapping("/article/uploadImg")
String imgUpload(@ModelAttribute UploadImgForm uploadImgForm){
return imgUploadUseCase.storeImg(uploadImgForm.getImg());
}
}

View File

@@ -1,14 +1,11 @@
package myblog.blog.img.dto;
package myblog.blog.imgupload.adapter.incomming;
import lombok.Getter;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;
/*
- 멀티파트 파일 래핑용 DTO
*/
@Getter
@Setter
public class UploadImgDto {
public class UploadImgForm {
private MultipartFile img;
}

View File

@@ -0,0 +1,49 @@
package myblog.blog.imgupload.adapter.outgoing;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.*;
import lombok.RequiredArgsConstructor;
import myblog.blog.imgupload.domain.ImageFile;
import myblog.blog.imgupload.application.port.outgoing.ImgUploadStrategyPort;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
/**
* S3 이미지 업로드 전략 구현체
*/
@RequiredArgsConstructor
@Service
public class AwsS3ImgUploadStrategyAdapter implements ImgUploadStrategyPort {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
public String uploadFile(ImageFile imageFile) {
var file = imageFile.getMultipartFile();
var metadata = createObjectMetadata(file);
try(var inputStream = file.getInputStream()) {
amazonS3.putObject(new PutObjectRequest(bucket, imageFile.getStoredFileName(), inputStream, metadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
} catch (IOException e) {
throw new IllegalArgumentException("파일 업로드에 실패했습니다.");
}
return amazonS3.getUrl(bucket, imageFile.getStoredFileName()).toString();
}
/**
* s3 api를 위한 dto 생성 메서드
* @param file 업로드 요청된 이미지 파일
* @return s3 api 요구 스펙 dto
*/
private ObjectMetadata createObjectMetadata(MultipartFile file) {
var metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
return metadata;
}
}

View File

@@ -0,0 +1,43 @@
package myblog.blog.imgupload.adapter.outgoing;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/**
* 깃헙 레포 이미지 업로드 전략 구현체
*/
//@RequiredArgsConstructor
//@Service
@Deprecated
public class GithubRepoImgUploadStrategyAdapter {
/*
- 설정 파일로 잡아놓은 깃헙 이미지 레포지토리와 토큰
*/
@Value("${git.gitToken}")
private String gitToken;
@Value("${git.imgRepo}")
private String gitRepo;
@Value("${git.imgUrl}")
private String rootUrl;
/*
- 이미지 업로드 로직
1. 깃허브 Repo에 이미지 업로드
2. 업로드된 Url 반환
*/
public String uploadFile(MultipartFile multipartFile, String storeFileName) throws IOException {
GitHub gitHub = new GitHubBuilder().withOAuthToken(gitToken).build();
GHRepository repository = gitHub.getRepository(gitRepo);
repository.createContent().path("img/"+storeFileName)
.content(multipartFile.getBytes()).message("thumbnail").branch("main").commit();
return rootUrl + storeFileName + "?raw=true";
}
}

View File

@@ -0,0 +1,30 @@
package myblog.blog.imgupload.application;
import lombok.RequiredArgsConstructor;
import myblog.blog.imgupload.domain.ImageFile;
import myblog.blog.imgupload.application.port.incomming.ImgUploadUseCase;
import myblog.blog.imgupload.application.port.outgoing.ImgUploadStrategyPort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@Service
@Transactional
@RequiredArgsConstructor
public class ImgUploadService implements ImgUploadUseCase {
private final ImgUploadStrategyPort imgUploadStrategyPort;
@Override
public String storeImg(MultipartFile multipartFile) {
validateFile(multipartFile);
var imageFile = ImageFile.from(multipartFile);
return imgUploadStrategyPort.uploadFile(imageFile);
}
private void validateFile(MultipartFile multipartFile) {
if (multipartFile.isEmpty()) {
throw new IllegalArgumentException("이미지가 존재하지 않습니다.");
}
}
}

View File

@@ -0,0 +1,7 @@
package myblog.blog.imgupload.application.port.incomming;
import org.springframework.web.multipart.MultipartFile;
public interface ImgUploadUseCase {
String storeImg(MultipartFile img);
}

View File

@@ -0,0 +1,10 @@
package myblog.blog.imgupload.application.port.outgoing;
import myblog.blog.imgupload.domain.ImageFile;
/**
* 파일 업로드 전략패턴 추상화 인터페이스
*/
public interface ImgUploadStrategyPort {
String uploadFile(ImageFile imageFile);
}

View File

@@ -0,0 +1,38 @@
package myblog.blog.imgupload.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.web.multipart.MultipartFile;
import java.util.UUID;
@Getter
@AllArgsConstructor
public class ImageFile {
String originalFileName;
String storedFileName;
MultipartFile multipartFile;
static public ImageFile from(MultipartFile multipartFile){
return new ImageFile(multipartFile.getOriginalFilename(),
createStoreFileName(multipartFile.getOriginalFilename()),
multipartFile
);
}
/*
- 이미지 중복 방지용 무작위 파일 이름 생성기
*/
private static String createStoreFileName(String originalFilename) {
var ext = extractExt(originalFilename);
var uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
/*
- 파일 이름 추출
*/
private static String extractExt(String originalFilename) {
var pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}

View File

@@ -1,4 +1,4 @@
package myblog.blog.base;
package myblog.blog.infra;
import org.hibernate.dialect.MySQL8Dialect;
import org.hibernate.dialect.function.SQLFunctionTemplate;

View File

@@ -1,4 +1,4 @@
package myblog.blog.main;
package myblog.blog.infra;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
@@ -17,15 +17,11 @@ public class ProfileController {
@GetMapping("/profile")
public String profile(){
List<String> profiles = Arrays.asList(env.getActiveProfiles());
List<String> realProfiles = Arrays.asList("real", "real1", "real2");
String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);
String defaultProfile = profiles.isEmpty() ? "default" : profiles.get(0);
return profiles.stream()
.filter(realProfiles::contains)
.findAny()
.orElse(defaultProfile);
}
}

View File

@@ -0,0 +1,33 @@
package myblog.blog.infra.config;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Value("${cloud.aws.credentials.accessKey}")
private String AWS_ACCESS_KEY_ID;
@Value("${cloud.aws.credentials.secretKey}")
private String AWS_SECRET_ACCESS_KEY;
/**
* AWS S3 설정
*/
@Bean
public AmazonS3 S3() {
AWSCredentials awsCredentials =
new BasicAWSCredentials(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials)).withRegion(Regions.AP_NORTHEAST_2)
.build();
}
}

Some files were not shown because too many files have changed in this diff Show More