From 72b32d20e41e2dcd17325a86a3734acfe1bee635 Mon Sep 17 00:00:00 2001 From: "sw_vi.xi" Date: Sun, 2 Nov 2025 16:37:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9E=84=EB=B0=95=ED=95=9C?= =?UTF-8?q?=EC=9D=98=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=A2=85=EB=A3=8C=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=EC=9E=91=EC=9D=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/get-article-list.request.dto.ts | 8 ++++---- .../domain/repository/article.query.repository.ts | 2 +- .../article.query.repository.impl.ts | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/article/query/application/article-list/dto/get-article-list.request.dto.ts b/src/article/query/application/article-list/dto/get-article-list.request.dto.ts index 3928f29..d3292e0 100644 --- a/src/article/query/application/article-list/dto/get-article-list.request.dto.ts +++ b/src/article/query/application/article-list/dto/get-article-list.request.dto.ts @@ -26,13 +26,13 @@ export class GetArticleListRequestDto { @ApiProperty({ description: '정렬 기준', - example: 'registrationEndAt', + example: 'registrationStartAt', required: false, - enum: ['registrationEndAt', 'scrapCount', 'viewCount'], + enum: ['registrationStartAt', 'scrapCount', 'viewCount'], }) @IsOptional() - @IsIn(['registrationEndAt', 'scrapCount', 'viewCount']) - sortBy?: 'registrationEndAt' | 'scrapCount' | 'viewCount'; + @IsIn(['registrationStartAt', 'scrapCount', 'viewCount']) + sortBy?: 'registrationStartAt' | 'scrapCount' | 'viewCount'; @ApiProperty({ description: '페이지 번호', diff --git a/src/article/query/domain/repository/article.query.repository.ts b/src/article/query/domain/repository/article.query.repository.ts index 7f13f74..5a3d4fd 100644 --- a/src/article/query/domain/repository/article.query.repository.ts +++ b/src/article/query/domain/repository/article.query.repository.ts @@ -6,7 +6,7 @@ export interface ArticleQueryRepository { findAllByCriteria( tags?: string[], isFinished?: boolean, - sortBy?: 'registrationEndAt' | 'scrapCount' | 'viewCount', + sortBy?: 'registrationStartAt' | 'scrapCount' | 'viewCount', page?: number, limit?: number, ): Promise; diff --git a/src/article/query/infrastructure/article.query.repository.impl.ts b/src/article/query/infrastructure/article.query.repository.impl.ts index 62ac665..fa23196 100644 --- a/src/article/query/infrastructure/article.query.repository.impl.ts +++ b/src/article/query/infrastructure/article.query.repository.impl.ts @@ -86,14 +86,14 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository { async findAllByCriteria( tags?: string[], isFinished?: boolean, - sortBy?: 'registrationEndAt' | 'scrapCount' | 'viewCount', + sortBy?: 'registrationStartAt' | 'scrapCount' | 'viewCount', // page?: number, // limit?: number, ): Promise { const query = this.ormRepository.createQueryBuilder('a'); // 런타임 안전 가드: 허용되지 않은 sortBy 값이 오면 기본 정렬로 처리 - const safeSortBy: 'registrationEndAt' | 'scrapCount' | 'viewCount' | undefined = - sortBy === 'registrationEndAt' || sortBy === 'scrapCount' || sortBy === 'viewCount' ? sortBy : undefined; + const safeSortBy: 'registrationStartAt' | 'scrapCount' | 'viewCount' | undefined = + sortBy === 'registrationStartAt' || sortBy === 'scrapCount' || sortBy === 'viewCount' ? sortBy : undefined; query .select([ 'a.id', @@ -138,15 +138,15 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository { // isFinished가 true이거나 undefined면 모든 것을 조회 (필터링 없음) // 정렬 - if (!safeSortBy || safeSortBy === 'registrationEndAt') { - // 현재 시간에 가장 가까운 순서 (미래 우선), 값 없으면 endAt 사용 + if (!safeSortBy || safeSortBy === 'registrationStartAt') { + // 현재 시간에 가장 가까운 순서 (미래 우선), 값 없으면 startAt 사용 query.orderBy([ { - [sql`CASE WHEN COALESCE(a.registration_end_at, a.end_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]: + [sql`CASE WHEN COALESCE(a.registration_start_at, a.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]: 'asc', }, { - [sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_end_at, a.end_at), NOW()))` as unknown as string]: + [sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_start_at, a.start_at), NOW()))` as unknown as string]: 'asc', }, ]); From 7bffdc62b8478bb33166e5160e91019d51d50101 Mon Sep 17 00:00:00 2001 From: "sw_vi.xi" Date: Sun, 2 Nov 2025 17:16:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/get-article-search.request.dto.ts | 13 +++++ .../get-article-search.use-case.ts | 17 ++++++ src/article/query/article.query.module.ts | 3 +- .../repository/article.query.repository.ts | 1 + .../article.query.repository.impl.ts | 58 +++++++++++++++++++ .../presentation/article.query.controller.ts | 9 +++ .../query/presentation/article.query.docs.ts | 21 ++++++- 7 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/article/query/application/article-search/dto/get-article-search.request.dto.ts create mode 100644 src/article/query/application/article-search/get-article-search.use-case.ts diff --git a/src/article/query/application/article-search/dto/get-article-search.request.dto.ts b/src/article/query/application/article-search/dto/get-article-search.request.dto.ts new file mode 100644 index 0000000..87d5219 --- /dev/null +++ b/src/article/query/application/article-search/dto/get-article-search.request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetArticleSearchRequestDto { + @ApiProperty({ + description: '검색어', + example: '대동제', + required: true, + }) + @IsString() + @IsNotEmpty() + keyword: string; +} diff --git a/src/article/query/application/article-search/get-article-search.use-case.ts b/src/article/query/application/article-search/get-article-search.use-case.ts new file mode 100644 index 0000000..996e702 --- /dev/null +++ b/src/article/query/application/article-search/get-article-search.use-case.ts @@ -0,0 +1,17 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ARTICLE_QUERY_REPOSITORY, ArticleQueryRepository } from '../../domain/repository/article.query.repository'; +import { ArticleModel } from '../../domain/article.model'; +import { GetArticleSearchRequestDto } from './dto/get-article-search.request.dto'; + +@Injectable() +export class GetArticleSearchUseCase { + constructor( + @Inject(ARTICLE_QUERY_REPOSITORY) + private readonly articleQueryRepository: ArticleQueryRepository, + ) {} + + async execute(reqDto: GetArticleSearchRequestDto): Promise { + const { keyword } = reqDto; + return await this.articleQueryRepository.searchByKeyword(keyword); + } +} diff --git a/src/article/query/article.query.module.ts b/src/article/query/article.query.module.ts index 5bf8df6..62f25b4 100644 --- a/src/article/query/article.query.module.ts +++ b/src/article/query/article.query.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { GetArticleDetailUseCase } from './application/article-detail/get-article-detail.use-case'; import { GetArticleListUseCase } from './application/article-list/get-article-list.use-case'; +import { GetArticleSearchUseCase } from './application/article-search/get-article-search.use-case'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { ArticleEntity } from '../command/infrastructure/article.entity'; import { ArticleQueryController } from './presentation/article.query.controller'; @@ -8,7 +9,7 @@ import { ARTICLE_QUERY_REPOSITORY } from './domain/repository/article.query.repo import { ArticleQueryRepositoryImpl } from './infrastructure/article.query.repository.impl'; import { MediaEntity } from 'src/media/command/infrastructure/media.entity'; -const usecases = [GetArticleDetailUseCase, GetArticleListUseCase]; +const usecases = [GetArticleDetailUseCase, GetArticleListUseCase, GetArticleSearchUseCase]; @Module({ imports: [MikroOrmModule.forFeature([ArticleEntity, MediaEntity])], diff --git a/src/article/query/domain/repository/article.query.repository.ts b/src/article/query/domain/repository/article.query.repository.ts index 5a3d4fd..2a59d22 100644 --- a/src/article/query/domain/repository/article.query.repository.ts +++ b/src/article/query/domain/repository/article.query.repository.ts @@ -10,6 +10,7 @@ export interface ArticleQueryRepository { page?: number, limit?: number, ): Promise; + searchByKeyword(keyword: string): Promise; } export const ARTICLE_QUERY_REPOSITORY = Symbol('ARTICLE_QUERY_REPOSITORY'); diff --git a/src/article/query/infrastructure/article.query.repository.impl.ts b/src/article/query/infrastructure/article.query.repository.impl.ts index fa23196..1605692 100644 --- a/src/article/query/infrastructure/article.query.repository.impl.ts +++ b/src/article/query/infrastructure/article.query.repository.impl.ts @@ -175,4 +175,62 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository { return result; } + + async searchByKeyword(keyword: string): Promise { + const query = this.ormRepository.createQueryBuilder('a'); + query + .select([ + 'a.id', + 'a.title', + 'a.organization', + 'a.startAt', + 'a.endAt', + 'a.registrationStartAt', + 'a.registrationEndAt', + 'a.scrapCount', + 'a.viewCount', + sql`( + SELECT m.media_path + FROM media m + WHERE m.article_id = a.id AND m.order = 0 + LIMIT 1 + ) AS thumbnailPath`, + sql`group_concat(distinct tag.name) as tags`, + ]) + .leftJoin('a.tags', 'tag') + .groupBy('a.id'); + + // 검색어 조건: 제목에서 검색 + query.andWhere(`a.title LIKE ?`, [`%${keyword}%`]); + + // 정렬: 현재 시간에 가장 가까운 순서 (미래 우선) + query.orderBy([ + { + [sql`CASE WHEN COALESCE(a.registration_start_at, a.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]: + 'asc', + }, + { + [sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_start_at, a.start_at), NOW()))` as unknown as string]: + 'asc', + }, + ]); + + const articleEntities = await query.execute(); + + const result = articleEntities.map((articleEntity) => ({ + id: articleEntity.id, + title: articleEntity.title, + organization: articleEntity.organization, + scrapCount: articleEntity.scrapCount, + viewCount: articleEntity.viewCount, + thumbnailPath: articleEntity.thumbnailPath, + tags: articleEntity.tags ? (articleEntity.tags as unknown as string).split(',') : [], + startAt: articleEntity.startAt, + endAt: articleEntity.endAt, + registrationStartAt: articleEntity.registrationStartAt, + registrationEndAt: articleEntity.registrationEndAt, + })); + + return result; + } } diff --git a/src/article/query/presentation/article.query.controller.ts b/src/article/query/presentation/article.query.controller.ts index c6fd795..9a0a1e6 100644 --- a/src/article/query/presentation/article.query.controller.ts +++ b/src/article/query/presentation/article.query.controller.ts @@ -2,10 +2,12 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { GetArticleDetailUseCase } from '../application/article-detail/get-article-detail.use-case'; import { GetArticleListUseCase } from '../application/article-list/get-article-list.use-case'; +import { GetArticleSearchUseCase } from '../application/article-search/get-article-search.use-case'; import { ArticleDetailModel } from '../domain/article-detail.model'; import { ArticleModel } from '../domain/article.model'; import { ArticleQueryDocs } from './article.query.docs'; import { GetArticleListRequestDto } from '../application/article-list/dto/get-article-list.request.dto'; +import { GetArticleSearchRequestDto } from '../application/article-search/dto/get-article-search.request.dto'; @ApiTags('article') @Controller('article') @@ -13,8 +15,15 @@ export class ArticleQueryController { constructor( private readonly getArticleDetailUseCase: GetArticleDetailUseCase, private readonly getArticleListUseCase: GetArticleListUseCase, + private readonly getArticleSearchUseCase: GetArticleSearchUseCase, ) {} + @Get('search') + @ArticleQueryDocs('search') + async getArticleSearch(@Query() reqDto: GetArticleSearchRequestDto): Promise { + return await this.getArticleSearchUseCase.execute(reqDto); + } + @Get(':id') @ArticleQueryDocs('detail') async getArticleDetail(@Param('id') articleId: string): Promise { diff --git a/src/article/query/presentation/article.query.docs.ts b/src/article/query/presentation/article.query.docs.ts index b0917c8..c1a98bc 100644 --- a/src/article/query/presentation/article.query.docs.ts +++ b/src/article/query/presentation/article.query.docs.ts @@ -8,8 +8,9 @@ import { } from '@nestjs/swagger'; import { createDocs } from 'src/shared/core/presentation/base.docs'; import { ArticleDetailModel } from '../domain/article-detail.model'; +import { ArticleModel } from '../domain/article.model'; -export type ArticleQueryEndpoint = 'list' | 'detail'; +export type ArticleQueryEndpoint = 'list' | 'detail' | 'search'; export const ArticleQueryDocs = createDocs({ list: () => @@ -44,4 +45,22 @@ export const ArticleQueryDocs = createDocs({ description: '게시글을 찾을 수 없음', }), ), + + search: () => + applyDecorators( + ApiOperation({ + summary: '게시글 검색', + description: '제목을 기준으로 게시글을 검색합니다.', + }), + ApiOkResponse({ + description: '게시글 검색 성공', + type: [ArticleModel], + }), + ApiBadRequestResponse({ + description: '잘못된 요청', + }), + ApiInternalServerErrorResponse({ + description: '서버 오류', + }), + ), });