Skip to content

Commit

Permalink
Feature/#184 - 주식을 한글로 검색할 수 있다. (#191)
Browse files Browse the repository at this point in the history
  • Loading branch information
xjfcnfw3 authored Nov 19, 2024
2 parents 613e2d9 + 35a2afc commit 65cbb9d
Show file tree
Hide file tree
Showing 15 changed files with 321 additions and 28 deletions.
39 changes: 39 additions & 0 deletions packages/backend/src/chat/chat.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
19 changes: 12 additions & 7 deletions packages/backend/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/backend/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
23 changes: 23 additions & 0 deletions packages/backend/src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
79 changes: 73 additions & 6 deletions packages/backend/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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';

interface ChatMessage {
export interface ChatMessage {
message: string;
stockId: string;
}

const DEFAULT_PAGE_SIZE = 20;

@Injectable()
export class ChatService {
constructor(private readonly dataSource: DataSource) {}
Expand All @@ -19,13 +22,77 @@ export class ChatService {
});
}

async getChatList(stockId: string) {
async scrollFirstChat(stockId: string, scrollSize?: number) {
this.validatePageSize(scrollSize);
const result = await this.findFirstChatScroll(stockId, scrollSize);
return await this.toScrollResponse(result, scrollSize);
}

async scrollNextChat(
stockId: string,
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);
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();
}
}
2 changes: 1 addition & 1 deletion packages/backend/src/chat/domain/chat.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ export class Chat {
likeCount: number = 0;

@Column(() => DateEmbedded, { prefix: '' })
date?: DateEmbedded;
date: DateEmbedded;
}
30 changes: 30 additions & 0 deletions packages/backend/src/chat/dto/chat.request.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions packages/backend/src/chat/dto/chat.response.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 2 additions & 2 deletions packages/backend/src/common/dateEmbedded.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 6 additions & 1 deletion packages/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
11 changes: 3 additions & 8 deletions packages/backend/src/stock/decorator/stock.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/stock/dto/stock.request.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 65cbb9d

Please sign in to comment.