diff --git a/.github/workflows/deployment-dev-api-server.yml b/.github/workflows/deployment-dev-api-server.yml deleted file mode 100644 index 4cb997c2..00000000 --- a/.github/workflows/deployment-dev-api-server.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: "[CD] DEV API 서버" -on: - push: - branches: - - dev - paths: - - "server/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-deployment-server.yml - with: - docker-context: server - docker-image-name: weview-dev - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} - NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }} - NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }} - NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }} - NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-dev-client.yml b/.github/workflows/deployment-dev-client.yml deleted file mode 100644 index 05d76bf5..00000000 --- a/.github/workflows/deployment-dev-client.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: "[CD] DEV 클라이언트" -on: - push: - branches: - - dev - paths: - - "client/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-deployment-client.yml - with: - bucket-name: weview-dev - secrets: - VITE_SERVER_URL: ${{ secrets.DEV_VITE_SERVER_URL }} - VITE_LOCAL_URL: ${{ secrets.DEV_VITE_LOCAL_URL }} - VITE_GITHUB_AUTH_SERVER_URL: ${{ secrets.DEV_VITE_GITHUB_AUTH_SERVER_URL }} - VITE_API_MODE: ${{ secrets.DEV_VITE_API_MODE }} - NCLOUD_BUCKET_ACCESS_KEY: ${{ secrets.DEV_NCLOUD_BUCKET_ACCESS_KEY }} - NCLOUD_BUCKET_SECRET_KEY: ${{ secrets.DEV_NCLOUD_BUCKET_SECRET_KEY }} - NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }} - NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }} - NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }} - NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-dev-scheduler-server.yml b/.github/workflows/deployment-dev-scheduler-server.yml deleted file mode 100644 index 8b256a3d..00000000 --- a/.github/workflows/deployment-dev-scheduler-server.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: "[CD] DEV 스케쥴러 서버" -on: - push: - branches: - - dev - paths: - - "scheduler-server/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-deployment-server.yml - with: - docker-context: scheduler-server - docker-image-name: weview-scheduler-dev - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} - NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }} - NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }} - NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }} - NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-dev.yml b/.github/workflows/deployment-dev.yml new file mode 100644 index 00000000..e2c103fa --- /dev/null +++ b/.github/workflows/deployment-dev.yml @@ -0,0 +1,79 @@ +name: "[CD] DEV" + +on: + push: + branches: + - dev + workflow_dispatch: + +jobs: + path-check: + runs-on: ubuntu-20.04 + outputs: + client: ${{ steps.filter.outputs.client }} + server: ${{ steps.filter.outputs.server }} + scheduler-server: ${{ steps.filter.outputs.scheduler-server }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - uses: dorny/paths-filter@v2 + id: filter + with: + base: ${{ github.ref }} + ref: ${{ github.head_ref }} + filters: | + client: + - 'client/**' + server: + - 'server/**' + scheduler-server: + - 'scheduler-server/**' + + client: + needs: path-check + if: ${{ needs.path-check.outputs.client == 'true' }} + uses: ./.github/workflows/reusable-deployment-client.yml + with: + bucket-name: weview-dev + secrets: + VITE_SERVER_URL: ${{ secrets.DEV_VITE_SERVER_URL }} + VITE_LOCAL_URL: ${{ secrets.DEV_VITE_LOCAL_URL }} + VITE_GITHUB_AUTH_SERVER_URL: ${{ secrets.DEV_VITE_GITHUB_AUTH_SERVER_URL }} + VITE_API_MODE: ${{ secrets.DEV_VITE_API_MODE }} + NCLOUD_BUCKET_ACCESS_KEY: ${{ secrets.DEV_NCLOUD_BUCKET_ACCESS_KEY }} + NCLOUD_BUCKET_SECRET_KEY: ${{ secrets.DEV_NCLOUD_BUCKET_SECRET_KEY }} + NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }} + NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }} + NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }} + NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }} + + server: + needs: path-check + if: ${{ needs.path-check.outputs.server == 'true' }} + uses: ./.github/workflows/reusable-deployment-server.yml + with: + docker-context: server + docker-image-name: weview-dev + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }} + NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }} + NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }} + NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }} + + scheduler-server: + needs: path-check + if: ${{ needs.path-check.outputs.scheduler-server == 'true' }} + uses: ./.github/workflows/reusable-deployment-server.yml + with: + docker-context: scheduler-server + docker-image-name: weview-scheduler-dev + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }} + NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }} + NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }} + NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-main-api-server.yml b/.github/workflows/deployment-main-api-server.yml deleted file mode 100644 index 54baf7d7..00000000 --- a/.github/workflows/deployment-main-api-server.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: "[CD] MAIN API 서버" -on: - push: - tags: - - "v*" - paths: - - "server/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-deployment-server.yml - with: - docker-context: server - docker-image-name: weview-main - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} - NCLOUD_HOST: ${{ secrets.MAIN_NCLOUD_HOST }} - NCLOUD_USERNAME: ${{ secrets.MAIN_NCLOUD_USERNAME }} - NCLOUD_PASSWORD: ${{ secrets.MAIN_NCLOUD_PASSWORD }} - NCLOUD_PORT: ${{ secrets.MAIN_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-main-client.yml b/.github/workflows/deployment-main-client.yml deleted file mode 100644 index 02432e91..00000000 --- a/.github/workflows/deployment-main-client.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: "[CD] MAIN 클라이언트" -on: - push: - tags: - - "v*" - paths: - - "client/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-deployment-client.yml - with: - bucket-name: weview - secrets: - VITE_SERVER_URL: ${{ secrets.MAIN_VITE_SERVER_URL }} - VITE_LOCAL_URL: ${{ secrets.MAIN_VITE_LOCAL_URL }} - VITE_GITHUB_AUTH_SERVER_URL: ${{ secrets.MAIN_VITE_GITHUB_AUTH_SERVER_URL }} - VITE_API_MODE: ${{ secrets.MAIN_VITE_API_MODE }} - NCLOUD_BUCKET_ACCESS_KEY: ${{ secrets.MAIN_NCLOUD_BUCKET_ACCESS_KEY }} - NCLOUD_BUCKET_SECRET_KEY: ${{ secrets.MAIN_NCLOUD_BUCKET_SECRET_KEY }} - NCLOUD_HOST: ${{ secrets.MAIN_NCLOUD_HOST }} - NCLOUD_USERNAME: ${{ secrets.MAIN_NCLOUD_USERNAME }} - NCLOUD_PASSWORD: ${{ secrets.MAIN_NCLOUD_PASSWORD }} - NCLOUD_PORT: ${{ secrets.MAIN_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-main-scheduler-server.yml b/.github/workflows/deployment-main-scheduler-server.yml deleted file mode 100644 index 2196ca37..00000000 --- a/.github/workflows/deployment-main-scheduler-server.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: "[CD] MAIN 스케쥴러 서버" -on: - push: - tags: - - "v*" - paths: - - "scheduler-server/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-deployment-server.yml - with: - docker-context: scheduler-server - docker-image-name: weview-scheduler-main - secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} - NCLOUD_HOST: ${{ secrets.MAIN_NCLOUD_HOST }} - NCLOUD_USERNAME: ${{ secrets.MAIN_NCLOUD_USERNAME }} - NCLOUD_PASSWORD: ${{ secrets.MAIN_NCLOUD_PASSWORD }} - NCLOUD_PORT: ${{ secrets.MAIN_NCLOUD_PORT }} diff --git a/.github/workflows/deployment-main.yml b/.github/workflows/deployment-main.yml new file mode 100644 index 00000000..f7c0beb6 --- /dev/null +++ b/.github/workflows/deployment-main.yml @@ -0,0 +1,78 @@ +name: "[CD] MAIN" +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + path-check: + runs-on: ubuntu-20.04 + outputs: + client: ${{ steps.filter.outputs.client }} + server: ${{ steps.filter.outputs.server }} + scheduler-server: ${{ steps.filter.outputs.scheduler-server }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - uses: dorny/paths-filter@v2 + id: filter + with: + base: ${{ github.ref }} + ref: ${{ github.head_ref }} + filters: | + client: + - 'client/**' + server: + - 'server/**' + scheduler-server: + - 'scheduler-server/**' + + client: + needs: path-check + if: ${{ needs.path-check.outputs.client == 'true' }} + uses: ./.github/workflows/reusable-deployment-client.yml + with: + bucket-name: weview + secrets: + VITE_SERVER_URL: ${{ secrets.MAIN_VITE_SERVER_URL }} + VITE_LOCAL_URL: ${{ secrets.MAIN_VITE_LOCAL_URL }} + VITE_GITHUB_AUTH_SERVER_URL: ${{ secrets.MAIN_VITE_GITHUB_AUTH_SERVER_URL }} + VITE_API_MODE: ${{ secrets.MAIN_VITE_API_MODE }} + NCLOUD_BUCKET_ACCESS_KEY: ${{ secrets.MAIN_NCLOUD_BUCKET_ACCESS_KEY }} + NCLOUD_BUCKET_SECRET_KEY: ${{ secrets.MAIN_NCLOUD_BUCKET_SECRET_KEY }} + NCLOUD_HOST: ${{ secrets.MAIN_NCLOUD_HOST }} + NCLOUD_USERNAME: ${{ secrets.MAIN_NCLOUD_USERNAME }} + NCLOUD_PASSWORD: ${{ secrets.MAIN_NCLOUD_PASSWORD }} + NCLOUD_PORT: ${{ secrets.MAIN_NCLOUD_PORT }} + + server: + needs: path-check + if: ${{ needs.path-check.outputs.server == 'true' }} + uses: ./.github/workflows/reusable-deployment-server.yml + with: + docker-context: server + docker-image-name: weview-main + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + NCLOUD_HOST: ${{ secrets.MAIN_NCLOUD_HOST }} + NCLOUD_USERNAME: ${{ secrets.MAIN_NCLOUD_USERNAME }} + NCLOUD_PASSWORD: ${{ secrets.MAIN_NCLOUD_PASSWORD }} + NCLOUD_PORT: ${{ secrets.MAIN_NCLOUD_PORT }} + + scheduler-server: + needs: path-check + if: ${{ needs.path-check.outputs.scheduler-server == 'true' }} + uses: ./.github/workflows/reusable-deployment-server.yml + with: + docker-context: scheduler-server + docker-image-name: weview-scheduler-main + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + NCLOUD_HOST: ${{ secrets.MAIN_NCLOUD_HOST }} + NCLOUD_USERNAME: ${{ secrets.MAIN_NCLOUD_USERNAME }} + NCLOUD_PASSWORD: ${{ secrets.MAIN_NCLOUD_PASSWORD }} + NCLOUD_PORT: ${{ secrets.MAIN_NCLOUD_PORT }} diff --git a/.github/workflows/integration-api-server.yml b/.github/workflows/integration-api-server.yml deleted file mode 100644 index f7c9b65d..00000000 --- a/.github/workflows/integration-api-server.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: "[CI] API 서버(Pull Request)" -on: - pull_request: - branches: ["main", "dev"] - paths: - - "server/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-integration.yml - with: - working-directory: server diff --git a/.github/workflows/integration-client.yml b/.github/workflows/integration-client.yml deleted file mode 100644 index 5d97654b..00000000 --- a/.github/workflows/integration-client.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: "[CI] 클라이언트(Pull Request)" -on: - pull_request: - branches: ["main", "dev"] - paths: - - "client/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-integration.yml - with: - working-directory: client diff --git a/.github/workflows/integration-scheduler-server.yml b/.github/workflows/integration-scheduler-server.yml deleted file mode 100644 index ee251fbd..00000000 --- a/.github/workflows/integration-scheduler-server.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: "[CI] 스케쥴러 서버(Pull Request)" -on: - pull_request: - branches: ["main", "dev"] - paths: - - "scheduler-server/**" - workflow_dispatch: - -jobs: - reusable: - uses: ./.github/workflows/reusable-integration.yml - with: - working-directory: scheduler-server diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 00000000..40ad8158 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + branches: ["main", "dev"] + workflow_dispatch: + +jobs: + path-check: + runs-on: ubuntu-20.04 + outputs: + client: ${{ steps.filter.outputs.client }} + server: ${{ steps.filter.outputs.server }} + scheduler-server: ${{ steps.filter.outputs.scheduler-server }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - uses: dorny/paths-filter@v2 + id: filter + with: + base: ${{ github.ref }} + ref: ${{ github.head_ref }} + filters: | + client: + - 'client/**' + server: + - 'server/**' + scheduler-server: + - 'scheduler-server/**' + + client: + needs: path-check + if: ${{ needs.path-check.outputs.client == 'true' }} + uses: ./.github/workflows/reusable-integration.yml + with: + working-directory: client + + server: + needs: path-check + if: ${{ needs.path-check.outputs.server == 'true' }} + uses: ./.github/workflows/reusable-integration.yml + with: + working-directory: server + + scheduler-server: + needs: path-check + if: ${{ needs.path-check.outputs.scheduler-server == 'true' }} + uses: ./.github/workflows/reusable-integration.yml + with: + working-directory: scheduler-server diff --git a/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.scss b/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.scss index 82ea6e17..abc01f80 100644 --- a/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.scss +++ b/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.scss @@ -1,14 +1,70 @@ +@import "@/styles/theme"; + .post__image-slider { + display: flex; flex-shrink: 0; - width: 100%; - height: 51rem; - overflow: hidden; - background-color: black; + height: 100%; + + &:hover .post__image-slider__dots { + opacity: 1; + } + + &--relative { + position: relative; + } + + &__wrapper { + width: 51rem; + min-width: 51rem; + height: 51rem; + overflow: hidden; + background-color: black; + + &:hover .post__image-slider__dots { + opacity: 1; + transition: all 0.2s ease-in-out; + } + } &--image { - width: inherit; - height: inherit; + width: auto; + min-width: 51rem; + height: auto; cursor: pointer; object-fit: cover; // 가로-세로 비율 맞춰서 꽉 차게 } + + &--button { + position: absolute; + top: 50%; + width: 100%; + } + + &__dots { + position: absolute; + bottom: 0; + display: flex; + justify-content: center; + width: 100%; + pointer-events: none; + opacity: 0.4; + + &--other, + &--now { + width: 0.8rem; + height: 0.8rem; + margin: 0.8rem 0.2rem; + border-radius: $radius-circle; + transition: all 0.2s ease-in-out; + } + + &--other { + background-color: $weview-off-white; + opacity: 0.5; + } + + &--now { + background-color: $weview-off-white; + } + } } diff --git a/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.tsx b/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.tsx index 8ea621de..090a3708 100644 --- a/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.tsx +++ b/client/src/components/main/PostScroll/Post/PostImageSlider/PostImageSlider.tsx @@ -1,32 +1,114 @@ -import React, { useContext } from "react"; +import React, { TouchEvent, useContext, useRef, useState } from "react"; import { PostContext } from "@/components/main/PostScroll/Post/Post"; import ProgressiveImage from "@/components/commons/ProgressiveImage/ProgressiveImage"; import codePlaceholder from "@/assets/progressive-image.jpg"; import useCommonModalStore from "@/store/useCommonModalStore"; import ReviewModal from "@/components/main/Modal/ReviewModal/ReviewModal"; +import SlideButton from "@/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton"; +import { get3dImageTransformStyle, getNextImageStyle } from "@/utils/style"; import "./PostImageSlider.scss"; const PostImageSlider = (): JSX.Element => { const { images, id: postId, code, language } = useContext(PostContext); const [openModal] = useCommonModalStore((state) => [state.openModal]); + const [currentImgIndex, setCurrentImgIndex] = useState(0); + const imageRef = useRef(null); + const [style, setStyle] = useState(getNextImageStyle(currentImgIndex)); + const [touchedX, setTouchedX] = useState({ start: 0, end: 0 }); + + const handlePrevImage = (): void => { + if (currentImgIndex === 0) { + setStyle(getNextImageStyle(currentImgIndex)); + return; + } + setCurrentImgIndex(currentImgIndex - 1); + setStyle(getNextImageStyle(currentImgIndex - 1)); + }; + + const handleNextImage = (): void => { + if (currentImgIndex === images.length - 1) { + setStyle(getNextImageStyle(currentImgIndex)); + return; + } + setCurrentImgIndex(currentImgIndex + 1); + setStyle(getNextImageStyle(currentImgIndex + 1)); + }; const handleClickImage = (): void => { openModal(); }; + const handleTouchStartWrapper = (e: TouchEvent): void => { + setTouchedX({ + ...touchedX, + start: e.touches[0].pageX, + }); + }; + + const handleMoveWrapper = (e: TouchEvent): void => { + if (imageRef?.current != null) { + const current = imageRef.current.clientWidth * currentImgIndex; + // e.targetTouches[0] 은 처음 터치한 표면 정보 즉 마우스 이동한만큼 이미지 이동 + const result = -current + (e.targetTouches[0].pageX - touchedX.start); + setStyle(get3dImageTransformStyle(result)); + } + }; + + const handleTouchEndWrapper = (e: TouchEvent): void => { + const end = e.changedTouches[0].pageX; + const isMoveRight = touchedX.start > end; + isMoveRight ? handleNextImage() : handlePrevImage(); + setTouchedX({ + ...touchedX, + end, + }); + }; + return ( -
- +
+
+
+ {images.map((image) => ( + + ))} +
+ + {images.length > 1 && ( +
+ {images.map((image, idx) => { + return ( +
+ ); + })} +
+ )} +
); }; diff --git a/client/src/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton.scss b/client/src/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton.scss new file mode 100644 index 00000000..5d304825 --- /dev/null +++ b/client/src/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton.scss @@ -0,0 +1,40 @@ +@import "@/styles/theme"; +@import "@/styles/mixin"; + +.prev-button, +.next-button { + @include flex-center; + position: absolute; + top: 50%; + width: 2rem; + height: 2rem; + padding: 0; + text-align: center; + cursor: pointer; + background-color: $code-color; + border: none; + border-radius: $radius-circle; + + & > svg { + width: 1.2rem; + height: 1.2rem; + margin-bottom: 0.5px; + color: $codeblock-color; + } +} + +.prev-button { + left: 1%; + + & > svg { + margin-right: 1.2px; + } +} + +.next-button { + right: 1%; + + & > svg { + margin-left: 2px; + } +} diff --git a/client/src/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton.tsx b/client/src/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton.tsx new file mode 100644 index 00000000..41762a8c --- /dev/null +++ b/client/src/components/main/PostScroll/Post/PostImageSlider/SlideButton/SlideButton.tsx @@ -0,0 +1,38 @@ +import React, { FC } from "react"; +import ArrowBackIosOutlinedIcon from "@mui/icons-material/ArrowBackIosNewOutlined"; +import ArrowForwardIosOutlinedIcon from "@mui/icons-material/ArrowForwardIosOutlined"; + +import { ARROW_ICON_STYLE } from "@/constants/style"; + +import "./SlideButton.scss"; + +interface SlideButtonProps { + isFirst: boolean; + isLast: boolean; + handlePrevImage: () => void; + handleNextImage: () => void; +} + +const SlideButton: FC = ({ + isFirst, + isLast, + handlePrevImage, + handleNextImage, +}) => { + return ( + <> + {!isFirst && ( + + )} + {!isLast && ( + + )} + + ); +}; + +export default SlideButton; diff --git a/client/src/constants/style.ts b/client/src/constants/style.ts new file mode 100644 index 00000000..a27e3fbc --- /dev/null +++ b/client/src/constants/style.ts @@ -0,0 +1 @@ +export const ARROW_ICON_STYLE = { stroke: "#292c33", strokeWidth: 0.5 }; diff --git a/client/src/mocks/datasource/mockDataSource.ts b/client/src/mocks/datasource/mockDataSource.ts index dc729e5e..52794fe4 100644 --- a/client/src/mocks/datasource/mockDataSource.ts +++ b/client/src/mocks/datasource/mockDataSource.ts @@ -24,7 +24,7 @@ posts = Array.from(Array(1024).keys()).map((id) => ({ name: "image1", }, { - src: "http://placeimg.com/640/640/animals", + src: "http://placeimg.com/640/640/people", name: "image1", }, ], diff --git a/client/src/utils/style.ts b/client/src/utils/style.ts new file mode 100644 index 00000000..51eb3be2 --- /dev/null +++ b/client/src/utils/style.ts @@ -0,0 +1,16 @@ +interface ImageSlidingStyle { + transform: string; + transition: string; +} + +export const getNextImageStyle = (index: number): ImageSlidingStyle => ({ + transform: `translateX(-${index}00%)`, + transition: `all 0.4s ease-in-out`, +}); + +export const get3dImageTransformStyle = ( + result: number +): ImageSlidingStyle => ({ + transform: `translate3d(${result}px, 0px, 0px)`, + transition: "0ms", +}); diff --git a/scheduler-server/src/ranking/ranking.controller.ts b/scheduler-server/src/ranking/ranking.controller.ts index 6d0db36d..0eddd94a 100644 --- a/scheduler-server/src/ranking/ranking.controller.ts +++ b/scheduler-server/src/ranking/ranking.controller.ts @@ -19,7 +19,7 @@ import { ApiTags, } from '@nestjs/swagger'; -export const MAX_TAG_COUNT = 10; +export const MAX_TAG_COUNT = 5; export const TAG_NAME_REGEX = /^[0-9|a-z|A-Z|ㄱ-ㅎ|가-힣]+$/; @Controller() diff --git a/scheduler-server/src/ranking/ranking.service.ts b/scheduler-server/src/ranking/ranking.service.ts index 7537409d..e3e86a5f 100644 --- a/scheduler-server/src/ranking/ranking.service.ts +++ b/scheduler-server/src/ranking/ranking.service.ts @@ -1,23 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; +import { Interval } from '@nestjs/schedule'; + +export const QUEUE_CYCLE = 15; +export const ROTATION_PER_MINUTE = 60 / QUEUE_CYCLE; // 15초마다 1번 +export const ARRAY_SIZE = ROTATION_PER_MINUTE * 60 * 24; // 1분에 4번 * 60(분) * 24(시간) -export const MINUTES_PER_HOUR = 60; -export const QUEUE_CYCLE_EXPRESSED_SECOND = 15; export const RANKING_COUNT = 10; export const TAG_NAME_INDEX = 0; export const TAG_COUNT_INDEX = 1; @Injectable() export class RankingService { - private readonly tagsCountsCircularQueue: any[]; - private tagCountBuffer; + private readonly tagCountsCircularQueue: any[]; + private index: number; private ranking: any[]; constructor() { - this.tagsCountsCircularQueue = new Array( - (MINUTES_PER_HOUR / QUEUE_CYCLE_EXPRESSED_SECOND) * MINUTES_PER_HOUR * 24, - ); - this.tagCountBuffer = {}; + this.tagCountsCircularQueue = new Array(ARRAY_SIZE); + this.index = 0; this.ranking = []; // TODO DB를 연동해 초기데이터 넣어주기 } @@ -27,54 +27,37 @@ export class RankingService { } async saveSearchedTags(tags: string[]) { + const tagCounts = this.tagCountsCircularQueue[this.index]; + tags.map((tag) => { - if (this.tagCountBuffer[tag]) { - this.tagCountBuffer[tag] += 1; + if (tagCounts[tag]) { + tagCounts[tag] += 1; } else { - this.tagCountBuffer[tag] = 1; + tagCounts[tag] = 1; } }); } - @Cron('0/' + QUEUE_CYCLE_EXPRESSED_SECOND + ' * * * * *') - async updateRanking() { - this.putValueInQueue(this.tagCountBuffer); - this.tagCountBuffer = {}; - const curRanking = this.getTopRankTagNames(); - this.ranking = this.addUpAndDownInfo(curRanking); - } - - /** - * 일정 시간마다 tagCountBuffer를 tagsCountsCircularQueue에 넣는다 - * 이후 tagCountBuffer를 초기화한다 - */ - private putValueInQueue(tagCountBuffer) { - const index = this.makeIndexUsingTimeStamp(new Date()); - this.tagsCountsCircularQueue[index] = Object.assign({}, tagCountBuffer); - } + @Interval(15000) + updateRanking() { + const tagCounts = this.countAllTags(); + const newRanking = this.getTopRankTagNames(tagCounts); + this.ranking = this.addPrevInfo(newRanking); - /** - * 시간을 사용해 Queue의 인덱스를 만든다 - * 일정 시간이 지나면 덮어쓸 큐의 index를 구하기 위해 사용한다 - */ - private makeIndexUsingTimeStamp(date: Date) { - return ( - date.getMinutes() * (MINUTES_PER_HOUR / QUEUE_CYCLE_EXPRESSED_SECOND) + - Math.floor(date.getSeconds() / QUEUE_CYCLE_EXPRESSED_SECOND) - ); + this.index = (this.index + 1) % this.tagCountsCircularQueue.length; + this.tagCountsCircularQueue[this.index] = {}; } /** * 가장 검색이 자주 된 최상위 10개 태그의 이름을 반환한다 * 만약 데이터가 10개가 되지 않으면, 존재하는 만큼만 반환한다 */ - private getTopRankTagNames() { - const tagsCount = this.countForEachTags(); + private getTopRankTagNames(tagCounts) { + const result = Object.keys(tagCounts).reduce((arr, tag) => { + arr.push([tag, tagCounts[tag]]); + return arr; + }, []); - const result = []; - for (const tagName of Object.keys(tagsCount)) { - result.push([tagName, tagsCount[tagName]]); - } result.sort((prev, next) => next[TAG_COUNT_INDEX] - prev[TAG_COUNT_INDEX]); return result @@ -82,35 +65,49 @@ export class RankingService { .map((each) => each[TAG_NAME_INDEX]); } - private countForEachTags() { - const tagsCount = {}; - for (const tagsCountsSearchedAtTime of this.tagsCountsCircularQueue) { - if (!tagsCountsSearchedAtTime) { - continue; + /** + * 시간 가중치를 적용한 태그 개수들을 반환한다 + */ + private countAllTags() { + const totalTagCounts = {}; + this.tagCountsCircularQueue.forEach((tagCounts, idx) => { + if (!tagCounts) { + return; } - for (const tagName of Object.keys(tagsCountsSearchedAtTime)) { - if (tagsCount[tagName]) { - tagsCount[tagName] += tagsCountsSearchedAtTime[tagName]; + + const timeWaste = this.calcTimeWaste(idx); + for (const tag in tagCounts) { + if (totalTagCounts[tag]) { + totalTagCounts[tag] += tagCounts[tag] * timeWaste; continue; } - tagsCount[tagName] = tagsCountsSearchedAtTime[tagName]; + + totalTagCounts[tag] = tagCounts[tag] * timeWaste; } - } - return tagsCount; + }); + + return totalTagCounts; } - private addUpAndDownInfo(curRanking: any[]) { - const rankingInfo = []; - for (let i = 0; i < curRanking.length; i++) { - const tagName = curRanking[i]; - if (!this.ranking) { - rankingInfo.push({ name: tagName, prev: 0 }); - continue; - } + /** + * 시간 가중치는 현재 큐의 인덱스와 배열의 크기에 따라 정해진다 + */ + private calcTimeWaste(idx: number): number { + const offset = idx - this.index + ARRAY_SIZE - 1; + const timeWaste = ((offset % ARRAY_SIZE) + 1) / ARRAY_SIZE; + + return timeWaste; + } + + private addPrevInfo(newRanking: any[]) { + const rankingInfo = newRanking.reduce((arr, tag) => { const prevIndex = - this.ranking.map((obj) => obj.name).indexOf(tagName) + 1; - rankingInfo.push({ name: tagName, prev: prevIndex }); - } + this.ranking.findIndex((tagInfo) => tagInfo.name === tag) + 1; + + arr.push({ name: tag, prev: prevIndex }); + return arr; + }, []); + return rankingInfo; } } diff --git a/server/package-lock.json b/server/package-lock.json index 316b890e..088c101b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@elastic/elasticsearch": "^8.5.0", "@nestjs/axios": "^1.0.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", + "@nestjs/elasticsearch": "^9.0.0", "@nestjs/jwt": "^9.0.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.0.0", @@ -827,6 +829,39 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@elastic/elasticsearch": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-8.5.0.tgz", + "integrity": "sha512-iOgr/3zQi84WmPhAplnK2W13R89VXD2oc6WhlQmH3bARQwmI+De23ZJKBEn7bvuG/AHMAqasPXX7uJIiJa2MqQ==", + "dependencies": { + "@elastic/transport": "^8.2.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@elastic/transport": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-8.2.0.tgz", + "integrity": "sha512-H/HmefMNQfLiBSVTmNExu2lYs5EzwipUnQB53WLr17RCTDaQX0oOLHcWpDsbKQSRhDAMPPzp5YZsZMJxuxPh7A==", + "dependencies": { + "debug": "^4.3.4", + "hpagent": "^1.0.0", + "ms": "^2.1.3", + "secure-json-parse": "^2.4.0", + "tslib": "^2.4.0", + "undici": "^5.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@elastic/transport/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", @@ -1616,6 +1651,16 @@ } } }, + "node_modules/@nestjs/elasticsearch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/elasticsearch/-/elasticsearch-9.0.0.tgz", + "integrity": "sha512-+NhXg0od/luNuObrCoouO8IGJ5d8esd2LbqOyX4wqM3PtzoVgQC5uDoxHu0ZyWkp/Coj7OiT0osYN9rgctEPbg==", + "peerDependencies": { + "@elastic/elasticsearch": "^7.4.0 || ^8.0.0", + "@nestjs/common": "^8.0.0 || ^9.0.0", + "rxjs": "^7.2.0" + } + }, "node_modules/@nestjs/jwt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", @@ -5362,6 +5407,14 @@ "node": "*" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "engines": { + "node": ">=14" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8903,6 +8956,11 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/secure-json-parse": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.6.0.tgz", + "integrity": "sha512-B9osKohb6L+EZ6Kve3wHKfsAClzOC/iISA2vSuCe5Jx5NAKiwitfxx8ZKYapHXr0sYRj7UZInT7pLb3rp2Yx6A==" + }, "node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -10142,6 +10200,17 @@ "node": ">=4.2.0" } }, + "node_modules/undici": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz", + "integrity": "sha512-UDZKtwb2k7KRsK4SdXWG7ErXiL7yTGgLWvk2AXO1JMjgjh404nFo6tWSCM2xMpJwMPx3J8i/vfqEh1zOqvj82Q==", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=12.18" + } + }, "node_modules/unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", @@ -11211,6 +11280,35 @@ } } }, + "@elastic/elasticsearch": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-8.5.0.tgz", + "integrity": "sha512-iOgr/3zQi84WmPhAplnK2W13R89VXD2oc6WhlQmH3bARQwmI+De23ZJKBEn7bvuG/AHMAqasPXX7uJIiJa2MqQ==", + "requires": { + "@elastic/transport": "^8.2.0", + "tslib": "^2.4.0" + } + }, + "@elastic/transport": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-8.2.0.tgz", + "integrity": "sha512-H/HmefMNQfLiBSVTmNExu2lYs5EzwipUnQB53WLr17RCTDaQX0oOLHcWpDsbKQSRhDAMPPzp5YZsZMJxuxPh7A==", + "requires": { + "debug": "^4.3.4", + "hpagent": "^1.0.0", + "ms": "^2.1.3", + "secure-json-parse": "^2.4.0", + "tslib": "^2.4.0", + "undici": "^5.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", @@ -11803,6 +11901,12 @@ "uuid": "9.0.0" } }, + "@nestjs/elasticsearch": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/elasticsearch/-/elasticsearch-9.0.0.tgz", + "integrity": "sha512-+NhXg0od/luNuObrCoouO8IGJ5d8esd2LbqOyX4wqM3PtzoVgQC5uDoxHu0ZyWkp/Coj7OiT0osYN9rgctEPbg==", + "requires": {} + }, "@nestjs/jwt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", @@ -14668,6 +14772,11 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==" }, + "hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==" + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -17290,6 +17399,11 @@ } } }, + "secure-json-parse": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.6.0.tgz", + "integrity": "sha512-B9osKohb6L+EZ6Kve3wHKfsAClzOC/iISA2vSuCe5Jx5NAKiwitfxx8ZKYapHXr0sYRj7UZInT7pLb3rp2Yx6A==" + }, "semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -18119,6 +18233,14 @@ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", "devOptional": true }, + "undici": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz", + "integrity": "sha512-UDZKtwb2k7KRsK4SdXWG7ErXiL7yTGgLWvk2AXO1JMjgjh404nFo6tWSCM2xMpJwMPx3J8i/vfqEh1zOqvj82Q==", + "requires": { + "busboy": "^1.6.0" + } + }, "unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", diff --git a/server/package.json b/server/package.json index 270ff1b6..ba63f3b1 100644 --- a/server/package.json +++ b/server/package.json @@ -22,10 +22,12 @@ "prepare": "chmod ug+x .husky/* && cd .. && husky install server/.husky" }, "dependencies": { + "@elastic/elasticsearch": "^8.5.0", "@nestjs/axios": "^1.0.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", + "@nestjs/elasticsearch": "^9.0.0", "@nestjs/jwt": "^9.0.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.0.0", diff --git a/server/src/domain/image/image.entity.ts b/server/src/domain/image/image.entity.ts index 6929f20c..d3cbd215 100644 --- a/server/src/domain/image/image.entity.ts +++ b/server/src/domain/image/image.entity.ts @@ -10,6 +10,9 @@ export class Image extends BaseTimeEntity { @ManyToOne(() => Post, (post) => post.images) post: Post; + @Column() + postId: number; + @Column({ length: 2000 }) src!: string; } diff --git a/server/src/domain/image/image.repository.ts b/server/src/domain/image/image.repository.ts new file mode 100644 index 00000000..560d1ead --- /dev/null +++ b/server/src/domain/image/image.repository.ts @@ -0,0 +1,10 @@ +import { DataSource, Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { Image } from './image.entity'; + +@Injectable() +export class ImageRepository extends Repository { + constructor(private dataSource: DataSource) { + super(Image, dataSource.createEntityManager()); + } +} diff --git a/server/src/domain/likes/likes.service.ts b/server/src/domain/likes/likes.service.ts index 4f03af1b..c2458c5f 100644 --- a/server/src/domain/likes/likes.service.ts +++ b/server/src/domain/likes/likes.service.ts @@ -29,13 +29,23 @@ export class LikesService { likes.user = user; likes.post = post; await this.likesRepository.save(likes); + this.postRepository.increaseLikeCount(post); } async cancelLikes(userId: number, postId: number) { + const [user, post] = await Promise.all([ + this.userRepository.findOneBy({ id: userId }), + this.postRepository.findOneBy({ id: postId }), + ]); + if (post === null || user === null) { + return; + } + await this.likesRepository.delete({ userId: userId, postId: postId, }); + this.postRepository.decreaseLikeCount(post); } async findPostIdsByUserId(userId: number) { diff --git a/server/src/domain/post/dto/controller-response.dto.ts b/server/src/domain/post/dto/controller-response.dto.ts index 2f0a6428..c60942cf 100644 --- a/server/src/domain/post/dto/controller-response.dto.ts +++ b/server/src/domain/post/dto/controller-response.dto.ts @@ -1,44 +1,78 @@ -export class InquiryUsingFilterDto { - posts: PostDtoUsingInquiryUsingFilter[]; +import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import { AuthorDto, EachImageResponseDto } from './service-response.dto'; +import { Image } from '../../image/image.entity'; +import { User } from '../../user/user.entity'; + +/** + * 정렬 기준을 한개만 사용하기 때문에(id desc) 0번째 인덱스를 사용하면 정렬된 값의 id를 꺼낼 수 있다 + */ +const SORT_BY_ID = 0; + +export class SearchResponseDto { + posts: EachSearchResponseDto[]; lastId: number; isLast: boolean; + + constructor( + posts: SearchHit[], + authors: User[], + imagesList: Image[][], + isLast: boolean, + ) { + this.posts = []; + for (let i = 0; i < posts.length; i++) { + this.posts.push( + new EachSearchResponseDto(posts[i], authors[i], imagesList[i]), + ); + } + + this.lastId = posts.length == 0 ? -1 : Number(this.getLastId(posts)); + this.isLast = isLast; + } + + private getLastId(posts: SearchHit[]) { + return posts[posts.length - 1].sort[SORT_BY_ID]; + } } -export class PostDtoUsingInquiryUsingFilter { +class EachSearchResponseDto { id: number; title: string; content: string; code: string; language: string; - images: ImageDtoUsingInquiryUsingFilter[]; - updatedAt: string; - author: AuthorDtoUsingInquiryUsingFilter[]; + images: EachImageResponseDto[]; + updatedAt: Date; + author: AuthorDto; tags: string[]; - reviews: ReviewDtoUsingInquiryUsingFilter[]; -} - -export class ImageDtoUsingInquiryUsingFilter { - src: string; - name: string; -} + isLiked: boolean; + isBookmarked: boolean; + likesCount: number; + lineCount: number; -export class AuthorDtoUsingInquiryUsingFilter { - id: number; - nickname: string; - profileUrl: string; - email: string; -} - -export class ReviewDtoUsingInquiryUsingFilter { - id: number; - reviewers: ReviewerDtoUsingInquiryUsingFilter[]; - content: string; - updatedAt: string; -} - -export class ReviewerDtoUsingInquiryUsingFilter { - id: number; - nickname: string; - profileUrl: string; - email: string; + constructor(post: any, author, images) { + let tags; + try { + tags = JSON.parse(post._source.tags); + } catch (e) { + if (post._source.tags.length > 0) { + tags = [post._source.tags]; + } else { + tags = []; + } + } + this.id = post._source.id; + this.title = post._source.title; + this.content = post._source.content; + this.code = post._source.code; + this.language = post._source.language; + this.updatedAt = post._source.updatedat; + this.tags = tags; + this.isLiked = false; + this.isBookmarked = false; + this.likesCount = post._source.likecount; + this.lineCount = post._source.linecount; + this.author = new AuthorDto(author); + this.images = images.map((image) => new EachImageResponseDto(image)); + } } diff --git a/server/src/domain/post/dto/service-request.dto.ts b/server/src/domain/post/dto/service-request.dto.ts index 85f0ed59..e9cfb953 100644 --- a/server/src/domain/post/dto/service-request.dto.ts +++ b/server/src/domain/post/dto/service-request.dto.ts @@ -1,6 +1,7 @@ import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; export class LoadPostListRequestDto { + //TODO 이름 변경. SearchCondition.. constructor( lastId: number, tags: string[], diff --git a/server/src/domain/post/post-search.service.spec.ts b/server/src/domain/post/post-search.service.spec.ts new file mode 100644 index 00000000..f8ca2323 --- /dev/null +++ b/server/src/domain/post/post-search.service.spec.ts @@ -0,0 +1,526 @@ +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PostSearchService } from './post-search.service'; +import { LoadPostListRequestDto } from './dto/service-request.dto'; +import { Post } from './post.entity'; +import { User } from '../user/user.entity'; +import { PostNotFoundException } from '../../exception/post-not-found.exception'; +import { PostInputInvalidException } from '../../exception/post-input-invalid.exception'; +import { SEND_POST_CNT } from './post.controller'; +import { SearchParamInvalidException } from '../../exception/search-param-invalid.exception'; +import { SearchResponseInvalidException } from '../../exception/search-response-invalid.exception'; +import { TagInvalidException } from '../../exception/tag-invalid.exception'; + +const mockElasticSearchService = { + index: jest.fn(), + search: jest.fn().mockResolvedValue({ + hits: { + hits: [], + }, + }), +}; +const mockConfigService = { + get: jest.fn(() => '엘라스틱_인덱스명'), +}; + +describe('PostSearchService', () => { + let service: PostSearchService; + let esService: ElasticsearchService; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PostSearchService, + ElasticsearchService, + ConfigService, + { + provide: ElasticsearchService, + useValue: mockElasticSearchService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + service = module.get(PostSearchService); + esService = module.get(ElasticsearchService); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(esService).toBeDefined(); + }); + + describe('저장', () => { + let post: Post; + let indexParameter; + + beforeEach(async () => { + const user = new User(); + user.id = 10; + user.nickname = '사용자이름'; + + post = new Post(); + post.id = 1; + post.title = '제목'; + post.content = 'content'; + post.code = 'System.out.println("zz")'; + post.language = 'java'; + post.createdAt = new Date(); + post.updatedAt = new Date(); + post.user = user; + post.lineCount = 1; + post.reviewCount = 0; + post.likeCount = 0; + + indexParameter = { + index: configService.get(''), + id: String(post.id), + body: { + id: post.id, + title: post.title, + content: post.content, + code: post.code, + language: post.language, + createdat: post.createdAt, + updatedat: post.updatedAt, + authorid: post.user.id, + authornickname: post.user.nickname, + linecount: post.lineCount, + reviewcount: post.reviewCount, + likecount: post.likeCount, + }, + }; + }); + it('게시물이 들어오지 않으면 예외를 반환한다', () => { + expect(() => { + service.indexPost(null, ['greedy', 'sort']); + }).toThrow(PostNotFoundException); + }); + + it('게시물이 1개 들어오면 정상적으로 동작한다', () => { + //given + const tags = ['greedy', 'sort']; + + //when + service.indexPost(post, tags); + indexParameter.body.tags = tags.join(' '); + + expect(esService.index).toBeCalled(); + expect(esService.index).toBeCalledWith(indexParameter); + }); + + it('태그가 0개 들어오면 정상 동작한다', () => { + service.indexPost(post, []); + indexParameter.body.tags = ''; // 비어있는 값일 때 ''가 들어간다 + + expect(esService.index).toBeCalled(); + expect(esService.index).toBeCalledWith(indexParameter); + }); + + it('태그가 1개 들어오면 정상 동작한다', () => { + //given + const tags = ['one']; + indexParameter.body.tags = tags[0]; + //when + service.indexPost(post, tags); + + expect(esService.index).toBeCalled(); + expect(esService.index).toBeCalledWith(indexParameter); + }); + + it('태그가 5개 들어오면 정상 동작한다', () => { + const tags = ['one', 'two', 'three', 'four', 'five']; + indexParameter.body.tags = tags.join(' '); + + service.indexPost(post, tags); + + expect(esService.index).toBeCalled(); + expect(esService.index).toBeCalledWith(indexParameter); + }); + + it('중복된 태그가 들어오면 중복을 제거하고 저장한다', () => { + //given + const tagsRemovedDuplicate = Array.from( + new Set(['one', 'two', 'three', 'two', 'five']), + ); + indexParameter.body.tags = tagsRemovedDuplicate.join(' '); + + //when + service.indexPost(post, ['one', 'two', 'three', 'two', 'five']); + expect(esService.index).toBeCalledWith(indexParameter); + }); + + it('태그가 6개 들어오면 예외를 반환한다', () => { + expect(() => + service.indexPost(post, ['one', 'two', 'three', 'four', 'five', 'six']), + ).toThrow(PostInputInvalidException); + }); + + it('태그 하나의 글자수가 30개면 정상 동작한다', () => { + service.indexPost(post, [ + '부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프', + ]); + expect(esService.index).toBeCalled(); + }); + + it('태그 하나의 글자수가 31개면 예외를 반환한다', () => { + expect(() => + service.indexPost(post, [ + 'one', + '부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프1', + 'three', + 'four', + 'five', + ]), + ).toThrow(TagInvalidException); + }); + + it('아무 값도 들어있지 않은 태그가 있으면 예외를 반환한다', () => { + expect(() => + service.indexPost(post, ['one', ' ', 'three', 'four', 'five']), + ).toThrow(TagInvalidException); + }); + + it('공백이 포함된 태그가 있으면 예외를 반환한다', () => { + expect(() => + service.indexPost(post, [' one', 'two', 'thre e', 'four']), + ).toThrow(TagInvalidException); + }); + }); + + describe('검색', () => { + let searchCondition: LoadPostListRequestDto; + let searchConditionUsingES; + + beforeEach(() => { + searchConditionUsingES = { + sort: [ + { + id: { + order: 'DESC', + }, + }, + ], + size: SEND_POST_CNT + 1, + index: configService.get(''), + body: { + query: { + bool: { + filter: { + bool: { + must: [], + }, + }, + }, + }, + }, + }; + + searchCondition = { + lastId: -1, + tags: [], + reviewCount: 0, + likeCount: 0, + details: [], + }; + }); + + describe('입력값 검증', () => { + it('tags가 한 개 들어왔을 때, 정상 출력한다', async () => { + //given + searchCondition.tags = ['one']; + searchConditionUsingES.body.query.bool.filter.bool.must.push({ + match: { + tags: 'one', + }, + }); + + //when + await service.search(searchCondition); + + expect(esService.search).toBeCalled(); + expect(esService.search).toBeCalledWith(searchConditionUsingES); + }); + + it('tags가 다섯 개 들어왔을 때, 정상 출력한다', async () => { + //given + searchCondition.tags = ['one', 'two', 'three', 'four', 'five']; + for (const tag of searchCondition.tags) { + searchConditionUsingES.body.query.bool.filter.bool.must.push({ + match: { + tags: tag, + }, + }); + } + + //when + await service.search(searchCondition); + + expect(esService.search).toBeCalled(); + expect(esService.search).toBeCalledWith(searchConditionUsingES); + }); + + it('tags가 여섯 개 들어왔을 때, 예외를 반환한다', async () => { + try { + //given + searchCondition.tags = ['one', 'two', 'three', 'four', 'five', 'six']; + + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + //then + expect(err).toBeInstanceOf(SearchParamInvalidException); + } + }); + + it('태그 하나의 글자수가 30개면 정상 동작한다', async () => { + //given + searchCondition.tags = [ + '부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프', + ]; + + //when + await service.search(searchCondition); + + expect(esService.search).toBeCalled(); + }); + + it('태그 하나의 글자수가 31개면 예외를 반환한다', async () => { + try { + //given + searchCondition.tags = [ + '부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프부스트캠프1', + ]; + + //when + await service.search(searchCondition); + console.log('rerererer', searchCondition); + throw new Error(); + } catch (err) { + //then + expect(err).toBeInstanceOf(TagInvalidException); + } + }); + + it('아무 값도 들어있지 않은 태그가 있으면 예외를 반환한다', async () => { + try { + //given + searchCondition.tags = ['one', ' ', 'three', 'four', 'five']; + + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + //then + expect(err).toBeInstanceOf(TagInvalidException); + } + }); + + it('공백이 포함된 태그가 있으면 예외를 반환한다', async () => { + try { + //given + searchCondition.tags = [' one', 'two', 'thre e', 'four']; + + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + //then + expect(err).toBeInstanceOf(TagInvalidException); + } + }); + + it('details에 공백이 넘어올 때, 정상 출력한다', async () => { + //TODO 놓친 케이스. 프론트에서 막아줄건지, 백엔드에서 무시할건지 이야기 + }); + + it('details가 1개일 때, 정상 출력한다', async () => { + //given + searchCondition.details = ['hello my name is taehoon kim']; + searchConditionUsingES.body.query.bool.filter.bool.must.push({ + multi_match: { + query: searchCondition.details[0], + fields: ['title', 'content', 'code', 'language', 'authorNickname'], + }, + }); + + //when + await service.search(searchCondition); + + expect(esService.search).toBeCalled(); + expect(esService.search).toBeCalledWith(searchConditionUsingES); + }); + + // TODO 검색어 길이 제한 + + it('details가 1개를 넘어가면, 예외를 반환한다', async () => { + try { + //given + searchCondition.details = [ + 'hello my name is taehoon kim', + 'second value', + ]; + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchParamInvalidException); + } + }); + + //ddd + it('reviewCount가 음수면, 예외를 반환한다', async () => { + try { + //given + searchCondition.reviewCount = -1; + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchParamInvalidException); + } + }); + + it('reviewCount가 20보다 크면, 예외를 반환한다', async () => { + try { + //given + searchCondition.reviewCount = 21; + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchParamInvalidException); + } + }); + + it('reviewCount가 1개일 때 정상 출력한다', async () => { + //given + searchCondition.reviewCount = 1; + searchConditionUsingES.body.query.bool.filter.bool.must.push({ + range: { + reviewCount: { + gte: searchCondition.reviewCount, + }, + }, + }); + + //when + await service.search(searchCondition); + + expect(esService.search).toBeCalled(); + expect(esService.search).toBeCalledWith(searchConditionUsingES); + }); + + it('likeCount 1개일 때 정상 출력한다', async () => { + //given + searchCondition.likeCount = 1; + searchConditionUsingES.body.query.bool.filter.bool.must.push({ + range: { + likeCount: { + gte: searchCondition.likeCount, + }, + }, + }); + + //when + await service.search(searchCondition); + + expect(esService.search).toBeCalled(); + expect(esService.search).toBeCalledWith(searchConditionUsingES); + }); + + it('likeCount가 음수면, 예외를 반환한다', async () => { + try { + //given + searchCondition.likeCount = -1; + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchParamInvalidException); + } + }); + + it('likeCount가 20보다 크면, 예외를 반환한다', async () => { + try { + //given + searchCondition.likeCount = 21; + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchParamInvalidException); + } + }); + }); + + describe('결과값 검증', () => { + it('결과값은 내림차순으로 정렬되어진다', async () => { + //given + mockElasticSearchService.search = jest.fn().mockResolvedValue({ + hits: { + hits: [ + { _source: { id: 3 } }, + { _source: { id: 2 } }, + { _source: { id: 10 } }, + ], + }, + }); + //when + const result = await service.search(searchCondition); + + //then + expect(result).toStrictEqual([ + { _source: { id: 10 } }, + { _source: { id: 3 } }, + { _source: { id: 2 } }, + ]); + }); + + it('N+1개 보다 많은 결과가 오면, 예외를 반환한다', async () => { + try { + mockElasticSearchService.search = jest.fn().mockResolvedValue({ + hits: { + hits: [ + { _source: { id: 5 } }, + { _source: { id: 4 } }, + { _source: { id: 3 } }, + { _source: { id: 2 } }, + { _source: { id: 1 } }, + ], + }, + }); + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchResponseInvalidException); + } + }); + + it('결과값에 중복되는 아이디가 있으면 예외를 반환한다', async () => { + try { + mockElasticSearchService.search = jest.fn().mockResolvedValue({ + hits: { + hits: [ + { _source: { id: 3 } }, + { _source: { id: 1 } }, + { _source: { id: 1 } }, + ], + }, + }); + //when + await service.search(searchCondition); + throw new Error(); + } catch (err) { + expect(err).toBeInstanceOf(SearchResponseInvalidException); + } + }); + }); + }); +}); diff --git a/server/src/domain/post/post-search.service.ts b/server/src/domain/post/post-search.service.ts new file mode 100644 index 00000000..f852f661 --- /dev/null +++ b/server/src/domain/post/post-search.service.ts @@ -0,0 +1,205 @@ +import { Injectable } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { Post } from './post.entity'; +import { LoadPostListRequestDto } from './dto/service-request.dto'; +import { SEND_POST_CNT } from './post.controller'; +import { ConfigService } from '@nestjs/config'; +import { PostNotFoundException } from '../../exception/post-not-found.exception'; +import { PostInputInvalidException } from '../../exception/post-input-invalid.exception'; +import { SearchParamInvalidException } from '../../exception/search-param-invalid.exception'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import { SearchResponseInvalidException } from '../../exception/search-response-invalid.exception'; +import { TagInvalidException } from '../../exception/tag-invalid.exception'; + +const MAX_TAG_COUNT = 5; // TODO 상수 클래스 지정 + +const MIN_LIKES_FILTERING_COUNT = 0; +const MIN_REVIEW_FILTERING_COUNT = 0; +const MAX_LIKES_FILTERING_COUNT = 20; +const MAX_REVIEW_FILTERING_COUNT = 20; + +const MAX_TAG_LENGTH = 30; + +@Injectable() +export class PostSearchService { + constructor( + private readonly esService: ElasticsearchService, + private readonly configService: ConfigService, + ) {} + + indexPost(post: Post, tags: string[]) { + if (!post) { + throw new PostNotFoundException(); + } + + if (tags.length > MAX_TAG_COUNT) { + throw new PostInputInvalidException('태그의 개수가 적절하지 않습니다'); + } + tags.map((tag) => tag.trim()).forEach((tag) => this.validateTag(tag)); + + let tagValue = ''; + if (tags) { + tagValue = Array.from(new Set(tags)).join(' '); + } + this.esService.index({ + index: this.configService.get('ELASTICSEARCH_INDEX'), + id: String(post.id), + body: { + id: post.id, + title: post.title, + content: post.content, + code: post.code, + language: post.language, + createdat: post.createdAt, + updatedat: post.updatedAt, + authorid: post.user.id, + authornickname: post.user.nickname, + tags: tagValue, + linecount: post.lineCount, + reviewcount: post.reviewCount, + likecount: post.likeCount, + }, + }); + } + + async search(loadPostListRequestDto: LoadPostListRequestDto) { + const { lastId, reviewCount, likeCount } = loadPostListRequestDto; + let { tags, details } = loadPostListRequestDto; + this.validateParam(loadPostListRequestDto); + if (typeof details === 'string') { + details = [details]; + } + if (typeof tags === 'string') { + tags = [tags]; + } + + const searchFilter: any = { + sort: [ + { + id: { + order: 'DESC', + }, + }, + ], + size: SEND_POST_CNT + 1, + index: this.configService.get('ELASTICSEARCH_INDEX'), + body: { + query: { + bool: { + filter: { + bool: { + must: [], + }, + }, + }, + }, + }, + }; + if (!(lastId === null || lastId === undefined || lastId === -1)) { + searchFilter.search_after = [lastId]; + } + + if (details.length === 1) { + searchFilter.body.query.bool.filter.bool.must.push({ + multi_match: { + query: details[0], + fields: ['title', 'content', 'code', 'language', 'authorNickname'], + }, + }); + } + + if (tags && tags.length > 0) { + for (const tag of tags) { + searchFilter.body.query.bool.filter.bool.must.push({ + match: { + tags: tag, + }, + }); + } + } + if (reviewCount && reviewCount >= 1) { + searchFilter.body.query.bool.filter.bool.must.push({ + range: { + reviewCount: { + gte: reviewCount, + }, + }, + }); + } + if (likeCount && likeCount >= 1) { + searchFilter.body.query.bool.filter.bool.must.push({ + range: { + likeCount: { + gte: likeCount, + }, + }, + }); + } + + const body = await this.esService.search(searchFilter); + this.validateReturnValue(body.hits.hits); + + body.hits.hits.sort( + (prev, next) => Number(next._source['id']) - Number(prev._source['id']), + ); + return body.hits.hits; + } + + private validateReturnValue(hits: SearchHit[]) { + if (hits.length > SEND_POST_CNT + 1) { + throw new SearchResponseInvalidException( + '너무 많은 검색 결과가 반환되었습니다', + ); + } + const ids = hits.map((each) => each._source['id']); + if (new Set(ids).size !== ids.length) { + throw new SearchResponseInvalidException( + '중복되는 결과가 반환되었습니다', + ); + } + } + + private validateParam(loadPostListRequestDto: LoadPostListRequestDto) { + const { tags, reviewCount, likeCount, details } = loadPostListRequestDto; + + if (tags.length > MAX_TAG_COUNT) { + throw new SearchParamInvalidException('태그의 개수가 적절하지 않습니다'); + } + + tags.forEach((tag) => this.validateTag(tag)); + if ( + reviewCount < MIN_REVIEW_FILTERING_COUNT || + MAX_REVIEW_FILTERING_COUNT < reviewCount + ) { + throw new SearchParamInvalidException( + '선택한 리뷰 개수는 적절하지 않습니다', + ); + } + if ( + likeCount < MIN_LIKES_FILTERING_COUNT || + MAX_LIKES_FILTERING_COUNT < likeCount + ) { + throw new SearchParamInvalidException( + '선택한 리뷰 개수는 적절하지 않습니다', + ); + } + if (Array.isArray(details) && details.length > 1) { + throw new SearchParamInvalidException( + '검색어의 개수가 적절하지 않습니다', + ); + } + } + + private validateTag(tag: string) { + if (tag.includes(' ')) { + throw new TagInvalidException('태그 이름은 공백이 올 수 없습니다'); + } + + if (tag.length === 0) { + throw new TagInvalidException('태그는 공백이 올 수 없습니다'); + } + if (tag.length > MAX_TAG_LENGTH) { + throw new TagInvalidException('태그의 길이가 너무 깁니다'); + } + } +} diff --git a/server/src/domain/post/post.controller.ts b/server/src/domain/post/post.controller.ts index 0362b5a8..0404c6c0 100644 --- a/server/src/domain/post/post.controller.ts +++ b/server/src/domain/post/post.controller.ts @@ -17,10 +17,6 @@ import { } from '@nestjs/common'; import { Request } from 'express'; import { PostService } from './post.service'; -import { - EachPostResponseDto, - LoadPostListResponseDto, -} from './dto/service-response.dto'; import { InquiryDto, InquiryPostDto, @@ -47,6 +43,7 @@ import { UserService } from '../user/user.service'; import { BookmarkService } from '../bookmark/bookmark.service'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; +import { SearchResponseDto } from './dto/controller-response.dto'; import { BookmarkCreateRequestDto } from '../bookmark/dto/controller-request.dto'; export const SEND_POST_CNT = 3; @@ -75,15 +72,20 @@ export class PostController { async search( @Query() inquiryDto: InquiryDto, @Headers() headers, - ): Promise { - const { tags, lastId, reviewCount, likeCount, details } = inquiryDto; + ): Promise { + const { lastId, reviewCount, likeCount, details } = inquiryDto; + let { tags } = inquiryDto; + // TODO @Transform을 쓰는게 맞을지, 이게 맞을지 고민하기 + if (typeof tags === 'string') { + tags = [tags]; + } const returnValue = await this.postService.loadPostList( new LoadPostListRequestDto(lastId, tags, reviewCount, likeCount, details), ); - await this.addLikesCntColumnEveryPosts(returnValue); + + // TODO 로그인 시 해당 작업 잘 동작하나 검사하기 await this.addLikesToPostIfLogin(headers['authorization'], returnValue); await this.addBookmarksToPostIfLogin(headers['authorization'], returnValue); - await this.addSearchHistory(headers['authorization'], inquiryDto); this.applyTags(tags, lastId); @@ -91,9 +93,10 @@ export class PostController { return returnValue; } - private async addLikesToPostIfLogin(token, result: LoadPostListResponseDto) { + private async addLikesToPostIfLogin(token, result: SearchResponseDto) { if (token) { const userId = this.authService.authenticate(token); + if (userId) { const postIdsYouLike = await this.likesService.findPostIdsByUserId( userId, @@ -107,10 +110,7 @@ export class PostController { } } - private async addBookmarksToPostIfLogin( - token, - result: LoadPostListResponseDto, - ) { + private async addBookmarksToPostIfLogin(token, result: SearchResponseDto) { if (token) { const userId = this.authService.authenticate(token); @@ -127,17 +127,6 @@ export class PostController { } } - private async addLikesCntColumnEveryPosts(result: LoadPostListResponseDto) { - const ary = []; - for (const post of result.posts) { - ary.push(this.likesService.countLikesCntByPostId(post.id)); - } - const likesCntStore = await Promise.all(ary); - for (let i = 0; i < result.posts.length; i++) { - result.posts[i].likesCount = likesCntStore[i]; - } - } - private async addSearchHistory(token, inquiryDto: InquiryDto) { if (!token) { return; @@ -225,7 +214,7 @@ export class PostController { async inquiryPost( @Param('postId') postId: number, @Headers() header, - @Query() requestDto: InquiryPostDto, + @Query() requestDto: InquiryPostDto, //TODO 살펴보기 ) { try { const returnValue = await this.postService.inquiryPost(postId); diff --git a/server/src/domain/post/post.entity.ts b/server/src/domain/post/post.entity.ts index f30a6716..17764f44 100644 --- a/server/src/domain/post/post.entity.ts +++ b/server/src/domain/post/post.entity.ts @@ -40,4 +40,19 @@ export class Post extends BaseTimeEntity { cascade: ['insert'], }) postToTags: PostToTag[]; + + @Column({ default: 0 }) + likeCount: number; + + @Column({ default: 0 }) + reviewCount: number; + + @Column({ length: 255, default: '[]' }) + tags: string; + + @Column({ length: 24, default: '' }) + userNickname: string; + + @Column() + userId!: number; } diff --git a/server/src/domain/post/post.module.ts b/server/src/domain/post/post.module.ts index 6d13d401..458e20ca 100644 --- a/server/src/domain/post/post.module.ts +++ b/server/src/domain/post/post.module.ts @@ -15,6 +15,10 @@ import { UserService } from '../user/user.service'; import { SearchHistoryMongoRepository } from '../search/search-history.mongo.repository'; import { BookmarkService } from '../bookmark/bookmark.service'; import { BookmarkRepository } from '../bookmark/bookmark.repository'; +import { ElasticsearchModule } from '@nestjs/elasticsearch'; +import { PostSearchService } from './post-search.service'; +import { ImageRepository } from '../image/image.repository'; +import { ConfigModule, ConfigService } from '@nestjs/config'; @Module({ controllers: [PostController], @@ -24,6 +28,7 @@ import { BookmarkRepository } from '../bookmark/bookmark.repository'; AuthService, UserService, BookmarkService, + PostSearchService, PostRepository, TagRepository, PostToTagRepository, @@ -32,8 +37,29 @@ import { BookmarkRepository } from '../bookmark/bookmark.repository'; LikesRepository, SearchHistoryMongoRepository, PostSubscriber, + ImageRepository, ], - imports: [HttpModule, JwtModule.register({})], // TODO App으로 올릴지 이야기하기 - exports: [PostService, PostRepository], + imports: [ + HttpModule, + JwtModule.register({}), + ConfigModule, + ElasticsearchModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + // TODO env로 이동 + node: configService.get('ELASTICSEARCH_URL'), + auth: { + username: configService.get('ELASTICSEARCH_USERNAME'), //TODO username, password 넣기 + password: configService.get('ELASTICSEARCH_PASSWORD'), + }, + maxRetries: configService.get('ELASTICSEARCH_MAX_RETRIES'), + requestTimeout: configService.get('ELASTICSEARCH_REQUEST_TIMEOUT'), + pingTimeout: configService.get('ELASTICSEARCH_PING_TIMEOUT'), + sniffOnStart: configService.get('ELASTICSEARCH_SNIFF_ON_START'), + }), + inject: [ConfigService], + }), + ], + exports: [PostService, PostRepository, ElasticsearchModule], }) export class PostModule {} diff --git a/server/src/domain/post/post.repository.ts b/server/src/domain/post/post.repository.ts index 720c5049..a9c6b09f 100644 --- a/server/src/domain/post/post.repository.ts +++ b/server/src/domain/post/post.repository.ts @@ -119,4 +119,31 @@ export class PostRepository extends Repository { .where('post.id = :postId', { postId: postId }) .getOne(); } + + increaseLikeCount(post: Post) { + this.createQueryBuilder() + .update(Post) + .set({ likeCount: () => 'likeCount + 1' }) + .where('id=:id', { id: post.id }) + .execute(); + } + + decreaseLikeCount(post: Post) { + if (post.likeCount <= 0) { + return; + } + this.createQueryBuilder() + .update(Post) + .set({ likeCount: () => 'likeCount - 1' }) + .where('id=:id', { id: post.id }) + .execute(); + } + + increaseReviewCount(post: Post) { + this.createQueryBuilder() + .update(Post) + .set({ reviewCount: () => 'reviewCount + 1' }) + .where('id=:id', { id: post.id }) + .execute(); + } } diff --git a/server/src/domain/post/post.service.spec.ts b/server/src/domain/post/post.service.spec.ts index 0f9a3f0b..4cd269ea 100644 --- a/server/src/domain/post/post.service.spec.ts +++ b/server/src/domain/post/post.service.spec.ts @@ -3,7 +3,6 @@ import { WriteDto } from '../post/dto/controller-request.dto'; import { PostService } from './post.service'; import { PostRepository } from './post.repository'; import { PostToTagRepository } from '../post-to-tag/post-to-tag.repository'; -import { LoadPostListRequestDto } from './dto/service-request.dto'; import { TagRepository } from '../tag/tag.repository'; import { UserNotFoundException } from '../../exception/user-not-found.exception'; import { UserRepository } from '../user/user.repository'; @@ -14,13 +13,19 @@ import { UserNotSameException } from '../../exception/user-not-same.exception'; import { Post } from './post.entity'; import { Tag } from '../tag/tag.entity'; import { PostToTag } from '../post-to-tag/post-to-tag.entity'; +import { ImageRepository } from '../image/image.repository'; +import { PostSearchService } from './post-search.service'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { ConfigService } from '@nestjs/config'; describe('PostService', () => { let service: PostService; - let postRepository; - let postToTagRepository; - let tagRepository; - let userRepository; + let postRepository: PostRepository; + let postToTagRepository: PostToTagRepository; + let tagRepository: TagRepository; + let userRepository: UserRepository; + let imageRepository: ImageRepository; + let searchService: PostSearchService; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -30,12 +35,42 @@ describe('PostService', () => { PostToTagRepository, UserRepository, TagRepository, + ImageRepository, + { + provide: PostSearchService, + useValue: { + indexPost: () => jest.fn(), + search: () => + jest.fn().mockResolvedValue({ + hits: { + hits: [ + { _source: { id: 1 } }, + { _source: { id: 2 } }, + { _source: { id: 3 } }, + ], + }, + }), + }, + }, { provide: DataSource, useValue: { createEntityManager: () => jest.fn(), }, }, + { + provide: ElasticsearchService, + useValue: { + index: () => jest.fn(), + search: () => jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: () => jest.fn(), + }, + }, ], }).compile(); @@ -44,499 +79,15 @@ describe('PostService', () => { postToTagRepository = module.get(PostToTagRepository); tagRepository = module.get(TagRepository); userRepository = module.get(UserRepository); + imageRepository = module.get(ImageRepository); + searchService = module.get(PostSearchService); }); it('should be defined', () => { expect(service).toBeDefined(); }); - describe('게시물 조회', () => { - const searchCondition: LoadPostListRequestDto = { - lastId: 1, - tags: [], - reviewCount: 1, - likeCount: 1, - }; - const resultFilteringLikesCnt = [ - { postId: 1, likesCnt: '1' }, - { postId: 2, likesCnt: '2' }, - ]; - const resultFilteringTag = [{ postId: 2 }, { postId: 3 }]; - - const postListThatHasOnePost = [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 6, - title: 'bubble sort 이렇게 해도 되나요?', - content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - code: "console.log('코드가 작성되니다');", - language: 'java', - user: { - isDeleted: false, - createdAt: '2022-11-17T08:01:08.967Z', - updatedAt: '2022-11-17T08:01:08.967Z', - id: 1, - nickname: 'taehoon1229', - profileUrl: 'https://avatars.githubusercontent.com/u/67636607?v=4', - email: 'kimth9981@naver.com', - }, - postToTags: [], - images: [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 1, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 2, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - }, - ], - }, - ]; - - const postListThatHasFourPost = [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 6, - title: 'bubble sort 이렇게 해도 되나요?', - content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - code: "console.log('코드가 작성되니다');", - language: 'java', - user: { - isDeleted: false, - createdAt: '2022-11-17T08:01:08.967Z', - updatedAt: '2022-11-17T08:01:08.967Z', - id: 1, - nickname: 'taehoon1229', - profileUrl: 'https://avatars.githubusercontent.com/u/67636607?v=4', - email: 'kimth9981@naver.com', - }, - postToTags: [], - images: [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 1, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 2, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - }, - ], - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 5, - title: 'bubble sort 이렇게 해도 되나요?', - content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - code: "console.log('코드가 작성되니다');", - language: 'java', - user: { - isDeleted: false, - createdAt: '2022-11-17T08:01:08.967Z', - updatedAt: '2022-11-17T08:01:08.967Z', - id: 1, - nickname: 'taehoon1229', - profileUrl: 'https://avatars.githubusercontent.com/u/67636607?v=4', - email: 'kimth9981@naver.com', - }, - postToTags: [], - images: [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 1, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 2, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - }, - ], - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 4, - title: 'bubble sort 이렇게 해도 되나요?', - content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - code: "console.log('코드가 작성되니다');", - language: 'java', - user: { - isDeleted: false, - createdAt: '2022-11-17T08:01:08.967Z', - updatedAt: '2022-11-17T08:01:08.967Z', - id: 1, - nickname: 'taehoon1229', - profileUrl: 'https://avatars.githubusercontent.com/u/67636607?v=4', - email: 'kimth9981@naver.com', - }, - postToTags: [], - images: [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 1, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 2, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - }, - ], - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 3, - title: 'bubble sort 이렇게 해도 되나요?', - content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - code: "console.log('코드가 작성되니다');", - language: 'java', - user: { - isDeleted: false, - createdAt: '2022-11-17T08:01:08.967Z', - updatedAt: '2022-11-17T08:01:08.967Z', - id: 1, - nickname: 'taehoon1229', - profileUrl: 'https://avatars.githubusercontent.com/u/67636607?v=4', - email: 'kimth9981@naver.com', - }, - postToTags: [], - images: [ - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 1, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - }, - { - isDeleted: false, - createdAt: '2022-11-17T11:41:34.568Z', - updatedAt: '2022-11-17T11:41:34.568Z', - id: 2, - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - }, - ], - }, - ]; - - const inquiryResult = { - posts: [ - { - id: 6, - title: 'bubble sort 이렇게 해도 되나요?', - content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - code: "console.log('코드가 작성되니다');", - language: 'java', - images: [ - { - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - name: 'js-error', - }, - { - src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - name: 'suggestions', - }, - ], - updatedAt: '2022-11-17T11:41:34.568Z', - author: { - id: 1, - nickname: 'taehoon1229', - profileUrl: 'https://avatars.githubusercontent.com/u/67636607?v=4', - email: 'kimth9981@naver.com', - }, - tags: [], - isLiked: false, - }, - ], - lastId: 6, - isLast: true, - }; - - beforeEach(async () => { - // given - jest - .spyOn(postRepository, 'findByIdLikesCntGreaterThanOrEqual') - .mockResolvedValue(resultFilteringLikesCnt); - jest - .spyOn(postToTagRepository, 'findByContainingTags') - .mockResolvedValue(resultFilteringTag); - jest - .spyOn(postRepository, 'filterUsingDetail') - .mockResolvedValue(resultFilteringTag); - jest - .spyOn(postRepository, 'findByReviewCntGreaterThanOrEqual') - .mockResolvedValue(resultFilteringTag); - jest.spyOn(tagRepository, 'findById').mockResolvedValue({ name: 'java' }); - }); - - it('마지막 결과값을 포함할 때 isLast는 true가 된다', async () => { - //given - jest - .spyOn(postRepository, 'findByIdWithFilterResult') - .mockResolvedValue(postListThatHasOnePost); - - //when - const result = await service.loadPostList(searchCondition); - - //then - // expect(result).toEqual({ - // posts: [ - // { - // id: 6, - // title: 'bubble sort 이렇게 해도 되나요?', - // content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - // code: "console.log('코드가 작성되니다');", - // language: 'java', - // images: [ - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - // name: 'js-error', - // }, - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - // name: 'suggestions', - // }, - // ], - // updatedAt: '2022-11-17T11:41:34.568Z', - // author: { - // id: 1, - // nickname: 'taehoon1229', - // profileUrl: - // 'https://avatars.githubusercontent.com/u/67636607?v=4', - // email: 'kimth9981@naver.com', - // }, - // tags: [], - // isLiked: false, - // }, - // ], - // lastId: 6, - // isLast: true, - // }); - }); - - it('마지막 결과가 아닐 때 isLast는 false가 된다', async () => { - //given - jest - .spyOn(postRepository, 'findByIdWithFilterResult') - .mockResolvedValue(postListThatHasFourPost); - - // when - const result = await service.loadPostList(searchCondition); - // then - // expect(result).toEqual({ - // posts: [ - // { - // id: 6, - // title: 'bubble sort 이렇게 해도 되나요?', - // content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - // code: "console.log('코드가 작성되니다');", - // language: 'java', - // images: [ - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - // name: 'js-error', - // }, - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - // name: 'suggestions', - // }, - // ], - // updatedAt: '2022-11-17T11:41:34.568Z', - // author: { - // id: 1, - // nickname: 'taehoon1229', - // profileUrl: - // 'https://avatars.githubusercontent.com/u/67636607?v=4', - // email: 'kimth9981@naver.com', - // }, - // tags: [], - // isLiked: false, - // }, - // { - // id: 5, - // title: 'bubble sort 이렇게 해도 되나요?', - // content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - // code: "console.log('코드가 작성되니다');", - // language: 'java', - // images: [ - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - // name: 'js-error', - // }, - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - // name: 'suggestions', - // }, - // ], - // updatedAt: '2022-11-17T11:41:34.568Z', - // author: { - // id: 1, - // nickname: 'taehoon1229', - // profileUrl: - // 'https://avatars.githubusercontent.com/u/67636607?v=4', - // email: 'kimth9981@naver.com', - // }, - // tags: [], - // isLiked: false, - // }, - // { - // id: 4, - // title: 'bubble sort 이렇게 해도 되나요?', - // content: '이게 맞나 모르겠어요 ㅠㅠㅠ', - // code: "console.log('코드가 작성되니다');", - // language: 'java', - // images: [ - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/js-error.png', - // name: 'js-error', - // }, - // { - // src: 'https://code.visualstudio.com/assets/docs/nodejs/reactjs/suggestions.png', - // name: 'suggestions', - // }, - // ], - // updatedAt: '2022-11-17T11:41:34.568Z', - // author: { - // id: 1, - // nickname: 'taehoon1229', - // profileUrl: - // 'https://avatars.githubusercontent.com/u/67636607?v=4', - // email: 'kimth9981@naver.com', - // }, - // tags: [], - // isLiked: false, - // }, - // ], - // lastId: 4, - // isLast: false, - // }); - }); - - it('검색 결과가 없을 때, post=[], lastId -1 isLast: true 를 반환한다', async () => { - // given - jest - .spyOn(postRepository, 'findByIdWithFilterResult') - .mockResolvedValue([]); - - // when - const result = await service.loadPostList(searchCondition); - - // then - expect(result).toEqual({ - posts: [], - lastId: -1, - isLast: true, - }); - }); - - it('이미지가 있을때, 결과를 정상적으로 반환한다', async () => { - // TODO - }); - - it('태그들이 있을 때, 정상적으로 결과를 반환한다', async () => { - // TODO - }); - - describe('returnPostIdByAllConditionPass 테스트: 여러 조건(좋아요, 태그, 검색어)로 post를 필터링한다', () => { - it('모든 조건을 다 사용하는 상황 ', () => { - const resultFilteringLikesCnt = [ - { postId: 1, likesCnt: '1' }, - { postId: 2, likesCnt: '2' }, - { postId: 3, likesCnt: '2' }, - ]; - const resultFilteringTag = [{ postId: 2 }, { postId: 3 }]; - const resultFilteringSearchWord = [{ postId: 2 }, { postId: 3 }]; - - const result = service.mergeFilterResult([ - resultFilteringLikesCnt, - resultFilteringTag, - resultFilteringSearchWord, - ]); - - expect(result).toEqual([2, 3]); - }); - - it('하나의 조건을 만족하는 Post가 한 개도 없는 경우', () => { - const resultFilteringLikesCnt = [ - { postId: 1, likesCnt: '1' }, - { postId: 2, likesCnt: '2' }, - { postId: 3, likesCnt: '2' }, - ]; - const resultFilteringTag = []; - const resultFilteringSearchWord = [{ postId: 2 }, { postId: 3 }]; - - const result = service.mergeFilterResult([ - resultFilteringLikesCnt, - resultFilteringTag, - resultFilteringSearchWord, - ]); - - expect(result).toEqual([]); - }); - - it('하나의 조건을 사용하지 않는 경우(null을 리턴)', () => { - const resultFilteringLikesCnt = [ - { postId: 1, likesCnt: '1' }, - { postId: 2, likesCnt: '2' }, - { postId: 3, likesCnt: '2' }, - ]; - const resultFilteringTag = [{ postId: 2 }, { postId: 3 }]; - const resultFilteringSearchWord = null; - - const result = service.mergeFilterResult([ - resultFilteringLikesCnt, - resultFilteringTag, - ]); - - expect(result).toEqual([2, 3]); - }); - - it('모든 조건을 사용하지 않는 경우', () => { - const resultFilteringLikesCnt = null; - const resultFilteringTag = null; - const resultFilteringSearchWord = null; - - const result = service.mergeFilterResult([ - resultFilteringLikesCnt, - resultFilteringTag, - resultFilteringSearchWord, - ]); - - expect(result).toBeNull(); - }); - }); - }); - + // TODO PostService에서 글 작성시, 중복된 태그 입력하면 예외 처리하기 describe('글 작성', () => { const writeDto: WriteDto = { title: '제목', @@ -559,10 +110,13 @@ describe('PostService', () => { }); it('글 작성 성공', async () => { - userRepository.findOneBy = jest.fn(() => new User()); + userRepository.findOneBy = jest.fn().mockResolvedValue(() => new User()); tagRepository.findOneBy = jest.fn( () => new Promise((resolve) => resolve(new Tag())), ); + const post = new Post(); + post.id = 1; + postRepository.save = jest.fn().mockResolvedValue(post); await service.write(1, writeDto); expect(postRepository.save).toBeCalledTimes(1); diff --git a/server/src/domain/post/post.service.ts b/server/src/domain/post/post.service.ts index 7feb3e72..712afc43 100644 --- a/server/src/domain/post/post.service.ts +++ b/server/src/domain/post/post.service.ts @@ -13,6 +13,9 @@ import { UserNotFoundException } from 'src/exception/user-not-found.exception'; import { UserRepository } from '../user/user.repository'; import { UserNotSameException } from '../../exception/user-not-same.exception'; import { PostNotFoundException } from '../../exception/post-not-found.exception'; +import { PostSearchService } from './post-search.service'; +import { SearchResponseDto } from './dto/controller-response.dto'; +import { ImageRepository } from '../image/image.repository'; @Injectable() export class PostService { @@ -21,12 +24,17 @@ export class PostService { private readonly postToTagRepository: PostToTagRepository, private readonly tagRepository: TagRepository, private readonly userRepository: UserRepository, + private readonly postSearchService: PostSearchService, + private readonly imageRepository: ImageRepository, ) {} async write( userId: number, { title, content, code, language, lineCount, images, tags }, ) { + // TODO 입력값 검증 + tags.sort(); + const userEntity = await this.userRepository.findOneBy({ id: userId, }); @@ -42,7 +50,7 @@ export class PostService { }); const postToTagEntities = await this.toPostToTagEntities(tags); - const postEntity = new Post(); + let postEntity: Post = new Post(); postEntity.title = title; postEntity.content = content; postEntity.code = code; @@ -51,7 +59,11 @@ export class PostService { postEntity.user = userEntity; postEntity.images = imageEntities; postEntity.postToTags = postToTagEntities; - await this.postRepository.save(postEntity); + postEntity.tags = JSON.stringify(tags); + postEntity.userNickname = userEntity.nickname; + + postEntity = await this.postRepository.save(postEntity); + await this.postSearchService.indexPost(postEntity, tags); return postEntity.id; } @@ -77,78 +89,6 @@ export class PostService { return Promise.all(postToTagEntityPromises); } - /** - * 게시물을 조회한다 - */ - async loadPostList( - loadPostListRequestDto: LoadPostListRequestDto, - ): Promise { - const { lastId, tags, reviewCount, likeCount, details } = - loadPostListRequestDto; - let isLast = true; - - const postIdsFiltered = await this.filter( - tags, - reviewCount, - likeCount, - details, - ); - const result = await this.postRepository.findByIdWithFilterResult( - lastId, - postIdsFiltered, - ); - - if (this.canGetNextPost(result.length)) { - result.pop(); - isLast = false; - } - return new LoadPostListResponseDto(result, isLast); - } - - private canGetNextPost(resultCnt: number) { - return resultCnt === SEND_POST_CNT + 1; - } - - private async filter( - tags: string[], - reviewCount: number, - likeCount: number, - details: string[], - ) { - const postsThatPassEachFilter = await Promise.all([ - this.postRepository.findByIdLikesCntGreaterThanOrEqual(likeCount), - this.postToTagRepository.findByContainingTags(tags), - this.postRepository.findByReviewCntGreaterThanOrEqual(reviewCount), - this.filterUsingDetails(details), - ]); - return this.mergeFilterResult(postsThatPassEachFilter); - } - - /** - * 사용된 검색 조건이 한개도 없으면 -> null을 반환 - * 조건을 만족시키는 사용자가 한 명도 없으면 -> 비어있는 배열 []을 반환 - * 배열 안에 값이 있다면 -> 조건을 만족하는 사용자들의 id 리스트를 반환 - */ - public mergeFilterResult(postInfos: any[]) { - let result; - for (const postInfo of postInfos) { - if (postInfo === null) { - continue; - } - if (result === undefined) { - result = postInfo.map((obj) => obj.postId); - } else { - result = postInfo - .map((obj) => obj.postId) - .filter((each) => result.includes(each)); - } - } - if (!result) { - return null; - } - return result; - } - async delete(userId: number, postId: number) { const post = await this.postRepository.findOne({ where: { @@ -178,42 +118,42 @@ export class PostService { return new LoadPostListResponseDto([post], true); } - private async filterUsingDetails(details: string[]) { - if (!details || details.length < 1) { - return null; + async loadPostList( + loadPostListRequestDto: LoadPostListRequestDto, + ): Promise { + let isLast = true; + const results = await this.postSearchService.search(loadPostListRequestDto); + if (results.length > SEND_POST_CNT + 1) { + throw new Error('너무 많은 검색 결과가 반환되었습니다'); } - const postsFilteringEachDetail = await Promise.all( - details.map((detail) => this.postRepository.filterUsingDetail(detail)), - ); - - const temp = []; - for (const posts of postsFilteringEachDetail) { - temp.push(posts.map((post) => post.postId)); + if (this.canGetNextPost(results.length)) { + results.pop(); + isLast = false; } - return this.findIntersection(temp); - } - private findIntersection(bundles: any[][]) { - const result = []; - let smallestBundle = bundles[0]; - for (const post of bundles) { - if (smallestBundle.length > post.length) { - smallestBundle = post; - } + const authors = []; + const images = []; + for (const result of results) { + authors.push( + this.userRepository.findOneBy({ + id: result._source['userid'], + }), + ); + images.push( + this.imageRepository.findBy({ + postId: Number(result._source['id']), + }), + ); } + return new SearchResponseDto( + results, + await Promise.all(authors), + await Promise.all(images), + isLast, + ); + } - for (const postId of smallestBundle) { - let isIntersect = true; - for (const bundle of bundles) { - if (!bundle.includes(postId)) { - isIntersect = false; - break; - } - } - if (isIntersect) { - result.push({ postId: postId }); - } - } - return result; + private canGetNextPost(resultCnt: number) { + return resultCnt === SEND_POST_CNT + 1; } } diff --git a/server/src/domain/post/types/post-search-body.interface.ts b/server/src/domain/post/types/post-search-body.interface.ts new file mode 100644 index 00000000..2c27dd02 --- /dev/null +++ b/server/src/domain/post/types/post-search-body.interface.ts @@ -0,0 +1,24 @@ +interface PostSearchBody { + id: number; + title: string; + content: string; + code: string; + language: string; + createdat: Date; + updatedat: Date; + authorid: number; + authornickname: string; + tags: string; + likecount: number; + reviewcount: number; + linecount: number; +} + +interface PostSearchResult { + hits: { + total: number; + hits: Array<{ + _source: PostSearchBody; + }>; + }; +} diff --git a/server/src/domain/review/review.service.ts b/server/src/domain/review/review.service.ts index 510e93f0..8f4215af 100644 --- a/server/src/domain/review/review.service.ts +++ b/server/src/domain/review/review.service.ts @@ -50,6 +50,7 @@ export class ReviewService { reviewEntity.content = content; await this.reviewRepository.insert(reviewEntity); + this.postRepository.increaseReviewCount(postEntity); } async getReviewsOfPost(postId: number, { lastId }: ReviewGetAllRequestDto) { diff --git a/server/src/exception/post-input-invalid.exception.ts b/server/src/exception/post-input-invalid.exception.ts new file mode 100644 index 00000000..2acfae7c --- /dev/null +++ b/server/src/exception/post-input-invalid.exception.ts @@ -0,0 +1,5 @@ +export class PostInputInvalidException extends Error { + constructor(msg: string) { + super(msg); + } +} diff --git a/server/src/exception/search-param-invalid.exception.ts b/server/src/exception/search-param-invalid.exception.ts new file mode 100644 index 00000000..a8270b21 --- /dev/null +++ b/server/src/exception/search-param-invalid.exception.ts @@ -0,0 +1,5 @@ +export class SearchParamInvalidException extends Error { + constructor(msg: string) { + super(msg); + } +} diff --git a/server/src/exception/search-response-invalid.exception.ts b/server/src/exception/search-response-invalid.exception.ts new file mode 100644 index 00000000..e9a608b6 --- /dev/null +++ b/server/src/exception/search-response-invalid.exception.ts @@ -0,0 +1,5 @@ +export class SearchResponseInvalidException extends Error { + constructor(msg) { + super(msg); + } +} diff --git a/server/src/exception/tag-invalid.exception.ts b/server/src/exception/tag-invalid.exception.ts new file mode 100644 index 00000000..f7cb9cbe --- /dev/null +++ b/server/src/exception/tag-invalid.exception.ts @@ -0,0 +1,5 @@ +export class TagInvalidException extends Error { + constructor(msg) { + super(msg); + } +}