diff --git a/packages/backend/src/stock/domain/stockDetail.entity.ts b/packages/backend/src/stock/domain/stockDetail.entity.ts new file mode 100644 index 00000000..db3e75d7 --- /dev/null +++ b/packages/backend/src/stock/domain/stockDetail.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Stock } from './stock.entity'; + +@Entity('stock_detail') +export class StockDetail { + @PrimaryGeneratedColumn() + id: number; + + @OneToOne(() => Stock) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @Column({ + type: 'decimal', + precision: 20, + scale: 2, + }) + marketCap: number; + + @Column({ type: 'integer' }) + eps: number; + + @Column({ type: 'decimal', precision: 6, scale: 3 }) + per: number; + + @Column({ type: 'integer' }) + high52w: number; + + @Column({ type: 'integer' }) + low52w: number; + + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/backend/src/stock/domain/stockLiveData.entity.ts b/packages/backend/src/stock/domain/stockLiveData.entity.ts index 6fd36c83..885ee78f 100644 --- a/packages/backend/src/stock/domain/stockLiveData.entity.ts +++ b/packages/backend/src/stock/domain/stockLiveData.entity.ts @@ -4,6 +4,7 @@ import { Column, OneToOne, JoinColumn, + UpdateDateColumn, } from 'typeorm'; import { Stock } from './stock.entity'; @@ -13,10 +14,10 @@ export class StockLiveData { id: number; @Column({ type: 'decimal', precision: 15, scale: 2 }) - current_price: number; + currentPrice: number; @Column({ type: 'decimal', precision: 5, scale: 2 }) - change_rate: number; + changeRate: number; @Column() volume: number; @@ -31,10 +32,11 @@ export class StockLiveData { open: number; @Column({ type: 'decimal', precision: 15, scale: 2 }) - previous_close: number; + previousClose: number; + @UpdateDateColumn() @Column({ type: 'timestamp' }) - updated_at: Date; + updatedAt: Date; @OneToOne(() => Stock) @JoinColumn({ name: 'stock_id' }) diff --git a/packages/backend/src/stock/dto/stockDetail.response.ts b/packages/backend/src/stock/dto/stockDetail.response.ts new file mode 100644 index 00000000..cf692e58 --- /dev/null +++ b/packages/backend/src/stock/dto/stockDetail.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StockDetailResponse { + @ApiProperty({ + description: '주식의 시가 총액', + example: 352510000000000, + }) + marketCap: number; + + @ApiProperty({ + description: '주식의 EPS', + example: 4091, + }) + eps: number; + + @ApiProperty({ + description: '주식의 PER', + example: 17.51, + }) + per: number; + + @ApiProperty({ + description: '주식의 52주 최고가', + example: 88000, + }) + high52w: number; + + @ApiProperty({ + description: '주식의 52주 최저가', + example: 53000, + }) + low52w: number; +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index a5506f06..147dd423 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -1,6 +1,16 @@ -import { Body, Controller, Delete, HttpCode, Post } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { StockDetailResponse } from './dto/stockDetail.response'; import { StockService } from './stock.service'; +import { StockDetailService } from './stockDetail.service'; import { StockViewsResponse } from '@/stock/dto/stock.Response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { @@ -11,7 +21,10 @@ import { UserStockResponse } from '@/stock/dto/userStock.response'; @Controller('stock') export class StockController { - constructor(private readonly stockService: StockService) {} + constructor( + private readonly stockService: StockService, + private readonly stockDetailService: StockDetailService, + ) {} @HttpCode(200) @Post('/view') @@ -80,4 +93,20 @@ export class StockController { '사용자 소유 주식을 삭제했습니다.', ); } + + @ApiOperation({ + summary: '주식 상세 정보 조회 API', + description: '시가 총액, EPS, PER, 52주 최고가, 52주 최저가를 조회합니다', + }) + @ApiOkResponse({ + description: '주식 상세 정보 조회 성공', + type: StockDetailResponse, + }) + @ApiParam({ name: 'stockId', required: true, description: '주식 ID' }) + @Get(':stockId/detail') + async getStockDetail( + @Param('stockId') stockId: string, + ): Promise { + return await this.stockDetailService.getStockDetailByStockId(stockId); + } } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 74e2d421..bfac281e 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -4,11 +4,17 @@ import { Stock } from './domain/stock.entity'; import { StockController } from './stock.controller'; import { StockGateway } from './stock.gateway'; import { StockService } from './stock.service'; +import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; @Module({ imports: [TypeOrmModule.forFeature([Stock])], controllers: [StockController], - providers: [StockService, StockGateway, StockLiveDataSubscriber], + providers: [ + StockService, + StockGateway, + StockLiveDataSubscriber, + StockDetailService, + ], }) export class StockModule {} diff --git a/packages/backend/src/stock/stockDetail.service.spec.ts b/packages/backend/src/stock/stockDetail.service.spec.ts new file mode 100644 index 00000000..403b8754 --- /dev/null +++ b/packages/backend/src/stock/stockDetail.service.spec.ts @@ -0,0 +1,75 @@ +import { NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { StockDetail } from './domain/stockDetail.entity'; +import { StockDetailService } from './stockDetail.service'; +import { createDataSourceMock } from '@/user/user.service.spec'; + +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), +} as unknown as Logger; + +describe('StockDetailService 테스트', () => { + const stockId = 'A005930'; + + test('stockId로 주식 상세 정보를 조회한다.', async () => { + const mockStockDetail = { + stock: { id: stockId }, + marketCap: 352510000000000, + eps: 4091, + per: 17.51, + high52w: 88000, + low52w: 53000, + }; + const managerMock = { + existsBy: jest.fn().mockResolvedValue(true), + findBy: jest.fn().mockResolvedValue([mockStockDetail]), + }; + const dataSource = createDataSourceMock(managerMock); + const stockDetailService = new StockDetailService( + dataSource as DataSource, + logger, + ); + + const result = await stockDetailService.getStockDetailByStockId(stockId); + + expect(managerMock.existsBy).toHaveBeenCalledWith(StockDetail, { + stock: { id: stockId }, + }); + expect(managerMock.findBy).toHaveBeenCalledWith(StockDetail, { + stock: { id: stockId }, + }); + expect(result).toMatchObject({ + marketCap: expect.any(Number), + eps: expect.any(Number), + per: expect.any(Number), + high52w: expect.any(Number), + low52w: expect.any(Number), + }); + expect(result.marketCap).toEqual(mockStockDetail.marketCap); + expect(result.eps).toEqual(mockStockDetail.eps); + expect(result.per).toEqual(mockStockDetail.per); + expect(result.high52w).toEqual(mockStockDetail.high52w); + expect(result.low52w).toEqual(mockStockDetail.low52w); + }); + + test('존재하지 않는 stockId로 조회 시 예외를 발생시킨다.', async () => { + const managerMock = { + existsBy: jest.fn().mockResolvedValue(false), + }; + const dataSource = createDataSourceMock(managerMock); + const stockDetailService = new StockDetailService( + dataSource as DataSource, + logger, + ); + + await expect( + stockDetailService.getStockDetailByStockId('nonexistentId'), + ).rejects.toThrow(NotFoundException); + expect(logger.warn).toHaveBeenCalledWith( + `stock detail not found (stockId: nonexistentId)`, + ); + }); +}); diff --git a/packages/backend/src/stock/stockDetail.service.ts b/packages/backend/src/stock/stockDetail.service.ts new file mode 100644 index 00000000..412a4745 --- /dev/null +++ b/packages/backend/src/stock/stockDetail.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { StockDetail } from './domain/stockDetail.entity'; +import { StockDetailResponse } from './dto/stockDetail.response'; + +@Injectable() +export class StockDetailService { + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) {} + + async getStockDetailByStockId(stockId: string): Promise { + return await this.datasource.transaction(async (manager) => { + const isExists = await manager.existsBy(StockDetail, { + stock: { id: stockId }, + }); + + if (!isExists) { + this.logger.warn(`stock detail not found (stockId: ${stockId})`); + throw new NotFoundException( + `stock detail not found (stockId: ${stockId}`, + ); + } + + const stockDetail = await manager.findBy(StockDetail, { + stock: { id: stockId }, + }); + + return plainToInstance(StockDetailResponse, stockDetail[0]); + }); + } +} diff --git a/packages/backend/src/stock/stockLiveData.subscriber.ts b/packages/backend/src/stock/stockLiveData.subscriber.ts index 2e292dfd..e28d55e2 100644 --- a/packages/backend/src/stock/stockLiveData.subscriber.ts +++ b/packages/backend/src/stock/stockLiveData.subscriber.ts @@ -29,8 +29,8 @@ export class StockLiveDataSubscriber if (updatedStockLiveData?.stock?.id) { const { id: stockId } = updatedStockLiveData.stock; const { - current_price: price, - change_rate: change, + currentPrice: price, + changeRate: change, volume: volume, } = updatedStockLiveData; this.stockGateway.onUpdateStock(stockId, price, change, volume);