From 7191e4acdf9ecf3eaa8e9a2d42de642152dda2d5 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 18:24:53 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20feat:=20limitQuery=20=EB=8D=B0?= =?UTF-8?q?=EC=BD=94=EB=A0=88=EC=9D=B4=ED=84=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/decorator/stock.decorator.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/backend/src/stock/decorator/stock.decorator.ts diff --git a/packages/backend/src/stock/decorator/stock.decorator.ts b/packages/backend/src/stock/decorator/stock.decorator.ts new file mode 100644 index 00000000..161bfe3f --- /dev/null +++ b/packages/backend/src/stock/decorator/stock.decorator.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common'; + +export function LimitQuery(defaultValue = 5): ParameterDecorator { + return Query('limit', new DefaultValuePipe(defaultValue), ParseIntPipe); +} From a451fd39c9a4e9affa214d4da171ab1c513847f3 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 18:33:32 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/domain/stockDetail.entity.ts | 1 + .../src/stock/domain/stockLiveData.entity.ts | 4 +-- .../backend/src/stock/stock.controller.ts | 6 ++++ packages/backend/src/stock/stock.service.ts | 31 +++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/stock/domain/stockDetail.entity.ts b/packages/backend/src/stock/domain/stockDetail.entity.ts index db3e75d7..9a21f530 100644 --- a/packages/backend/src/stock/domain/stockDetail.entity.ts +++ b/packages/backend/src/stock/domain/stockDetail.entity.ts @@ -18,6 +18,7 @@ export class StockDetail { stock: Stock; @Column({ + name: 'market_cap', type: 'decimal', precision: 20, scale: 2, diff --git a/packages/backend/src/stock/domain/stockLiveData.entity.ts b/packages/backend/src/stock/domain/stockLiveData.entity.ts index 885ee78f..bf82f8d6 100644 --- a/packages/backend/src/stock/domain/stockLiveData.entity.ts +++ b/packages/backend/src/stock/domain/stockLiveData.entity.ts @@ -13,10 +13,10 @@ export class StockLiveData { @PrimaryGeneratedColumn() id: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ name: 'current_price', type: 'decimal', precision: 15, scale: 2 }) currentPrice: number; - @Column({ type: 'decimal', precision: 5, scale: 2 }) + @Column({ name: 'change_rate', type: 'decimal', precision: 5, scale: 2 }) changeRate: number; @Column() diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 0b097658..d7000c69 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -9,6 +9,7 @@ import { Query, } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { LimitQuery } from './decorator/stock.decorator'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; import { StockService } from './stock.service'; @@ -180,4 +181,9 @@ export class StockController { ): Promise { return await this.stockDetailService.getStockDetailByStockId(stockId); } + + @Get('topViews') + async getTopStocksByViews(@LimitQuery(5) limit: number) { + return this.stockService.getTopStocksByViews(limit); + } } diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index cca8f692..33cb45e3 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -91,4 +91,35 @@ export class StockService { private async existsStock(stockId: string, manager: EntityManager) { return await manager.exists(Stock, { where: { id: stockId } }); } + + private StocksQuery() { + return this.datasource + .getRepository(Stock) + .createQueryBuilder('stock') + .leftJoin( + 'stock_live_data', + 'stockLiveData', + 'stock.id = stockLiveData.stock_id', + ) + .leftJoin( + 'stock_detail', + 'stockDetail', + 'stock.id = stockDetail.stock_id', + ) + .select([ + 'stock.id AS id', + 'stock.name AS name', + 'stockLiveData.currentPrice AS currentPrice', + 'stockLiveData.changeRate AS changeRate', + 'stockLiveData.volume AS volume', + 'stockDetail.marketCap AS marketCap', + ]); + } + + async getTopStocksByViews(limit: number) { + return this.StocksQuery() + .orderBy('stock.views', 'DESC') + .limit(limit) + .getRawMany(); + } } From 93b6cc0be004eb93d3a1f659b6f093c69b773ed9 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 19:03:16 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8D=B0=EC=BD=94?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?class-tranformer=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/decorator/stock.decorator.ts | 28 ++++++++++++++- .../src/stock/domain/stockDetail.entity.ts | 7 ++-- .../backend/src/stock/dto/stock.Response.ts | 36 +++++++++++++++++++ .../backend/src/stock/stock.controller.ts | 5 +-- packages/backend/src/stock/stock.service.ts | 6 +++- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/stock/decorator/stock.decorator.ts b/packages/backend/src/stock/decorator/stock.decorator.ts index 161bfe3f..1412860e 100644 --- a/packages/backend/src/stock/decorator/stock.decorator.ts +++ b/packages/backend/src/stock/decorator/stock.decorator.ts @@ -1,6 +1,32 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common'; +import { + Query, + ParseIntPipe, + DefaultValuePipe, + applyDecorators, +} 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); } + +export function ApiGetStocks(summary: string) { + return applyDecorators( + ApiOperation({ + summary, + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: '주식 리스트의 요소수', + }), + ApiResponse({ + status: 200, + description: `주식 리스트 데이터 성공적으로 조회`, + type: [StocksResponse], + }), + ); +} diff --git a/packages/backend/src/stock/domain/stockDetail.entity.ts b/packages/backend/src/stock/domain/stockDetail.entity.ts index 9a21f530..e11d1cd9 100644 --- a/packages/backend/src/stock/domain/stockDetail.entity.ts +++ b/packages/backend/src/stock/domain/stockDetail.entity.ts @@ -19,11 +19,10 @@ export class StockDetail { @Column({ name: 'market_cap', - type: 'decimal', - precision: 20, - scale: 2, + type: 'bigint', + unsigned: true, }) - marketCap: number; + marketCap: string; @Column({ type: 'integer' }) eps: number; diff --git a/packages/backend/src/stock/dto/stock.Response.ts b/packages/backend/src/stock/dto/stock.Response.ts index c3875462..81102d5f 100644 --- a/packages/backend/src/stock/dto/stock.Response.ts +++ b/packages/backend/src/stock/dto/stock.Response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; export class StockViewsResponse { @ApiProperty({ @@ -25,3 +26,38 @@ export class StockViewsResponse { this.date = new Date(); } } + +export class StocksResponse { + @ApiProperty({ + description: '주식 종목 코드', + 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, + }) + volume: number; + @ApiProperty({ + description: '주식 시가 총액', + example: '500000000000.00', + }) + marketCap: string; +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index d7000c69..ca0eb50e 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -9,7 +9,7 @@ import { Query, } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; -import { LimitQuery } from './decorator/stock.decorator'; +import { ApiGetStocks, LimitQuery } from './decorator/stock.decorator'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; import { StockService } from './stock.service'; @@ -183,7 +183,8 @@ export class StockController { } @Get('topViews') + @ApiGetStocks('조회수 기반 주식 리스트 조회 API') async getTopStocksByViews(@LimitQuery(5) limit: number) { - return this.stockService.getTopStocksByViews(limit); + return await this.stockService.getTopStocksByViews(limit); } } diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 33cb45e3..c6ef0898 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +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 { UserStock } from '@/stock/domain/userStock.entity'; @Injectable() @@ -117,9 +119,11 @@ export class StockService { } async getTopStocksByViews(limit: number) { - return this.StocksQuery() + const rawData = await this.StocksQuery() .orderBy('stock.views', 'DESC') .limit(limit) .getRawMany(); + + return plainToInstance(StocksResponse, rawData); } } From b03fc479f39eac13ac1ef9c359c94d53bf93a061 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Fri, 15 Nov 2024 19:15:37 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=85=20test:=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EC=A3=BC=EC=8B=9D=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/dto/stock.Response.ts | 1 + .../backend/src/stock/stock.service.spec.ts | 69 +++++++++++++++++++ .../backend/src/user/user.service.spec.ts | 1 + 3 files changed, 71 insertions(+) diff --git a/packages/backend/src/stock/dto/stock.Response.ts b/packages/backend/src/stock/dto/stock.Response.ts index 81102d5f..c5139450 100644 --- a/packages/backend/src/stock/dto/stock.Response.ts +++ b/packages/backend/src/stock/dto/stock.Response.ts @@ -54,6 +54,7 @@ export class StocksResponse { description: '주식 거래량', example: 500000, }) + @Transform(({ value }) => parseInt(value)) volume: number; @ApiProperty({ description: '주식 시가 총액', diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 50b76446..3dc5a3d7 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -1,5 +1,7 @@ +import { instanceToPlain } from 'class-transformer'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; +import { Stock } from './domain/stock.entity'; import { StockService } from './stock.service'; import { createDataSourceMock } from '@/user/user.service.spec'; @@ -118,4 +120,71 @@ describe('StockService 테스트', () => { stockService.deleteUserStock(notOwnerUserId, userStockId), ).rejects.toThrow('you are not owner of user stock'); }); + + test('주식 조회수 기준 상위 데이터를 반환한다.', async () => { + const limit = 5; + // QueryBuilder Mock + const queryBuilderMock = { + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { + id: 'A005930', + name: '삼성전자', + currentPrice: '100000.0', + changeRate: '2.5', + volume: '500000', + marketCap: '500000000000.00', + }, + { + id: 'A051910', + name: 'LG화학', + currentPrice: '75000.0', + changeRate: '-1.2', + volume: '300000', + marketCap: '20000000000.00', + }, + ]), + }; + + // Manager Mock + const managerMock = { + getRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue(queryBuilderMock), + }), + }; + const dataSource = createDataSourceMock(managerMock); + const stockService = new StockService(dataSource as DataSource, logger); + + const result = await stockService.getTopStocksByViews(limit); + + expect(managerMock.getRepository).toHaveBeenCalledWith(Stock); + expect(queryBuilderMock.orderBy).toHaveBeenCalledWith( + 'stock.views', + 'DESC', + ); + expect(queryBuilderMock.limit).toHaveBeenCalledWith(limit); + expect(queryBuilderMock.getRawMany).toHaveBeenCalled(); + + expect(instanceToPlain(result)).toEqual([ + { + id: 'A005930', + name: '삼성전자', + currentPrice: 100000.0, + changeRate: 2.5, + volume: 500000, + marketCap: '500000000000.00', + }, + { + id: 'A051910', + name: 'LG화학', + currentPrice: 75000.0, + changeRate: -1.2, + volume: 300000, + marketCap: '20000000000.00', + }, + ]); + }); }); diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index 6f80adf4..3cc57f11 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -6,6 +6,7 @@ export function createDataSourceMock( managerMock: Partial, ): Partial { return { + getRepository: managerMock.getRepository, transaction: jest.fn().mockImplementation(async (work) => { return work(managerMock); }), From 6deead0c2caca634d73da743273577f7470c5d75 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Sun, 17 Nov 2024 21:06:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EC=83=81=EC=8A=B9=EB=A5=A0=20=EA=B8=B0=EB=B0=98=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/stock/stock.controller.ts | 6 ++++++ packages/backend/src/stock/stock.service.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index ca0eb50e..5d1b8810 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -187,4 +187,10 @@ export class StockController { async getTopStocksByViews(@LimitQuery(5) limit: number) { return await this.stockService.getTopStocksByViews(limit); } + + @Get('topGainers') + @ApiGetStocks('가격 상승률 기반 주식 리스트 조회 API') + async getTopStocksByGainers(@LimitQuery(20) limit: number) { + return await this.stockService.getTopStocksByViews(limit); + } } diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index c6ef0898..6761ec74 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -126,4 +126,13 @@ export class StockService { return plainToInstance(StocksResponse, rawData); } + + async getTopStocksByGainers(limit: number) { + const rawData = await this.StocksQuery() + .orderBy('stockLiveData.changeRate', 'DESC') + .limit(limit) + .getRawMany(); + + return plainToInstance(StocksResponse, rawData); + } } From e2d8c442ecd2fd75f4644ebcd4c0d94e528da3bb Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Sun, 17 Nov 2024 21:08:52 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 5d1b8810..78f5596c 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -191,6 +191,6 @@ export class StockController { @Get('topGainers') @ApiGetStocks('가격 상승률 기반 주식 리스트 조회 API') async getTopStocksByGainers(@LimitQuery(20) limit: number) { - return await this.stockService.getTopStocksByViews(limit); + return await this.stockService.getTopStocksByGainers(limit); } } From c956d58e575920584a3ac77b39b0e5c8fee01a38 Mon Sep 17 00:00:00 2001 From: demian-m00n Date: Sun, 17 Nov 2024 21:12:20 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=85=20test:=20=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EA=B0=80=EA=B2=A9=20=EC=83=81=EC=8A=B9=EB=A5=A0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stock.service.spec.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 3dc5a3d7..34fe4e92 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function */ import { instanceToPlain } from 'class-transformer'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; @@ -187,4 +188,71 @@ describe('StockService 테스트', () => { }, ]); }); + + test('주식 상승률 기준 상위 데이터를 반환한다.', async () => { + const limit = 20; + // QueryBuilder Mock + const queryBuilderMock = { + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { + id: 'A005930', + name: '삼성전자', + currentPrice: '100000.0', + changeRate: '2.5', + volume: '500000', + marketCap: '500000000000.00', + }, + { + id: 'A051910', + name: 'LG화학', + currentPrice: '75000.0', + changeRate: '-1.2', + volume: '300000', + marketCap: '20000000000.00', + }, + ]), + }; + + // Manager Mock + const managerMock = { + getRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue(queryBuilderMock), + }), + }; + const dataSource = createDataSourceMock(managerMock); + const stockService = new StockService(dataSource as DataSource, logger); + + const result = await stockService.getTopStocksByGainers(limit); + + expect(managerMock.getRepository).toHaveBeenCalledWith(Stock); + expect(queryBuilderMock.orderBy).toHaveBeenCalledWith( + 'stockLiveData.changeRate', + 'DESC', + ); + expect(queryBuilderMock.limit).toHaveBeenCalledWith(limit); + expect(queryBuilderMock.getRawMany).toHaveBeenCalled(); + + expect(instanceToPlain(result)).toEqual([ + { + id: 'A005930', + name: '삼성전자', + currentPrice: 100000.0, + changeRate: 2.5, + volume: 500000, + marketCap: '500000000000.00', + }, + { + id: 'A051910', + name: 'LG화학', + currentPrice: 75000.0, + changeRate: -1.2, + volume: 300000, + marketCap: '20000000000.00', + }, + ]); + }); });