diff --git a/backend/README.md b/backend/README.md index ea367cc..c980ab8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -180,8 +180,7 @@ NewsSource는 뉴스를 수집할 언론사를 의미합니다. 뉴스 스크래 | 기능 | 설명 | 경로 | Guard | |-|-|-|-| |이슈 키워드 목록|최근 이슈 키워드 목록을 가져옵니다.|``GET /search/popular-keywords``|| -|키워드 및 대표 댓글 목록|특정 키워드에 대해 기간 내 감정 별로 최대 공감 수를 받은 댓글들을 가져옵니다.| ``GET /search/top-comments?keyword=~``|| - +|키워드 및 대표 댓글 목록|특정 키워드에 대해 기간 내 감정 별로 최대 공감 수를 받은 댓글들을 가져옵니다.| ``GET /search/keyword?keyword=~``|| # API 경로 현재 프로젝트는 [@nestjs/swagger](https://www.npmjs.com/package/@nestjs/swagger)을 이용하여 API를 문서화합니다. 서버를 가동한 후 다음 주소를 통해 swagger 문서를 볼 수 있습니다. diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml index ab39465..36f0356 100644 --- a/backend/docker-compose.dev.yml +++ b/backend/docker-compose.dev.yml @@ -2,7 +2,7 @@ version: '3' services: backend: ports: - - 8080:8080 + - 3030:8080 depends_on: - my-redis # redis 동작해야 의미 O build: @@ -11,12 +11,7 @@ services: env_file: - "./server/.env" volumes: - - /home/app/node_modules - - ./server:/home/app - deploy: - restart_policy: - condition: on-failure - delay: 3s - max_attempts: 3 + - /usr/src/app/node_modules + - ./server:/usr/src/app my-redis: image: redis:alpine \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index cced02d..a9d8091 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -6,12 +6,11 @@ services: - ./.env ports: - 8080:8080 - volumes: - - /home/app/node_modules - - ./server:/home/app deploy: restart_policy: condition: on-failure delay: 3s # 실패 시 대기시간 max_attempts: 3 # 최대 재실행 횟수 - window: 120s # "성공" 기준이 되는 실행 시간 \ No newline at end of file + window: 120s # "성공" 기준이 되는 실행 시간 + my-redis: + image: redis:alpine \ No newline at end of file diff --git a/backend/server/Dockerfile.dev b/backend/server/Dockerfile.dev index dbaf8fb..ea346c8 100644 --- a/backend/server/Dockerfile.dev +++ b/backend/server/Dockerfile.dev @@ -1,12 +1,7 @@ FROM node:alpine # 작업 폴더 생성 -RUN mkdir /home/app -# 작업 폴더 지정 -WORKDIR /home/app - -# port 8080으로 외부 데이터 들을 수 있음 -EXPOSE 8080 +WORKDIR /usr/src/app # 설정 파일들 복사 COPY package*.json . @@ -15,6 +10,9 @@ COPY package*.json . RUN npm ci # 나머지 파일들 복사 COPY . . +# 파일 빌드 +# port 8080으로 외부 데이터 들을 수 있음 +EXPOSE 8080 # 실행 시 보여줄 명령 CMD ["npm", "run", "start:dev"] diff --git a/backend/server/src/analysis-comment/analysis-comment.controller.spec.ts b/backend/server/src/analysis-comment/analysis-comment.controller.spec.ts index 1a6b749..d5f7480 100644 --- a/backend/server/src/analysis-comment/analysis-comment.controller.spec.ts +++ b/backend/server/src/analysis-comment/analysis-comment.controller.spec.ts @@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AnalysisCommentController } from './analysis-comment.controller'; import { AnalysisCommentService } from './analysis-comment.service'; import { CreateCommentDto } from './dtos/create-comment.dto'; -import { GetCommentsQueriesDto } from './dtos/get-comments-query.dto'; import { AuthGuard } from '../auth/auth.guard'; jest.mock('./analysis-comment.service'); @@ -51,13 +50,11 @@ describe('AnalysisCommentController', () => { describe('getComments()', () => { it('call commentService.getComments', async () => { // dummy data - const dummyDto: GetCommentsQueriesDto = { - search: '', - }; + const comment_id = 1; // action - await controller.getComments(dummyDto); + await controller.getComment(comment_id); // assert - expect(service.findMany).toBeCalled(); + expect(service.findManyWithQuery).toBeCalled(); }); }); }); diff --git a/backend/server/src/analysis-comment/analysis-comment.controller.ts b/backend/server/src/analysis-comment/analysis-comment.controller.ts index d3607ed..537fbc7 100644 --- a/backend/server/src/analysis-comment/analysis-comment.controller.ts +++ b/backend/server/src/analysis-comment/analysis-comment.controller.ts @@ -1,8 +1,7 @@ -import { Controller, Post, Get, Body, Query, UseGuards } from '@nestjs/common'; +import { Controller, Post, Get, Body, UseGuards, Param } from '@nestjs/common'; import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CreateCommentDto } from './dtos/create-comment.dto'; -import { GetCommentsQueriesDto } from './dtos/get-comments-query.dto'; import { AnalysisCommentService } from './analysis-comment.service'; import { AuthGuard } from '../auth/auth.guard'; @@ -33,9 +32,9 @@ export class AnalysisCommentController { description: '가져온 댓글 목록', status: 200, }) - @Get() - async getComments(@Query() queries: GetCommentsQueriesDto) { - const comments = await this.commentService.findMany(queries); - return comments; + @Get(':id') + async getComment(@Param('id') id: number) { + const comment = await this.commentService.findOneById(id); + return comment; } } diff --git a/backend/server/src/analysis-comment/analysis-comment.service.ts b/backend/server/src/analysis-comment/analysis-comment.service.ts index 43ef074..496cc85 100644 --- a/backend/server/src/analysis-comment/analysis-comment.service.ts +++ b/backend/server/src/analysis-comment/analysis-comment.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { Between, DataSource, FindOperator, Repository } from 'typeorm'; import { AnalysisComment } from './entity/analysis-comment.entity'; import { CreateCommentDto } from './dtos/create-comment.dto'; import { ArticleContent } from './entity/article-content.entity'; import { GetCommentsQueriesDto } from './dtos/get-comments-query.dto'; + @Injectable() export class AnalysisCommentService { constructor( @@ -40,7 +41,58 @@ export class AnalysisCommentService { return comment; } - async findMany({ search, psize, head_id, from, to }: GetCommentsQueriesDto) { + /** + * id 기반으로 댓글을 검색한다. 연관 기사 문장과 함께 가져올지 지정할 수 있다. + */ + async findOneById(id: number, isWithSentences = true) { + return await this.comment_repo.findOne({ + where: { + id: id, + }, + relations: { + news_sentences: isWithSentences, + }, + }); + } + + /** + * commment 정보를 이용하여 연관된 키워드 목록을 가져온다 + */ + async findManyByInfo(info: { + keyword_id: number; + emotion: string; + count: number; + from?: string; + to?: string; + }) { + const { keyword_id, emotion, count, from, to } = info; + let between: FindOperator | undefined; + if (from && to) { + between = Between(new Date(from), new Date(to)); + } + + await this.comment_repo.find({ + where: { + keyword_id: keyword_id, + emotion: emotion, + createdAt: between, + }, + order: { + sympathy: { + direction: 'DESC', + }, + }, + take: count, + }); + } + + async findManyWithQuery({ + search, + psize, + head_id, + from, + to, + }: GetCommentsQueriesDto) { if (!search) return []; const qb = this.comment_repo.createQueryBuilder(); diff --git a/backend/server/src/app.controller.spec.ts b/backend/server/src/app.controller.spec.ts new file mode 100644 index 0000000..aec925a --- /dev/null +++ b/backend/server/src/app.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; + +describe('AppController', () => { + let controller: AppController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + }).compile(); + + controller = module.get(AppController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/server/src/app.controller.ts b/backend/server/src/app.controller.ts new file mode 100644 index 0000000..84a6b47 --- /dev/null +++ b/backend/server/src/app.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + /** + * 접근 가능한 메인 주소 + */ + @Get() + sayHello() { + return ` + + + + keyword-on + + + +

