From 8b33b3f11a0f8c30f454541fa9f5c18ef33726d1 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 14 Nov 2024 11:46:39 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20swagger?= =?UTF-8?q?=20url=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/configs/swagger.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/configs/swagger.config.ts b/packages/backend/src/configs/swagger.config.ts index c9d9dfd5..ad58834f 100644 --- a/packages/backend/src/configs/swagger.config.ts +++ b/packages/backend/src/configs/swagger.config.ts @@ -9,5 +9,5 @@ export function useSwagger(app: INestApplication) { .build(); const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, documentFactory); + SwaggerModule.setup('swagger', app, documentFactory); } From 6f19e72e93076061adf2e5d067bf61f1037795ca Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 16 Nov 2024 20:44:06 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9D=B4=EC=A0=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=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 --- packages/backend/src/chat/chat.controller.ts | 39 +++++++++++ packages/backend/src/chat/chat.gateway.ts | 19 +++-- packages/backend/src/chat/chat.module.ts | 4 +- packages/backend/src/chat/chat.service.ts | 69 +++++++++++++++++-- .../backend/src/chat/domain/chat.entity.ts | 2 +- packages/backend/src/chat/dto/chat.request.ts | 30 ++++++++ .../backend/src/chat/dto/chat.response.ts | 44 ++++++++++++ .../backend/src/common/dateEmbedded.entity.ts | 4 +- 8 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/chat/chat.controller.ts create mode 100644 packages/backend/src/chat/dto/chat.request.ts create mode 100644 packages/backend/src/chat/dto/chat.response.ts diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts new file mode 100644 index 00000000..f2a5452d --- /dev/null +++ b/packages/backend/src/chat/chat.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ChatService } from '@/chat/chat.service'; +import { ChatScrollRequest } from '@/chat/dto/chat.request'; +import { ChatScrollResponse } from '@/chat/dto/chat.response'; + +@Controller('chat') +export class ChatController { + constructor(private readonly chatService: ChatService) {} + + @ApiOperation({ + summary: '채팅 스크롤 조회 API', + description: '채팅을 스크롤하여 조회한다.', + }) + @ApiOkResponse({ + description: '스크롤 조회 성공', + type: ChatScrollResponse, + }) + @ApiBadRequestResponse({ + description: '스크롤 크기 100 초과', + example: { + message: 'pageSize should be less than 100', + error: 'Bad Request', + statusCode: 400, + }, + }) + @Get() + async findChatList(@Query() request: ChatScrollRequest) { + return await this.chatService.scrollNextChat( + request.stockId, + request.latestChatId, + request.pageSize, + ); + } +} diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 322f35e9..f5fc099e 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -67,18 +67,23 @@ export class ChatGateway implements OnGatewayConnection { async handleConnection(client: Socket) { const room = client.handshake.query.stockId; - if (!room || !(await this.stockService.checkStockExist(room as string))) { + if ( + !this.isString(room) || + !(await this.stockService.checkStockExist(room)) + ) { client.emit('error', 'Invalid stockId'); this.logger.warn(`client connected with invalid stockId: ${room}`); client.disconnect(); return; } - if (room) { - client.join(room); - const messages = await this.chatService.getChatList(room as string); - this.logger.info(`client joined room ${room}`); - client.emit('chat', messages); - } + client.join(room); + const messages = await this.chatService.scrollFirstChat(room); + this.logger.info(`client joined room ${room}`); + client.emit('chat', messages); + } + + private isString(value: string | string[] | undefined): value is string { + return typeof value === 'string'; } private toResponse(chat: Chat): chatResponse { diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 345a0f76..089b37e4 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -1,13 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SessionModule } from '@/auth/session.module'; +import { ChatController } from '@/chat/chat.controller'; import { ChatGateway } from '@/chat/chat.gateway'; +import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; import { StockModule } from '@/stock/stock.module'; -import { ChatService } from '@/chat/chat.service'; @Module({ imports: [TypeOrmModule.forFeature([Chat]), StockModule, SessionModule], + controllers: [ChatController], providers: [ChatGateway, ChatService], }) export class ChatModule {} diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 624618bd..bfdf5c28 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; +import { ChatScrollResponse } from '@/chat/dto/chat.response'; -interface ChatMessage { +export interface ChatMessage { message: string; stockId: string; } +const DEFAULT_PAGE_SIZE = 20; + @Injectable() export class ChatService { constructor(private readonly dataSource: DataSource) {} @@ -19,13 +22,69 @@ export class ChatService { }); } - async getChatList(stockId: string) { + async scrollFirstChat(stockId: string, scrollSize?: number) { + const result = await this.findFirstChatScroll(stockId, scrollSize); + return await this.toScrollResponse(result, scrollSize); + } + + async scrollNextChat( + stockId: string, + latestChatId?: number, + pageSize?: number, + ) { + const result = await this.findChatScroll(stockId, latestChatId, pageSize); + return await this.toScrollResponse(result, pageSize); + } + + private async toScrollResponse(result: Chat[], pageSize: number | undefined) { + const hasMore = + !!result && result.length > (pageSize ? pageSize : DEFAULT_PAGE_SIZE); + if (hasMore) { + result.pop(); + } + return new ChatScrollResponse(result, hasMore); + } + + private async findChatScroll( + stockId: string, + latestChatId?: number, + pageSize?: number, + ) { + if (!latestChatId) { + return await this.findFirstChatScroll(stockId, pageSize); + } else { + return await this.findNextChatScroll(stockId, latestChatId, pageSize); + } + } + + private async findFirstChatScroll(stockId: string, pageSize?: number) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - console.log(stockId); + if (!pageSize) { + pageSize = DEFAULT_PAGE_SIZE; + } return queryBuilder .where('chat.stock_id = :stockId', { stockId }) - .orderBy('chat.created_at', 'DESC') - .limit(100) + .orderBy('chat.id', 'DESC') + .limit(pageSize + 1) + .getMany(); + } + + private async findNextChatScroll( + stockId: string, + latestChatId: number, + pageSize?: number, + ) { + const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); + if (!pageSize) { + pageSize = DEFAULT_PAGE_SIZE; + } + return queryBuilder + .where('chat.stock_id = :stockId and chat.id < :latestChatId', { + stockId, + latestChatId, + }) + .orderBy('chat.id', 'DESC') + .limit(pageSize + 1) .getMany(); } } diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index 9406a2e4..13800bff 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -33,5 +33,5 @@ export class Chat { likeCount: number = 0; @Column(() => DateEmbedded, { prefix: '' }) - date?: DateEmbedded; + date: DateEmbedded; } diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts new file mode 100644 index 00000000..f3260509 --- /dev/null +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class ChatScrollRequest { + @ApiProperty({ + description: '종목 주식 id(종목방 id)', + example: 'A005930', + }) + @IsString() + readonly stockId: string; + + @ApiProperty({ + description: '최신 채팅 id', + example: 99999, + required: false, + }) + @IsOptional() + @IsNumber() + readonly latestChatId?: number; + + @ApiProperty({ + description: '페이지 크기', + example: 20, + default: 20, + required: false, + }) + @IsOptional() + @IsNumber() + readonly pageSize?: number; +} diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts new file mode 100644 index 00000000..48aacb0d --- /dev/null +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Chat } from '@/chat/domain/chat.entity'; +import { ChatType } from '@/chat/domain/chatType.enum'; + +interface ChatResponse { + id: number; + likeCount: number; + message: string; + type: string; + createdAt: Date; +} + +export class ChatScrollResponse { + @ApiProperty({ + description: '다음 페이지가 있는지 여부', + example: true, + }) + readonly hasMore: boolean; + + @ApiProperty({ + description: '채팅 목록', + example: [ + { + id: 1, + likeCount: 0, + message: '안녕하세요', + type: ChatType.NORMAL, + createdAt: new Date(), + }, + ], + }) + readonly chats: ChatResponse[]; + + constructor(chats: Chat[], hasMore: boolean) { + this.chats = chats.map((chat) => ({ + id: chat.id, + likeCount: chat.likeCount, + message: chat.message, + type: chat.type, + createdAt: chat.date!.createdAt, + })); + this.hasMore = hasMore; + } +} diff --git a/packages/backend/src/common/dateEmbedded.entity.ts b/packages/backend/src/common/dateEmbedded.entity.ts index 2a0c9dd8..a16c1cf9 100644 --- a/packages/backend/src/common/dateEmbedded.entity.ts +++ b/packages/backend/src/common/dateEmbedded.entity.ts @@ -2,8 +2,8 @@ import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; export class DateEmbedded { @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) - createdAt?: Date; + createdAt: Date; @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) - updatedAt?: Date; + updatedAt: Date; } From 8c2796edc5bb29f4a153fc67b64aa527e39f77d8 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 16 Nov 2024 20:44:31 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20feat:=20class=20dto=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=ED=83=80=EC=9E=85=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 311bcdb9..73012397 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -11,7 +11,12 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); const store = app.get(MEMORY_STORE); app.use(session({ ...sessionConfig, store })); - app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); useSwagger(app); app.use(passport.initialize()); app.use(passport.session()); From f6369d8e4fc8d84b0952800375cf8a45c0254652 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 17 Nov 2024 20:33:07 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20100=EC=9D=84=20?= =?UTF-8?q?=EB=84=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index bfdf5c28..ef262e7b 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; @@ -23,6 +23,7 @@ export class ChatService { } async scrollFirstChat(stockId: string, scrollSize?: number) { + this.validatePageSize(scrollSize); const result = await this.findFirstChatScroll(stockId, scrollSize); return await this.toScrollResponse(result, scrollSize); } @@ -32,10 +33,17 @@ export class ChatService { latestChatId?: number, pageSize?: number, ) { + this.validatePageSize(pageSize); const result = await this.findChatScroll(stockId, latestChatId, pageSize); return await this.toScrollResponse(result, pageSize); } + private validatePageSize(scrollSize?: number) { + if (scrollSize && scrollSize > 100) { + throw new BadRequestException('pageSize should be less than 100'); + } + } + private async toScrollResponse(result: Chat[], pageSize: number | undefined) { const hasMore = !!result && result.length > (pageSize ? pageSize : DEFAULT_PAGE_SIZE); From e2e3938de1a0e4a45744b7cd9ef6b2483450d3fd Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 17 Nov 2024 20:41:22 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=85=20test:=20100=EA=B0=9C=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/chat/chat.service.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/backend/src/chat/chat.service.spec.ts diff --git a/packages/backend/src/chat/chat.service.spec.ts b/packages/backend/src/chat/chat.service.spec.ts new file mode 100644 index 00000000..7d181061 --- /dev/null +++ b/packages/backend/src/chat/chat.service.spec.ts @@ -0,0 +1,23 @@ +import { DataSource } from 'typeorm'; +import { ChatService } from '@/chat/chat.service'; +import { createDataSourceMock } from '@/user/user.service.spec'; + +describe('ChatService 테스트', () => { + test('첫 스크롤을 조회시 100개 이상 조회하면 예외가 발생한다.', async () => { + const dataSource = createDataSourceMock({}); + const chatService = new ChatService(dataSource as DataSource); + + await expect(() => + chatService.scrollNextChat('A005930', 1, 101), + ).rejects.toThrow('pageSize should be less than 100'); + }); + + test('100개 이상의 채팅을 조회하려 하면 예외가 발생한다.', async () => { + const dataSource = createDataSourceMock({}); + const chatService = new ChatService(dataSource as DataSource); + + await expect(() => + chatService.scrollFirstChat('A005930', 101), + ).rejects.toThrow('pageSize should be less than 100'); + }); +}); From 42bd4adb327d2321f3fbc8c282eec86fc9661e5b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 14 Nov 2024 11:46:39 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20swagger?= =?UTF-8?q?=20url=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/configs/swagger.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/configs/swagger.config.ts b/packages/backend/src/configs/swagger.config.ts index c9d9dfd5..ad58834f 100644 --- a/packages/backend/src/configs/swagger.config.ts +++ b/packages/backend/src/configs/swagger.config.ts @@ -9,5 +9,5 @@ export function useSwagger(app: INestApplication) { .build(); const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, documentFactory); + SwaggerModule.setup('swagger', app, documentFactory); } From 00a0c0623450eb6cee56b67b37ad4828c0916954 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 16 Nov 2024 20:44:06 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9D=B4=EC=A0=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=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 --- packages/backend/src/chat/chat.controller.ts | 39 +++++++++++ packages/backend/src/chat/chat.gateway.ts | 19 +++-- packages/backend/src/chat/chat.module.ts | 4 +- packages/backend/src/chat/chat.service.ts | 69 +++++++++++++++++-- .../backend/src/chat/domain/chat.entity.ts | 2 +- packages/backend/src/chat/dto/chat.request.ts | 30 ++++++++ .../backend/src/chat/dto/chat.response.ts | 44 ++++++++++++ .../backend/src/common/dateEmbedded.entity.ts | 4 +- 8 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/chat/chat.controller.ts create mode 100644 packages/backend/src/chat/dto/chat.request.ts create mode 100644 packages/backend/src/chat/dto/chat.response.ts diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts new file mode 100644 index 00000000..f2a5452d --- /dev/null +++ b/packages/backend/src/chat/chat.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ChatService } from '@/chat/chat.service'; +import { ChatScrollRequest } from '@/chat/dto/chat.request'; +import { ChatScrollResponse } from '@/chat/dto/chat.response'; + +@Controller('chat') +export class ChatController { + constructor(private readonly chatService: ChatService) {} + + @ApiOperation({ + summary: '채팅 스크롤 조회 API', + description: '채팅을 스크롤하여 조회한다.', + }) + @ApiOkResponse({ + description: '스크롤 조회 성공', + type: ChatScrollResponse, + }) + @ApiBadRequestResponse({ + description: '스크롤 크기 100 초과', + example: { + message: 'pageSize should be less than 100', + error: 'Bad Request', + statusCode: 400, + }, + }) + @Get() + async findChatList(@Query() request: ChatScrollRequest) { + return await this.chatService.scrollNextChat( + request.stockId, + request.latestChatId, + request.pageSize, + ); + } +} diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 322f35e9..f5fc099e 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -67,18 +67,23 @@ export class ChatGateway implements OnGatewayConnection { async handleConnection(client: Socket) { const room = client.handshake.query.stockId; - if (!room || !(await this.stockService.checkStockExist(room as string))) { + if ( + !this.isString(room) || + !(await this.stockService.checkStockExist(room)) + ) { client.emit('error', 'Invalid stockId'); this.logger.warn(`client connected with invalid stockId: ${room}`); client.disconnect(); return; } - if (room) { - client.join(room); - const messages = await this.chatService.getChatList(room as string); - this.logger.info(`client joined room ${room}`); - client.emit('chat', messages); - } + client.join(room); + const messages = await this.chatService.scrollFirstChat(room); + this.logger.info(`client joined room ${room}`); + client.emit('chat', messages); + } + + private isString(value: string | string[] | undefined): value is string { + return typeof value === 'string'; } private toResponse(chat: Chat): chatResponse { diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 345a0f76..089b37e4 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -1,13 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SessionModule } from '@/auth/session.module'; +import { ChatController } from '@/chat/chat.controller'; import { ChatGateway } from '@/chat/chat.gateway'; +import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; import { StockModule } from '@/stock/stock.module'; -import { ChatService } from '@/chat/chat.service'; @Module({ imports: [TypeOrmModule.forFeature([Chat]), StockModule, SessionModule], + controllers: [ChatController], providers: [ChatGateway, ChatService], }) export class ChatModule {} diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 624618bd..bfdf5c28 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; +import { ChatScrollResponse } from '@/chat/dto/chat.response'; -interface ChatMessage { +export interface ChatMessage { message: string; stockId: string; } +const DEFAULT_PAGE_SIZE = 20; + @Injectable() export class ChatService { constructor(private readonly dataSource: DataSource) {} @@ -19,13 +22,69 @@ export class ChatService { }); } - async getChatList(stockId: string) { + async scrollFirstChat(stockId: string, scrollSize?: number) { + const result = await this.findFirstChatScroll(stockId, scrollSize); + return await this.toScrollResponse(result, scrollSize); + } + + async scrollNextChat( + stockId: string, + latestChatId?: number, + pageSize?: number, + ) { + const result = await this.findChatScroll(stockId, latestChatId, pageSize); + return await this.toScrollResponse(result, pageSize); + } + + private async toScrollResponse(result: Chat[], pageSize: number | undefined) { + const hasMore = + !!result && result.length > (pageSize ? pageSize : DEFAULT_PAGE_SIZE); + if (hasMore) { + result.pop(); + } + return new ChatScrollResponse(result, hasMore); + } + + private async findChatScroll( + stockId: string, + latestChatId?: number, + pageSize?: number, + ) { + if (!latestChatId) { + return await this.findFirstChatScroll(stockId, pageSize); + } else { + return await this.findNextChatScroll(stockId, latestChatId, pageSize); + } + } + + private async findFirstChatScroll(stockId: string, pageSize?: number) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - console.log(stockId); + if (!pageSize) { + pageSize = DEFAULT_PAGE_SIZE; + } return queryBuilder .where('chat.stock_id = :stockId', { stockId }) - .orderBy('chat.created_at', 'DESC') - .limit(100) + .orderBy('chat.id', 'DESC') + .limit(pageSize + 1) + .getMany(); + } + + private async findNextChatScroll( + stockId: string, + latestChatId: number, + pageSize?: number, + ) { + const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); + if (!pageSize) { + pageSize = DEFAULT_PAGE_SIZE; + } + return queryBuilder + .where('chat.stock_id = :stockId and chat.id < :latestChatId', { + stockId, + latestChatId, + }) + .orderBy('chat.id', 'DESC') + .limit(pageSize + 1) .getMany(); } } diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index 9406a2e4..13800bff 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -33,5 +33,5 @@ export class Chat { likeCount: number = 0; @Column(() => DateEmbedded, { prefix: '' }) - date?: DateEmbedded; + date: DateEmbedded; } diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts new file mode 100644 index 00000000..f3260509 --- /dev/null +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class ChatScrollRequest { + @ApiProperty({ + description: '종목 주식 id(종목방 id)', + example: 'A005930', + }) + @IsString() + readonly stockId: string; + + @ApiProperty({ + description: '최신 채팅 id', + example: 99999, + required: false, + }) + @IsOptional() + @IsNumber() + readonly latestChatId?: number; + + @ApiProperty({ + description: '페이지 크기', + example: 20, + default: 20, + required: false, + }) + @IsOptional() + @IsNumber() + readonly pageSize?: number; +} diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts new file mode 100644 index 00000000..48aacb0d --- /dev/null +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Chat } from '@/chat/domain/chat.entity'; +import { ChatType } from '@/chat/domain/chatType.enum'; + +interface ChatResponse { + id: number; + likeCount: number; + message: string; + type: string; + createdAt: Date; +} + +export class ChatScrollResponse { + @ApiProperty({ + description: '다음 페이지가 있는지 여부', + example: true, + }) + readonly hasMore: boolean; + + @ApiProperty({ + description: '채팅 목록', + example: [ + { + id: 1, + likeCount: 0, + message: '안녕하세요', + type: ChatType.NORMAL, + createdAt: new Date(), + }, + ], + }) + readonly chats: ChatResponse[]; + + constructor(chats: Chat[], hasMore: boolean) { + this.chats = chats.map((chat) => ({ + id: chat.id, + likeCount: chat.likeCount, + message: chat.message, + type: chat.type, + createdAt: chat.date!.createdAt, + })); + this.hasMore = hasMore; + } +} diff --git a/packages/backend/src/common/dateEmbedded.entity.ts b/packages/backend/src/common/dateEmbedded.entity.ts index 2a0c9dd8..a16c1cf9 100644 --- a/packages/backend/src/common/dateEmbedded.entity.ts +++ b/packages/backend/src/common/dateEmbedded.entity.ts @@ -2,8 +2,8 @@ import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; export class DateEmbedded { @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) - createdAt?: Date; + createdAt: Date; @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) - updatedAt?: Date; + updatedAt: Date; } From d5040ccf189dcb6bbb0177fbffc27f61d6c703cd Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 16 Nov 2024 20:44:31 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=A8=20feat:=20class=20dto=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=ED=83=80=EC=9E=85=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 14613072..0be16e68 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -13,7 +13,12 @@ async function bootstrap() { app.setGlobalPrefix('api'); app.use(session({ ...sessionConfig, store })); - app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); useSwagger(app); app.use(passport.initialize()); app.use(passport.session()); From b003c024d346efe4e5cee609e127d1866c6cad1d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 18 Nov 2024 21:16:25 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=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 --- .../backend/src/stock/dto/stock.Response.ts | 39 +++++++++++++++++++ packages/backend/src/stock/stock.service.ts | 16 +++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/stock/dto/stock.Response.ts b/packages/backend/src/stock/dto/stock.Response.ts index c5139450..6ac76f31 100644 --- a/packages/backend/src/stock/dto/stock.Response.ts +++ b/packages/backend/src/stock/dto/stock.Response.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; +import { Stock } from '@/stock/domain/stock.entity'; export class StockViewsResponse { @ApiProperty({ @@ -33,32 +34,70 @@ export class StocksResponse { example: 'A005930', }) id: string; + @ApiProperty({ description: '주식 종목 이름', example: '삼성전자', }) name: string; + @ApiProperty({ description: '주식 현재가', example: 100000.0, }) @Transform(({ value }) => parseFloat(value)) currentPrice: number; + @ApiProperty({ description: '주식 변동률', example: 2.5, }) @Transform(({ value }) => parseFloat(value)) changeRate: number; + @ApiProperty({ description: '주식 거래량', example: 500000, }) @Transform(({ value }) => parseInt(value)) volume: number; + @ApiProperty({ description: '주식 시가 총액', example: '500000000000.00', }) marketCap: string; } + +class StockSearchResult { + @ApiProperty({ + description: '주식 종목 코드', + example: 'A005930', + }) + id: string; + + @ApiProperty({ + description: '주식 종목 이름', + example: '삼성전자', + }) + name: string; +} + +export class StockSearchResponse { + @ApiProperty({ + description: '주식 검색 결과', + type: [StockSearchResult], + }) + searchResults: StockSearchResult[]; + + constructor(stocks?: Stock[]) { + if (!stocks) { + this.searchResults = []; + return; + } + this.searchResults = stocks.map((stock) => ({ + id: stock.id as string, + name: stock.name as string, + })); + } +} diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 1f23e48f..4b63264a 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -3,7 +3,7 @@ import { plainToInstance } from 'class-transformer'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { Stock } from './domain/stock.entity'; -import { StocksResponse } from './dto/stock.Response'; +import { StockSearchResponse, StocksResponse } from './dto/stock.Response'; import { UserStock } from '@/stock/domain/userStock.entity'; @Injectable() @@ -68,6 +68,20 @@ export class StockService { }); } + async searchStock(stockName: string) { + const queryBuilder = this.datasource + .getRepository(Stock) + .createQueryBuilder(); + const result = await queryBuilder + .where('stock.stock_name LIKE :name', { + isTrading: true, + name: `%${stockName}%`, + }) + .limit(10) + .getMany(); + return new StockSearchResponse(result); + } + validateUserStock(userId: number, userStock: UserStock | null) { if (!userStock) { throw new BadRequestException('user stock not found'); From 95af1ae19a279d1845b59594aee56782f6544fb8 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 18 Nov 2024 15:59:19 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/decorator/stock.decorator.ts | 11 +++------- .../backend/src/stock/dto/stock.request.ts | 12 +++++++++++ .../{stock.Response.ts => stock.response.ts} | 0 .../backend/src/stock/stock.controller.ts | 20 ++++++++++++++++++- packages/backend/src/stock/stock.service.ts | 2 +- 5 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/stock/dto/stock.request.ts rename packages/backend/src/stock/dto/{stock.Response.ts => stock.response.ts} (100%) diff --git a/packages/backend/src/stock/decorator/stock.decorator.ts b/packages/backend/src/stock/decorator/stock.decorator.ts index 1412860e..4e2f3469 100644 --- a/packages/backend/src/stock/decorator/stock.decorator.ts +++ b/packages/backend/src/stock/decorator/stock.decorator.ts @@ -1,12 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { - Query, - ParseIntPipe, - DefaultValuePipe, - applyDecorators, -} from '@nestjs/common'; -import { ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { StocksResponse } from '../dto/stock.Response'; +import { applyDecorators, DefaultValuePipe, ParseIntPipe, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiResponse } from "@nestjs/swagger"; +import { StocksResponse } from "../dto/stock.response"; export function LimitQuery(defaultValue = 5): ParameterDecorator { return Query('limit', new DefaultValuePipe(defaultValue), ParseIntPipe); diff --git a/packages/backend/src/stock/dto/stock.request.ts b/packages/backend/src/stock/dto/stock.request.ts new file mode 100644 index 00000000..255e4481 --- /dev/null +++ b/packages/backend/src/stock/dto/stock.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class StockSearchRequest { + @ApiProperty({ + description: '검색할 단어', + example: '삼성', + }) + @IsNotEmpty() + @IsString() + name: string; +} diff --git a/packages/backend/src/stock/dto/stock.Response.ts b/packages/backend/src/stock/dto/stock.response.ts similarity index 100% rename from packages/backend/src/stock/dto/stock.Response.ts rename to packages/backend/src/stock/dto/stock.response.ts diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 042353a3..ed85f3c1 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -32,7 +32,10 @@ import { StockDetailService } from './stockDetail.service'; import SessionGuard from '@/auth/session/session.guard'; import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; -import { StockViewsResponse } from '@/stock/dto/stock.Response'; +import { + StockSearchResponse, + StockViewsResponse, +} from '@/stock/dto/stock.response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { UserStockDeleteRequest, @@ -43,6 +46,7 @@ import { UserStockResponse, } from '@/stock/dto/userStock.response'; import { User } from '@/user/domain/user.entity'; +import { StockSearchRequest } from '@/stock/dto/stock.request'; @Controller('stock') export class StockController { @@ -150,6 +154,20 @@ export class StockController { return new UserStockOwnerResponse(result); } + @ApiOperation({ + summary: '주식 검색 API', + description: '주식 이름에 매칭되는 주식을 검색', + }) + @ApiOkResponse({ + description: '검색 완료', + type: StockSearchResponse, + }) + @Get() + async searchStock(@Query() request: StockSearchRequest) { + console.log(request.name); + return await this.stockService.searchStock(request.name); + } + @Get(':stockId/minutely') @ApiGetStockData('주식 분 단위 데이터 조회 API', '분') async getStockDataMinutely( diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 4b63264a..154eb9d6 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -3,7 +3,7 @@ import { plainToInstance } from 'class-transformer'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { Stock } from './domain/stock.entity'; -import { StockSearchResponse, StocksResponse } from './dto/stock.Response'; +import { StockSearchResponse, StocksResponse } from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; @Injectable() From 35a2afc2a6398533d9639cd28bcedb84e5b29674 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 18 Nov 2024 21:25:26 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20feat:=20swagger=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B2=BD=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 --- packages/backend/src/configs/swagger.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/configs/swagger.config.ts b/packages/backend/src/configs/swagger.config.ts index ad58834f..c9d9dfd5 100644 --- a/packages/backend/src/configs/swagger.config.ts +++ b/packages/backend/src/configs/swagger.config.ts @@ -9,5 +9,5 @@ export function useSwagger(app: INestApplication) { .build(); const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('swagger', app, documentFactory); + SwaggerModule.setup('api', app, documentFactory); }