Keyword-on에 어서 오세요

+

키워드 온 메인 페이지입니다!

+ +`; + } +} diff --git a/backend/server/src/app.module.ts b/backend/server/src/app.module.ts index 04f53da..f90f108 100644 --- a/backend/server/src/app.module.ts +++ b/backend/server/src/app.module.ts @@ -13,6 +13,8 @@ import { AuthModule } from './auth/auth.module'; import { TokenModule } from './token/token.module'; import { RedisModule } from './redis/redis.module'; import { SearchModule } from './search/search.module'; +import { BatchModule } from './batch/batch.module'; +import { AppController } from './app.controller'; @Module({ imports: [ @@ -25,6 +27,8 @@ import { SearchModule } from './search/search.module'; TokenModule, RedisModule, SearchModule, + BatchModule, ], + controllers: [AppController], }) export class AppModule {} diff --git a/backend/server/src/batch/batch.module.ts b/backend/server/src/batch/batch.module.ts new file mode 100644 index 0000000..f11588b --- /dev/null +++ b/backend/server/src/batch/batch.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BatchService } from './batch.service'; +import { ScheduleModule } from '@nestjs/schedule'; +import { SearchModule } from 'src/search/search.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), SearchModule], + providers: [BatchService], +}) +export class BatchModule {} diff --git a/backend/server/src/batch/batch.service.spec.ts b/backend/server/src/batch/batch.service.spec.ts new file mode 100644 index 0000000..92ff216 --- /dev/null +++ b/backend/server/src/batch/batch.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BatchService } from './batch.service'; + +describe('BatchService', () => { + let service: BatchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BatchService], + }).compile(); + + service = module.get(BatchService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/server/src/batch/batch.service.ts b/backend/server/src/batch/batch.service.ts new file mode 100644 index 0000000..d680d65 --- /dev/null +++ b/backend/server/src/batch/batch.service.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; + +import { SearchService } from 'src/search/search.service'; + +@Injectable() +export class BatchService { + private readonly logger = new Logger(BatchService.name); + constructor(private searchService: SearchService) {} + + @Cron('0 0 0 * * *') + async runSchedule() { + try { + const result = await this.searchService.clearPopularKeywords(); + this.logger.log(`clear popular keyword list. delete count: ${result}`); + } catch (e) { + this.logger.error('wrong with clearPopKeywords', e); + } + } +} diff --git a/backend/server/src/search/dtos/common/out-keyword.dto.ts b/backend/server/src/search/dtos/common/out-keyword.dto.ts new file mode 100644 index 0000000..d838896 --- /dev/null +++ b/backend/server/src/search/dtos/common/out-keyword.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from 'class-transformer'; +import { Keyword } from '../../../keyword/keyword.entity'; + +export class OutKeywordDto + implements Pick +{ + @Expose() + id: number; + + @Expose() + name: string; + + @Expose() + description: string; +} diff --git a/backend/server/src/search/dtos/keyword-with-top-comments.dto.ts b/backend/server/src/search/dtos/keyword-with-top-comments.dto.ts index c428111..2ce6c66 100644 --- a/backend/server/src/search/dtos/keyword-with-top-comments.dto.ts +++ b/backend/server/src/search/dtos/keyword-with-top-comments.dto.ts @@ -1,7 +1,7 @@ import { Expose } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; import { AnalysisComment } from '../../analysis-comment/entity/analysis-comment.entity'; -import { Keyword } from '../../keyword/keyword.entity'; +import { OutKeywordDto } from './common/out-keyword.dto'; export class KeywordWithTopCommentsReqQueryDto { /** @@ -17,7 +17,7 @@ export class KeywordWithTopCommentsReqQueryDto { */ @IsString() @IsOptional() - from: string; + from?: string; /** * 검색 끝일 @@ -25,15 +25,7 @@ export class KeywordWithTopCommentsReqQueryDto { */ @IsString() @IsOptional() - to: string; -} - -export class OutKeywordDto implements Pick { - @Expose() - id: number; - - @Expose() - name: string; + to?: string; } export class OutCommentDto diff --git a/backend/server/src/search/dtos/popular-keyword.dto.ts b/backend/server/src/search/dtos/popular-keyword.dto.ts index 00ca099..d3da9c2 100644 --- a/backend/server/src/search/dtos/popular-keyword.dto.ts +++ b/backend/server/src/search/dtos/popular-keyword.dto.ts @@ -1,6 +1,7 @@ import { IsInt, IsOptional, IsPositive } from 'class-validator'; +import { OutKeywordDto } from './common/out-keyword.dto'; -export class ReqPopularKeywordQueryDto { +export class PopularKeywordsReqQueryDto { /** * 한번에 가져올 키워드 개수 * @example 3 @@ -10,3 +11,5 @@ export class ReqPopularKeywordQueryDto { @IsOptional() count?: number = 10; } + +export class PopularKeywordsResDto extends OutKeywordDto {} diff --git a/backend/server/src/search/search.controller.ts b/backend/server/src/search/search.controller.ts index 30b15a0..f5758e4 100644 --- a/backend/server/src/search/search.controller.ts +++ b/backend/server/src/search/search.controller.ts @@ -1,13 +1,17 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, Param } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Keyword } from 'src/keyword/keyword.entity'; -import { ReqPopularKeywordQueryDto } from './dtos/popular-keyword.dto'; + import { SearchService } from './search.service'; +import { Serialize } from '../interceptors/serialize.interceptor'; + +import { + PopularKeywordsReqQueryDto, + PopularKeywordsResDto, +} from './dtos/popular-keyword.dto'; import { KeywordWithTopCommentsReqQueryDto, KeywordWithTopCommentsResDto, } from './dtos/keyword-with-top-comments.dto'; -import { Serialize } from 'src/interceptors/serialize.interceptor'; @ApiTags('Search') @Controller('search') @@ -19,13 +23,20 @@ export class SearchController { @ApiResponse({ status: 200, description: '중요한 키워드 목록을 반환한다.', - type: () => Keyword, + type: () => PopularKeywordsResDto, }) - @Get('popular-keyword') + // @Serialize(PopularKeywordsResDto) + @Get('popular-keywords') async getPopularKeywords( - @Query() dto: ReqPopularKeywordQueryDto, - ): Promise { - return await this.service.findManyPopularKeyword(dto.count!); + @Query() dto: PopularKeywordsReqQueryDto, + ): Promise { + const keywords = await this.service.getManyPopularKeywords(dto.count!); + const results = keywords.map((keyword) => { + const { id, description, name } = keyword; + return { id, description, name }; + }); + + return results; } /** @@ -36,8 +47,10 @@ export class SearchController { type: () => KeywordWithTopCommentsResDto, }) @Serialize(KeywordWithTopCommentsResDto) - @Get('keyword') - async getTopComments(@Query() dto: KeywordWithTopCommentsReqQueryDto) { + @Get('keyword-search-result') + async getKeywordWithTopComments( + @Query() dto: KeywordWithTopCommentsReqQueryDto, + ) { const { name, from, to } = dto; return await this.service.getKeywordWithTopCommentsForEmotion( name, @@ -45,4 +58,15 @@ export class SearchController { to, ); } + + /** + * 선택한 댓글의 정보를 연관 문장과 함께 가져온다 + */ + @ApiResponse({ + status: 200, + }) + @Get('detail/comment/:id') + async getCommentWithSentences(@Param('id') id: number) { + return await this.service.getCommentWithSentences(id); + } } diff --git a/backend/server/src/search/search.module.ts b/backend/server/src/search/search.module.ts index be8ab05..a0b5424 100644 --- a/backend/server/src/search/search.module.ts +++ b/backend/server/src/search/search.module.ts @@ -9,5 +9,6 @@ import { SearchController } from './search.controller'; imports: [KeywordModule, AnalysisCommentModule], providers: [redisProvider, SearchService], controllers: [SearchController], + exports: [SearchService], }) export class SearchModule {} diff --git a/backend/server/src/search/search.service.ts b/backend/server/src/search/search.service.ts index b914e89..6eeb4d9 100644 --- a/backend/server/src/search/search.service.ts +++ b/backend/server/src/search/search.service.ts @@ -18,7 +18,7 @@ export class SearchService { /** * 인기 있는 키워드 목록을 반환한다. 메인 화면에서 사용됨. */ - async findManyPopularKeyword(count: number): Promise { + async getManyPopularKeywords(count: number): Promise { const key = this.config.get('POPULAR_KEYWORDS_KEY'); const keyword_ids = (await this.redisStore.zrevrange(key, 0, count)).map( (it) => parseInt(it), @@ -56,4 +56,35 @@ export class SearchService { comments, }; } + + /** + * 최근 인기 키워드 목록을 초기화 + */ + async clearPopularKeywords() { + const key = this.config.get('POPULAR_KEYWORDS_KEY'); + return await this.redisStore.del(key); + } + + /** + * 댓글을 관련된 문장과 함께 가져오기 + */ + async getCommentWithSentences(id: number) { + const commentWithSentences = await this.commentService.findOneById( + id, + true, + ); + if (!commentWithSentences) + throw new NotFoundException(`there is no comment id: ${id}`); + return commentWithSentences; + } + + async getRelatedKeywordList(info: { + keyword_id: number; + emotion: string; + count: number; + from?: string; + to?: string; + }) { + return await this.commentService.findManyByInfo(info); + } }