From 8b33b3f11a0f8c30f454541fa9f5c18ef33726d1 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 14 Nov 2024 11:46:39 +0900 Subject: [PATCH 001/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20swagge?= =?UTF-8?q?r=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 002/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=20=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 003/223] =?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 004/223] =?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 005/223] =?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 0bb7c83848259d3f8768af0f5ec6c0990f106f35 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Mon, 18 Nov 2024 16:46:04 +0900 Subject: [PATCH 006/223] =?UTF-8?q?=E2=9C=A8=20feat:=20detail=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EC=A7=84=ED=96=89=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 102 +++++++++ .../openapi/api/openapiPeriodData.api.ts | 8 +- .../src/scraper/openapi/openapiUtil.api.ts | 5 +- .../openapi/type/openapiDetailData.type.ts | 213 ++++++++++++++++++ .../scraper/openapi/type/openapiUtil.type.ts | 7 + .../src/stock/domain/kospiStock.entity.ts | 0 .../backend/src/stock/domain/stock.entity.ts | 4 +- .../src/stock/domain/stockData.entity.ts | 4 +- 8 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiUtil.type.ts create mode 100644 packages/backend/src/stock/domain/kospiStock.entity.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts new file mode 100644 index 00000000..643f7636 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -0,0 +1,102 @@ +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { openApiConfig } from '../config/openapi.config'; +import { getOpenApi } from '../openapiUtil.api'; +import { + DetailDataQuery, + FinancialData, + FinancialDetail, + isFinancialData, + isFinancialDetail, + isProductDetail, + ProductDetail, + StockDetailQuery, +} from '../type/openapiDetailData.type'; +import { openApiToken } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; +import { StockDaily } from '@/stock/domain/stockData.entity'; + +export class OpenapiDetailData { + private readonly financialUrl: string = + '/uapi/domestic-stock/v1/finance/financial-ratio'; + private readonly defaultUrl: string = + '/uapi/domestic-stock/v1/quotations/search-stock-info'; + private readonly incomeUrl: string = '/uapi/domestic-stock/v1/finance/income-statement'; + private readonly intervals = 4000; + private readonly config: (typeof openApiConfig)[] = openApiToken.configs; + constructor(private readonly datasource: DataSource) {} + + @Cron('10 1 * * 1-5') + public async getDetailData() { + const entityManager = this.datasource.manager; + const stocks = await entityManager.find(Stock); + const configCount = this.config.length; + + const chunkSize = Math.ceil(stocks.length / configCount); + + for (let i = 0; i < configCount; i++) { + const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); + this.getDetailDataChunk(chunk, this.config[i]); + } + } + + private async saveDetailData(output1: FinancialData, output2: ProductDetail, output3 : StockDaily[]) { + const entityManager = this.datasource.manager; + const entity = StockDetail; + entityManager.create(entity, output1); + } + + private makeStockDetailObject( + output1: FinancialDetail, + output2: ProductDetail, + ): StockDetail { + const result = new StockDetail(); + result.marketCap = output2. + return result; + } + + private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { + const manager = this.datasource.manager; + for (const stock of chunk) { + const dataQuery = this.getDetailDataQuery(stock.id!); + const defaultQuery = this.getDefaultDataQuery(stock.id!); + const output1 = await getOpenApi(this.incomeUrl, conf, dataQuery, 'FHKST66430200'); + const output2 = await getOpenApi( + this.defaultUrl, + conf, + defaultQuery, + 'CTPF1002R', + ); + const output3 = await manager.find(StockDaily, { + where: { + + } + }) + if (isFinancialDetail(output1) && isProductDetail(output2)) { + } + } + } + + private getDefaultDataQuery( + stockId: string, + code: '300' | '301' | '302' | '306' = '300', + ): StockDetailQuery { + return { + pdno: stockId, + code: code, + }; + } + + private getDetailDataQuery( + stockId: string, + divCode: 'J' = 'J', + classify: '0' | '1' = '1', + ): DetailDataQuery { + return { + fid_cond_mrkt_div_code: divCode, + fid_input_iscd: stockId, + fid_div_cls_code: classify, + }; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index a11766b6..f06efcb0 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -36,13 +36,13 @@ const INTERVALS = 4000; export class OpenapiPeriodData { private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; - public constructor(private readonly datasourse: DataSource) { + public constructor(private readonly datasource: DataSource) { //this.getItemChartPriceCheck(); } @Cron('0 1 * * 1-5') public async getItemChartPriceCheck() { - const entityManager = this.datasourse.manager; + const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); const configCount = openApiToken.configs.length; const chunkSize = Math.ceil(stocks.length / configCount); @@ -59,7 +59,7 @@ export class OpenapiPeriodData { private async getChartData(chunk: Stock[], period: Period) { const baseTime = INTERVALS * 4; const entity = DATE_TO_ENTITY[period]; - const manager = this.datasourse.manager; + const manager = this.datasource.manager; let time = 0; for (const stock of chunk) { @@ -148,7 +148,7 @@ export class OpenapiPeriodData { } private async insertChartData(stock: StockData, entity: typeof StockData) { - const manager = this.datasourse.manager; + const manager = this.datasource.manager; if (!(await this.existsChartData(stock, manager, entity))) { await manager.save(entity, stock); } diff --git a/packages/backend/src/scraper/openapi/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/openapiUtil.api.ts index e09c181c..06ae8c23 100644 --- a/packages/backend/src/scraper/openapi/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/openapiUtil.api.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { openApiConfig } from './config/openapi.config'; +import { DEFAULT_TR_ID, TR_ID } from './type/openapiUtil.type'; const postOpenApi = async ( url: string, @@ -18,6 +19,7 @@ const getOpenApi = async ( url: string, config: typeof openApiConfig, query: object, + tr_id: TR_ID = DEFAULT_TR_ID, ) => { try { const response = await axios.get(config.STOCK_URL + url, { @@ -26,7 +28,8 @@ const getOpenApi = async ( Authorization: `Bearer ${config.STOCK_API_TOKEN}`, appkey: config.STOCK_API_KEY, appsecret: config.STOCK_API_PASSWORD, - tr_id: 'FHKST03010100', + tr_id, + custtype: 'P', }, }); return response.data; diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts new file mode 100644 index 00000000..a4267b40 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +/* eslint-disable max-lines-per-function */ + +export type DetailDataQuery = { + fid_cond_mrkt_div_code: 'J'; + fid_input_iscd: string; + fid_div_cls_code: '0' | '1'; +}; +export type FinancialData = { + stac_yymm: string; // 결산 년월 + grs: string; // 매출액 증가율 + bsop_prfi_inrt: string; // 영업 이익 증가율 + ntin_inrt: string; // 순이익 증가율 + roe_val: string; // ROE 값 + eps: string; // EPS + sps: string; // 주당매출액 + bps: string; // BPS + rsrv_rate: string; // 유보 비율 + lblt_rate: string; // 부채 비율 +}; + +export function isFinancialData(data: any): data is FinancialData { + return ( + data && + typeof data.stac_yymm === 'string' && + typeof data.grs === 'string' && + typeof data.bsop_prfi_inrt === 'string' && + typeof data.ntin_inrt === 'string' && + typeof data.roe_val === 'string' && + typeof data.eps === 'string' && + typeof data.sps === 'string' && + typeof data.bps === 'string' && + typeof data.rsrv_rate === 'string' && + typeof data.lblt_rate === 'string' + ); +} + +export type ProductDetail = { + pdno: string; // 상품번호 + prdt_type_cd: string; // 상품유형코드 + mket_id_cd: string; // 시장ID코드 + scty_grp_id_cd: string; // 증권그룹ID코드 + excg_dvsn_cd: string; // 거래소구분코드 + setl_mmdd: string; // 결산월일 + lstg_stqt: string; // 상장주수 + lstg_cptl_amt: string; // 상장자본금액 + cpta: string; // 자본금 + papr: string; // 액면가 + issu_pric: string; // 발행가격 + kospi200_item_yn: string; // 코스피200종목여부 + scts_mket_lstg_dt: string; // 유가증권시장상장일자 + scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 + kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 + kosdaq_mket_lstg_abol_dt: string; // 코스닥시장상장폐지일자 + frbd_mket_lstg_dt: string; // 프리보드시장상장일자 + frbd_mket_lstg_abol_dt: string; // 프리보드시장상장폐지일자 + reits_kind_cd: string; // 리츠종류코드 + etf_dvsn_cd: string; // ETF구분코드 + oilf_fund_yn: string; // 유전펀드여부 + idx_bztp_lcls_cd: string; // 지수업종대분류코드 + idx_bztp_mcls_cd: string; // 지수업종중분류코드 + idx_bztp_scls_cd: string; // 지수업종소분류코드 + stck_kind_cd: string; // 주식종류코드 + mfnd_opng_dt: string; // 뮤추얼펀드개시일자 + mfnd_end_dt: string; // 뮤추얼펀드종료일자 + dpsi_erlm_cncl_dt: string; // 예탁등록취소일자 + etf_cu_qty: string; // ETFCU수량 + prdt_name: string; // 상품명 + prdt_name120: string; // 상품명120 + prdt_abrv_name: string; // 상품약어명 + std_pdno: string; // 표준상품번호 + prdt_eng_name: string; // 상품영문명 + prdt_eng_name120: string; // 상품영문명120 + prdt_eng_abrv_name: string; // 상품영문약어명 + dpsi_aptm_erlm_yn: string; // 예탁지정등록여부 + etf_txtn_type_cd: string; // ETF과세유형코드 + etf_type_cd: string; // ETF유형코드 + lstg_abol_dt: string; // 상장폐지일자 + nwst_odst_dvsn_cd: string; // 신주구주구분코드 + sbst_pric: string; // 대용가격 + thco_sbst_pric: string; // 당사대용가격 + thco_sbst_pric_chng_dt: string; // 당사대용가격변경일자 + tr_stop_yn: string; // 거래정지여부 + admn_item_yn: string; // 관리종목여부 + thdt_clpr: string; // 당일종가 + bfdy_clpr: string; // 전일종가 + clpr_chng_dt: string; // 종가변경일자 + std_idst_clsf_cd: string; // 표준산업분류코드 + std_idst_clsf_cd_name: string; // 표준산업분류코드명 + idx_bztp_lcls_cd_name: string; // 지수업종대분류코드명 + idx_bztp_mcls_cd_name: string; // 지수업종중분류코드명 + idx_bztp_scls_cd_name: string; // 지수업종소분류코드명 + ocr_no: string; // OCR번호 + crfd_item_yn: string; // 크라우드펀딩종목여부 + elec_scty_yn: string; // 전자증권여부 + issu_istt_cd: string; // 발행기관코드 + etf_chas_erng_rt_dbnb: string; // ETF추적수익율배수 + etf_etn_ivst_heed_item_yn: string; // ETFETN투자유의종목여부 + stln_int_rt_dvsn_cd: string; // 대주이자율구분코드 + frnr_psnl_lmt_rt: string; // 외국인개인한도비율 + lstg_rqsr_issu_istt_cd: string; // 상장신청인발행기관코드 + lstg_rqsr_item_cd: string; // 상장신청인종목코드 + trst_istt_issu_istt_cd: string; // 신탁기관발행기관코드 +}; + +export const isProductDetail = (data: any): data is ProductDetail => { + return ( + typeof data.pdno === 'string' && + typeof data.prdt_type_cd === 'string' && + typeof data.mket_id_cd === 'string' && + typeof data.scty_grp_id_cd === 'string' && + typeof data.excg_dvsn_cd === 'string' && + typeof data.setl_mmdd === 'string' && + typeof data.lstg_stqt === 'string' && + typeof data.lstg_cptl_amt === 'string' && + typeof data.cpta === 'string' && + typeof data.papr === 'string' && + typeof data.issu_pric === 'string' && + typeof data.kospi200_item_yn === 'string' && + typeof data.scts_mket_lstg_dt === 'string' && + typeof data.scts_mket_lstg_abol_dt === 'string' && + typeof data.kosdaq_mket_lstg_dt === 'string' && + typeof data.kosdaq_mket_lstg_abol_dt === 'string' && + typeof data.frbd_mket_lstg_dt === 'string' && + typeof data.frbd_mket_lstg_abol_dt === 'string' && + typeof data.reits_kind_cd === 'string' && + typeof data.etf_dvsn_cd === 'string' && + typeof data.oilf_fund_yn === 'string' && + typeof data.idx_bztp_lcls_cd === 'string' && + typeof data.idx_bztp_mcls_cd === 'string' && + typeof data.idx_bztp_scls_cd === 'string' && + typeof data.stck_kind_cd === 'string' && + typeof data.mfnd_opng_dt === 'string' && + typeof data.mfnd_end_dt === 'string' && + typeof data.dpsi_erlm_cncl_dt === 'string' && + typeof data.etf_cu_qty === 'string' && + typeof data.prdt_name === 'string' && + typeof data.prdt_name120 === 'string' && + typeof data.prdt_abrv_name === 'string' && + typeof data.std_pdno === 'string' && + typeof data.prdt_eng_name === 'string' && + typeof data.prdt_eng_name120 === 'string' && + typeof data.prdt_eng_abrv_name === 'string' && + typeof data.dpsi_aptm_erlm_yn === 'string' && + typeof data.etf_txtn_type_cd === 'string' && + typeof data.etf_type_cd === 'string' && + typeof data.lstg_abol_dt === 'string' && + typeof data.nwst_odst_dvsn_cd === 'string' && + typeof data.sbst_pric === 'string' && + typeof data.thco_sbst_pric === 'string' && + typeof data.thco_sbst_pric_chng_dt === 'string' && + typeof data.tr_stop_yn === 'string' && + typeof data.admn_item_yn === 'string' && + typeof data.thdt_clpr === 'string' && + typeof data.bfdy_clpr === 'string' && + typeof data.clpr_chng_dt === 'string' && + typeof data.std_idst_clsf_cd === 'string' && + typeof data.std_idst_clsf_cd_name === 'string' && + typeof data.idx_bztp_lcls_cd_name === 'string' && + typeof data.idx_bztp_mcls_cd_name === 'string' && + typeof data.idx_bztp_scls_cd_name === 'string' && + typeof data.ocr_no === 'string' && + typeof data.crfd_item_yn === 'string' && + typeof data.elec_scty_yn === 'string' && + typeof data.issu_istt_cd === 'string' && + typeof data.etf_chas_erng_rt_dbnb === 'string' && + typeof data.etf_etn_ivst_heed_item_yn === 'string' && + typeof data.stln_int_rt_dvsn_cd === 'string' && + typeof data.frnr_psnl_lmt_rt === 'string' && + typeof data.lstg_rqsr_issu_istt_cd === 'string' && + typeof data.lstg_rqsr_item_cd === 'string' && + typeof data.trst_istt_issu_istt_cd === 'string' + ); +}; + +export type StockDetailQuery = { + pdno: string; + code: string; +}; + +export type FinancialDetail = { + stac_yymm: string; // 결산 년월 + sale_account: string; // 매출액 + sale_cost: string; // 매출원가 + sale_totl_prfi: string; // 매출총이익 + depr_cost: string; // 감가상각비 + sell_mang: string; // 판매관리비 + bsop_prti: string; // 영업이익 + bsop_non_ernn: string; // 영업외수익 + bsop_non_expn: string; // 영업외비용 + op_prfi: string; // 영업이익 + spec_prfi: string; // 특별이익 + spec_loss: string; // 특별손실 + thtr_ntin: string; // 세전순이익 +}; + +export const isFinancialDetail = (data: any): data is FinancialDetail => { + return ( + typeof data.stac_yymm === 'string' && + typeof data.sale_account === 'string' && + typeof data.sale_cost === 'string' && + typeof data.sale_totl_prfi === 'string' && + typeof data.depr_cost === 'string' && + typeof data.sell_mang === 'string' && + typeof data.bsop_prti === 'string' && + typeof data.bsop_non_ernn === 'string' && + typeof data.bsop_non_expn === 'string' && + typeof data.op_prfi === 'string' && + typeof data.spec_prfi === 'string' && + typeof data.spec_loss === 'string' && + typeof data.thtr_ntin === 'string' + ); +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts new file mode 100644 index 00000000..df05e35a --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -0,0 +1,7 @@ +export type TR_ID = + | 'FHKST03010100' + | 'FHKST66430200' + | 'HHKDB669107C0' + | 'CTPF1002R'; + +export const DEFAULT_TR_ID: TR_ID = 'FHKST03010100'; diff --git a/packages/backend/src/stock/domain/kospiStock.entity.ts b/packages/backend/src/stock/domain/kospiStock.entity.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 033833bf..24b0fbca 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -1,6 +1,4 @@ import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; -import { DateEmbedded } from '@/common/dateEmbedded.entity'; -import { UserStock } from '@/stock/domain/userStock.entity'; import { StockDaily, StockMinutely, @@ -8,6 +6,8 @@ import { StockWeekly, StockYearly, } from './stockData.entity'; +import { DateEmbedded } from '@/common/dateEmbedded.entity'; +import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() export class Stock { diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index 55395ffd..f053c2d9 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -1,3 +1,4 @@ +import { applyDecorators } from '@nestjs/common'; import { Entity, PrimaryGeneratedColumn, @@ -8,7 +9,6 @@ import { ColumnOptions, } from 'typeorm'; import { Stock } from './stock.entity'; -import { applyDecorators } from '@nestjs/common'; export const GenerateBigintColumn = ( options?: ColumnOptions, @@ -44,7 +44,7 @@ export class StockData { open: number; @GenerateBigintColumn() - volume: BigInt; + volume: bigint; @Column({ type: 'timestamp', name: 'start_time' }) startTime: Date; From 1e4a4b6db6d2c372fab8b26a0717da26422040e9 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Mon, 18 Nov 2024 19:26:09 +0900 Subject: [PATCH 007/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20kospiStock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/domain/kospiStock.entity.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/backend/src/stock/domain/kospiStock.entity.ts b/packages/backend/src/stock/domain/kospiStock.entity.ts index e69de29b..f8578aa7 100644 --- a/packages/backend/src/stock/domain/kospiStock.entity.ts +++ b/packages/backend/src/stock/domain/kospiStock.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, OneToOne, PrimaryColumn } from 'typeorm'; +import { Stock } from './stock.entity'; + +@Entity() +export class KospiStock { + @PrimaryColumn({ name: 'stock_id' }) + id: string; + + @Column({ name: 'is_kospi' }) + isKospi: boolean; + + @OneToOne(() => Stock) +} From b003c024d346efe4e5cee609e127d1866c6cad1d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 18 Nov 2024 21:16:25 +0900 Subject: [PATCH 008/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=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 009/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=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 010/223] =?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); } From 0ab32335143714a10852529c72e55265371818cc Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 18 Nov 2024 22:08:49 +0900 Subject: [PATCH 011/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=94=ED=8B=B0=ED=8B=B0=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.module.ts | 3 +- .../backend/src/chat/domain/chat.entity.ts | 5 ++++ .../backend/src/chat/domain/like.entity.ts | 28 +++++++++++++++++++ .../backend/src/stock/domain/stock.entity.ts | 10 +++++-- 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/chat/domain/like.entity.ts diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 089b37e4..4a1a27b5 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -5,10 +5,11 @@ 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 { Like } from '@/chat/domain/like.entity'; import { StockModule } from '@/stock/stock.module'; @Module({ - imports: [TypeOrmModule.forFeature([Chat]), StockModule, SessionModule], + imports: [TypeOrmModule.forFeature([Chat, Like]), StockModule, SessionModule], controllers: [ChatController], providers: [ChatGateway, ChatService], }) diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index 13800bff..a1bab9ca 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -3,9 +3,11 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { ChatType } from '@/chat/domain/chatType.enum'; +import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; @@ -23,6 +25,9 @@ export class Chat { @JoinColumn({ name: 'stock_id' }) stock: Stock; + @OneToMany(() => Like, (like) => like.chat) + likes?: Like[]; + @Column() message: string; diff --git a/packages/backend/src/chat/domain/like.entity.ts b/packages/backend/src/chat/domain/like.entity.ts new file mode 100644 index 00000000..84238b15 --- /dev/null +++ b/packages/backend/src/chat/domain/like.entity.ts @@ -0,0 +1,28 @@ +import { + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { User } from '@/user/domain/user.entity'; + +@Index('chat_user_unique', ['chat', 'user'], { unique: true }) +@Entity() +export class Like { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Chat, (chat) => chat.id) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 033833bf..3645b33e 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -1,6 +1,4 @@ import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; -import { DateEmbedded } from '@/common/dateEmbedded.entity'; -import { UserStock } from '@/stock/domain/userStock.entity'; import { StockDaily, StockMinutely, @@ -8,6 +6,9 @@ import { StockWeekly, StockYearly, } from './stockData.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { DateEmbedded } from '@/common/dateEmbedded.entity'; +import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() export class Stock { @@ -26,8 +27,11 @@ export class Stock { @Column({ name: 'group_code' }) groupCode?: string; + @OneToMany(() => Like, (like) => like.chat) + likes?: Like[]; + @Column(() => DateEmbedded, { prefix: '' }) - dare?: DateEmbedded; + date?: DateEmbedded; @OneToMany(() => UserStock, (userStock) => userStock.stock) userStocks?: UserStock[]; From 74cbf7b5dac3767bafa1f7d02019651b345e2817 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 18 Nov 2024 22:11:06 +0900 Subject: [PATCH 012/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20typeOR?= =?UTF-8?q?M=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=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/app.module.ts | 2 +- .../src/configs/{devTypeormConfig.ts => typeormConfig.ts} | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) rename packages/backend/src/configs/{devTypeormConfig.ts => typeormConfig.ts} (99%) diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index d0c5f81d..f4735ad2 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -10,7 +10,7 @@ import { ChatModule } from '@/chat/chat.module'; import { typeormDevelopConfig, typeormProductConfig, -} from '@/configs/devTypeormConfig'; +} from '@/configs/typeormConfig'; import { logger } from '@/configs/logger.config'; import { StockModule } from '@/stock/stock.module'; import { UserModule } from '@/user/user.module'; diff --git a/packages/backend/src/configs/devTypeormConfig.ts b/packages/backend/src/configs/typeormConfig.ts similarity index 99% rename from packages/backend/src/configs/devTypeormConfig.ts rename to packages/backend/src/configs/typeormConfig.ts index 2641d8d9..a8c70a18 100644 --- a/packages/backend/src/configs/devTypeormConfig.ts +++ b/packages/backend/src/configs/typeormConfig.ts @@ -24,4 +24,3 @@ export const typeormDevelopConfig: TypeOrmModuleOptions = { //logging: true, synchronize: true, }; - From 13a8f1f00c277618ef9442b0a28d6be7675f8a73 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 19 Nov 2024 11:19:59 +0900 Subject: [PATCH 013/223] =?UTF-8?q?=E2=9C=A8=20feat:=20kospi=20stock=20ent?= =?UTF-8?q?ity=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/domain/kospiStock.entity.ts | 3 ++- packages/backend/src/stock/domain/stock.entity.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/stock/domain/kospiStock.entity.ts b/packages/backend/src/stock/domain/kospiStock.entity.ts index f8578aa7..7d96a992 100644 --- a/packages/backend/src/stock/domain/kospiStock.entity.ts +++ b/packages/backend/src/stock/domain/kospiStock.entity.ts @@ -9,5 +9,6 @@ export class KospiStock { @Column({ name: 'is_kospi' }) isKospi: boolean; - @OneToOne(() => Stock) + @OneToOne(() => Stock, (stock) => stock.id) + stock: Stock; } diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 24b0fbca..463389cf 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; +import { Column, Entity, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; import { StockDaily, StockMinutely, @@ -8,6 +8,7 @@ import { } from './stockData.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; +import { KospiStock } from './kospiStock.entity'; @Entity() export class Stock { @@ -46,4 +47,7 @@ export class Stock { @OneToMany(() => StockYearly, (stockYearly) => stockYearly.stock) stockYearly?: StockYearly[]; + + @OneToOne(() => KospiStock, (kospiStock) => kospiStock.stock) + kospiStock?: KospiStock; } From 17d4cb1b75eeee97126fc299d451d38760af94d6 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 19 Nov 2024 11:46:35 +0900 Subject: [PATCH 014/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20openapi=20detail?= =?UTF-8?q?=20=EC=9E=A0=EA=B9=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 18 +++++++++++++++++- .../openapi/type/openapiDetailData.type.ts | 4 ++-- test.js | 11 +++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 643f7636..77fa6feb 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -61,19 +61,26 @@ export class OpenapiDetailData { for (const stock of chunk) { const dataQuery = this.getDetailDataQuery(stock.id!); const defaultQuery = this.getDefaultDataQuery(stock.id!); + // 여기서 가져올 건 eps -> eps와 per 계산하자. const output1 = await getOpenApi(this.incomeUrl, conf, dataQuery, 'FHKST66430200'); + // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 const output2 = await getOpenApi( this.defaultUrl, conf, defaultQuery, 'CTPF1002R', ); + // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 const output3 = await manager.find(StockDaily, { + select: { + + }, where: { } }) - if (isFinancialDetail(output1) && isProductDetail(output2)) { + // 주식 마지막 데이터 끌고 오기. 최신 데이터로. + if ( isProductDetail(output1)) { } } } @@ -99,4 +106,13 @@ export class OpenapiDetailData { fid_div_cls_code: classify, }; } + + private getDate52WeeksAgo(): Date { + const today = new Date(); + const weeksAgo = 52 * 7; + const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); + date52WeeksAgo.setHours(0, 0, 0, 0); + return date52WeeksAgo; + } + } diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index a4267b40..c5642b3a 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -42,12 +42,12 @@ export type ProductDetail = { scty_grp_id_cd: string; // 증권그룹ID코드 excg_dvsn_cd: string; // 거래소구분코드 setl_mmdd: string; // 결산월일 - lstg_stqt: string; // 상장주수 + lstg_stqt: string; // 상장주수 - 이거 사용 lstg_cptl_amt: string; // 상장자본금액 cpta: string; // 자본금 papr: string; // 액면가 issu_pric: string; // 발행가격 - kospi200_item_yn: string; // 코스피200종목여부 + kospi200_item_yn: string; // 코스피200종목여부 - 이것도 사용 scts_mket_lstg_dt: string; // 유가증권시장상장일자 scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 diff --git a/test.js b/test.js index ec521fac..ea3c8120 100644 --- a/test.js +++ b/test.js @@ -1,5 +1,8 @@ -const getTodayDate = () => { +function getDate52WeeksAgo() { const today = new Date(); - return today.toISOString().split('T')[0].replace(/-/g, ''); -}; -console.log(getTodayDate()); + const weeksAgo = 52 * 7; // 52주 * 7일 + const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); + return date52WeeksAgo; + } + + console.log(getDate52WeeksAgo()); From 1b49e9cf0214d42cc04c7a42f9a17401199210bd Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 12:02:06 +0900 Subject: [PATCH 015/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=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.module.ts | 3 +- .../backend/src/chat/dto/like.response.ts | 31 +++++++++++++ packages/backend/src/chat/like.service.ts | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/chat/dto/like.response.ts create mode 100644 packages/backend/src/chat/like.service.ts diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 4a1a27b5..62dc1c29 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -6,11 +6,12 @@ import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; import { Like } from '@/chat/domain/like.entity'; +import { LikeService } from '@/chat/like.service'; import { StockModule } from '@/stock/stock.module'; @Module({ imports: [TypeOrmModule.forFeature([Chat, Like]), StockModule, SessionModule], controllers: [ChatController], - providers: [ChatGateway, ChatService], + providers: [ChatGateway, ChatService, LikeService], }) export class ChatModule {} diff --git a/packages/backend/src/chat/dto/like.response.ts b/packages/backend/src/chat/dto/like.response.ts new file mode 100644 index 00000000..94fafde6 --- /dev/null +++ b/packages/backend/src/chat/dto/like.response.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class LikeResponse { + @ApiProperty({ + type: Number, + description: '좋아요를 누른 채팅의 ID', + example: 1, + }) + chatId: number; + + @ApiProperty({ + type: Number, + description: '채팅의 좋아요 수', + example: 45, + }) + likeCount: number; + + @ApiProperty({ + type: String, + description: '결과 메시지', + example: 'like chat', + }) + message: string; + + @ApiProperty({ + type: Date, + description: '좋아요를 누른 시간', + example: '2021-08-01T00:00:00', + }) + date: Date; +} diff --git a/packages/backend/src/chat/like.service.ts b/packages/backend/src/chat/like.service.ts new file mode 100644 index 00000000..a58a0104 --- /dev/null +++ b/packages/backend/src/chat/like.service.ts @@ -0,0 +1,46 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { LikeResponse } from '@/chat/dto/like.response'; + +@Injectable() +export class LikeService { + constructor(private readonly dataSource: DataSource) {} + + async toggleLike(userId: number, chatId: number) { + return await this.dataSource.transaction(async (manager) => { + const chat = await this.findChat(chatId, manager); + return await this.saveLike(manager, chat, userId); + }); + } + + private async findChat(chatId: number, manager: EntityManager) { + const chat = await manager.findOne(Chat, { where: { id: chatId } }); + if (!chat) { + throw new BadRequestException('Chat not found'); + } + return chat; + } + + private async saveLike( + manager: EntityManager, + chat: Chat, + userId: number, + ): Promise { + chat.likeCount += 1; + await Promise.all([ + manager.save(Like, { + user: { id: userId }, + chat, + }), + manager.save(Chat, chat), + ]); + return { + likeCount: chat.likeCount, + message: 'like chat', + chatId: chat.id, + date: chat.date.updatedAt, + }; + } +} From 011f4f0379ab6869e4f76218dbb0e31cf3e132d8 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 12:03:18 +0900 Subject: [PATCH 016/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=94=EB=93=9C=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=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 | 20 ++++++++++-- .../src/chat/decorator/like.decorator.ts | 31 +++++++++++++++++++ packages/backend/src/chat/dto/like.request.ts | 13 ++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/chat/decorator/like.decorator.ts create mode 100644 packages/backend/src/chat/dto/like.request.ts diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index f2a5452d..4e2c9371 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -1,16 +1,25 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBadRequestResponse, ApiOkResponse, ApiOperation, } from '@nestjs/swagger'; +import SessionGuard from '@/auth/session/session.guard'; import { ChatService } from '@/chat/chat.service'; +import { ToggleLikeApi } from '@/chat/decorator/like.decorator'; import { ChatScrollRequest } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; +import { LikeRequest } from '@/chat/dto/like.request'; +import { LikeService } from '@/chat/like.service'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { User } from '@/user/domain/user.entity'; @Controller('chat') export class ChatController { - constructor(private readonly chatService: ChatService) {} + constructor( + private readonly chatService: ChatService, + private readonly likeService: LikeService, + ) {} @ApiOperation({ summary: '채팅 스크롤 조회 API', @@ -36,4 +45,11 @@ export class ChatController { request.pageSize, ); } + + @UseGuards(SessionGuard) + @ToggleLikeApi() + @Post('like') + async toggleChatLike(@Body() request: LikeRequest, @GetUser() user: User) { + return await this.likeService.toggleLike(user.id, request.chatId); + } } diff --git a/packages/backend/src/chat/decorator/like.decorator.ts b/packages/backend/src/chat/decorator/like.decorator.ts new file mode 100644 index 00000000..e1a1ea6b --- /dev/null +++ b/packages/backend/src/chat/decorator/like.decorator.ts @@ -0,0 +1,31 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiCookieAuth, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { LikeResponse } from '@/chat/dto/like.response'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function ToggleLikeApi() { + return applyDecorators( + ApiCookieAuth(), + ApiOperation({ + summary: '채팅 좋아요 토글 API', + description: '채팅 좋아요를 토글한다.', + }), + ApiOkResponse({ + description: '좋아요 성공', + type: LikeResponse, + }), + ApiBadRequestResponse({ + description: '채팅이 존재하지 않음', + example: { + message: 'Chat not found', + error: 'Bad Request', + statusCode: 400, + }, + }), + ); +} diff --git a/packages/backend/src/chat/dto/like.request.ts b/packages/backend/src/chat/dto/like.request.ts new file mode 100644 index 00000000..02feec50 --- /dev/null +++ b/packages/backend/src/chat/dto/like.request.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; + +export class LikeRequest { + @ApiProperty({ + required: true, + type: Number, + description: '좋아요를 누를 채팅의 ID', + example: 1, + }) + @IsNumber() + chatId: number; +} From 71d51155be7a6a7480ae5e87b7f5e2de7057cc0b Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 19 Nov 2024 13:25:46 +0900 Subject: [PATCH 017/223] =?UTF-8?q?=F0=9F=92=84=20style:=20=EC=95=88=20?= =?UTF-8?q?=EC=93=B0=EC=9D=B4=EB=8A=94=20bigint=20=EB=8D=B0=EC=BD=94?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?import=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/domain/stockData.entity.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index e9c75407..7f6790d3 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -1,4 +1,3 @@ -import { applyDecorators } from '@nestjs/common'; import { Entity, PrimaryGeneratedColumn, @@ -6,27 +5,9 @@ import { CreateDateColumn, JoinColumn, ManyToOne, - ColumnOptions, } from 'typeorm'; import { Stock } from './stock.entity'; -export const GenerateBigintColumn = ( - options?: ColumnOptions, -): PropertyDecorator => { - return applyDecorators( - Column({ - ...options, - type: 'bigint', - transformer: { - to: (value: bigint): string => - typeof value === 'bigint' ? value.toString() : value, - from: (value: string): bigint => - typeof value === 'string' ? BigInt(value) : value, - }, - }), - ); -}; - export class StockData { @PrimaryGeneratedColumn() id: number; From 8dc2acf226678c0e6dd16c310eae8a59e552cc6b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 14:10:29 +0900 Subject: [PATCH 018/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=B7=A8=EC=86=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/chat/like.service.spec.ts | 68 +++++++++++++++++++ packages/backend/src/chat/like.service.ts | 21 ++++++ 2 files changed, 89 insertions(+) create mode 100644 packages/backend/src/chat/like.service.spec.ts diff --git a/packages/backend/src/chat/like.service.spec.ts b/packages/backend/src/chat/like.service.spec.ts new file mode 100644 index 00000000..1099b7bd --- /dev/null +++ b/packages/backend/src/chat/like.service.spec.ts @@ -0,0 +1,68 @@ +import { createDataSourceMock } from '@/user/user.service.spec'; +import { DataSource } from 'typeorm'; +import { LikeService } from '@/chat/like.service'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; +import { Like } from '@/chat/domain/like.entity'; + +function createChat(): Chat { + return { + stock: new Stock(), + user: new User(), + id: 1, + likeCount: 1, + message: '안녕하세요', + type: 'NORMAL', + date: { + createdAt: new Date(), + updatedAt: new Date(), + }, + }; +} + +describe('LikeService 테스트', () => { + test('존재하지 않는 채팅을 좋아요를 시도하면 예외가 발생한다.', () => { + const managerMock = { + findOne: jest.fn().mockResolvedValue(null), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + expect(likeService.toggleLike(1, 1)).rejects.toThrow('Chat not found'); + }); + + test('특정 채팅에 좋아요를 한다.', async () => { + const chat = createChat(); + const managerMock = { + findOne: jest + .fn() + .mockResolvedValueOnce(chat) + .mockResolvedValueOnce(null), + save: jest.fn(), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + const response = await likeService.toggleLike(1, 1); + + expect(response.likeCount).toBe(2); + }); + + test('특정 채팅에 좋아요를 취소한다.', async () => { + const chat = createChat(); + const managerMock = { + findOne: jest + .fn() + .mockResolvedValueOnce(chat) + .mockResolvedValueOnce(new Like()), + remove: jest.fn(), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + const response = await likeService.toggleLike(1, 1); + + expect(response.likeCount).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/backend/src/chat/like.service.ts b/packages/backend/src/chat/like.service.ts index a58a0104..014111a7 100644 --- a/packages/backend/src/chat/like.service.ts +++ b/packages/backend/src/chat/like.service.ts @@ -11,6 +11,12 @@ export class LikeService { async toggleLike(userId: number, chatId: number) { return await this.dataSource.transaction(async (manager) => { const chat = await this.findChat(chatId, manager); + const like = await manager.findOne(Like, { + where: { user: { id: userId }, chat: { id: chatId } }, + }); + if (like) { + return await this.deleteLike(manager, chat, like); + } return await this.saveLike(manager, chat, userId); }); } @@ -43,4 +49,19 @@ export class LikeService { date: chat.date.updatedAt, }; } + + private async deleteLike( + manager: EntityManager, + chat: Chat, + like: Like, + ): Promise { + chat.likeCount -= 1; + await Promise.all([manager.remove(like), manager.save(Chat, chat)]); + return { + likeCount: chat.likeCount, + message: 'like cancel', + chatId: chat.id, + date: chat.date.updatedAt, + }; + } } From 3a5e55239b48d56f0bebd4d1525e2ea4edda2f2b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 16:56:00 +0900 Subject: [PATCH 019/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=A4=91=EC=9D=B8=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=ED=8C=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.controller.ts | 6 ++- packages/backend/src/chat/chat.gateway.ts | 5 +++ .../backend/src/chat/dto/like.response.ts | 38 +++++++++++++++++++ packages/backend/src/chat/like.service.ts | 19 +++------- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index 4e2c9371..ddcfc87f 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -13,12 +13,14 @@ import { LikeRequest } from '@/chat/dto/like.request'; import { LikeService } from '@/chat/like.service'; import { GetUser } from '@/common/decorator/user.decorator'; import { User } from '@/user/domain/user.entity'; +import { ChatGateway } from '@/chat/chat.gateway'; @Controller('chat') export class ChatController { constructor( private readonly chatService: ChatService, private readonly likeService: LikeService, + private readonly chatGateWay: ChatGateway, ) {} @ApiOperation({ @@ -50,6 +52,8 @@ export class ChatController { @ToggleLikeApi() @Post('like') async toggleChatLike(@Body() request: LikeRequest, @GetUser() user: User) { - return await this.likeService.toggleLike(user.id, request.chatId); + const result = await this.likeService.toggleLike(user.id, request.chatId); + this.chatGateWay.broadcastLike(result); + return result; } } diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index f5fc099e..768fe383 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -17,6 +17,7 @@ import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; +import { LikeResponse } from '@/chat/dto/like.response'; interface chatMessage { room: string; @@ -65,6 +66,10 @@ export class ChatGateway implements OnGatewayConnection { this.server.to(room).emit('chat', this.toResponse(savedChat)); } + async broadcastLike(response: LikeResponse) { + this.server.to(response.stockId).emit('like', response); + } + async handleConnection(client: Socket) { const room = client.handshake.query.stockId; if ( diff --git a/packages/backend/src/chat/dto/like.response.ts b/packages/backend/src/chat/dto/like.response.ts index 94fafde6..5c6ff7fa 100644 --- a/packages/backend/src/chat/dto/like.response.ts +++ b/packages/backend/src/chat/dto/like.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Chat } from '@/chat/domain/chat.entity'; export class LikeResponse { @ApiProperty({ @@ -8,6 +9,13 @@ export class LikeResponse { }) chatId: number; + @ApiProperty({ + type: 'string', + description: '참여 중인 좀목 id', + example: 'A005930', + }) + stockId: string; + @ApiProperty({ type: Number, description: '채팅의 좋아요 수', @@ -28,4 +36,34 @@ export class LikeResponse { example: '2021-08-01T00:00:00', }) date: Date; + + static createLikeResponse(chat: Chat): LikeResponse { + if (!isStockId(chat.stock.id)) { + throw new Error(`Stock id is undefined: ${chat.id}`); + } + return { + stockId: chat.stock.id, + chatId: chat.id, + likeCount: chat.likeCount, + message: 'like chat', + date: chat.date.updatedAt, + }; + } + + static createUnlikeResponse(chat: Chat): LikeResponse { + if (!isStockId(chat.stock.id)) { + throw new Error(`Stock id is undefined: ${chat.id}`); + } + return { + stockId: chat.stock.id, + chatId: chat.id, + likeCount: chat.likeCount, + message: 'like cancel', + date: chat.date.updatedAt, + }; + } } + +function isStockId(stockId?: string): stockId is string { + return stockId !== undefined; +} \ No newline at end of file diff --git a/packages/backend/src/chat/like.service.ts b/packages/backend/src/chat/like.service.ts index 014111a7..a1495905 100644 --- a/packages/backend/src/chat/like.service.ts +++ b/packages/backend/src/chat/like.service.ts @@ -22,7 +22,10 @@ export class LikeService { } private async findChat(chatId: number, manager: EntityManager) { - const chat = await manager.findOne(Chat, { where: { id: chatId } }); + const chat = await manager.findOne(Chat, { + where: { id: chatId }, + relations: ['stock'], + }); if (!chat) { throw new BadRequestException('Chat not found'); } @@ -42,12 +45,7 @@ export class LikeService { }), manager.save(Chat, chat), ]); - return { - likeCount: chat.likeCount, - message: 'like chat', - chatId: chat.id, - date: chat.date.updatedAt, - }; + return LikeResponse.createLikeResponse(chat); } private async deleteLike( @@ -57,11 +55,6 @@ export class LikeService { ): Promise { chat.likeCount -= 1; await Promise.all([manager.remove(like), manager.save(Chat, chat)]); - return { - likeCount: chat.likeCount, - message: 'like cancel', - chatId: chat.id, - date: chat.date.updatedAt, - }; + return LikeResponse.createUnlikeResponse(chat); } } From e26fe57a7391154e1fc0ba2a6b589f59514c983a Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 20:14:13 +0900 Subject: [PATCH 020/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=8B=9C=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=AC=EB=B6=80=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.controller.ts | 22 ++++--- packages/backend/src/chat/chat.gateway.ts | 46 ++++++++++----- .../backend/src/chat/chat.service.spec.ts | 8 ++- packages/backend/src/chat/chat.service.ts | 58 +++++++++++-------- packages/backend/src/chat/dto/chat.request.ts | 25 ++++++++ .../backend/src/chat/dto/chat.response.ts | 3 + 6 files changed, 114 insertions(+), 48 deletions(-) diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index ddcfc87f..84c30070 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBadRequestResponse, ApiOkResponse, @@ -40,12 +48,12 @@ export class ChatController { }, }) @Get() - async findChatList(@Query() request: ChatScrollRequest) { - return await this.chatService.scrollNextChat( - request.stockId, - request.latestChatId, - request.pageSize, - ); + async findChatList( + @Query() request: ChatScrollRequest, + @Req() req: Express.Request, + ) { + const user = req.user as User; + return await this.chatService.scrollNextChat(request, user?.id); } @UseGuards(SessionGuard) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 768fe383..ad600a47 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -18,6 +18,7 @@ import { Chat } from '@/chat/domain/chat.entity'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; import { LikeResponse } from '@/chat/dto/like.response'; +import { ChatScrollQuery, isChatScrollQuery } from '@/chat/dto/chat.request'; interface chatMessage { room: string; @@ -36,6 +37,7 @@ interface chatResponse { export class ChatGateway implements OnGatewayConnection { @WebSocketServer() server: Server; + constructor( @Inject('winston') private readonly logger: Logger, private readonly stockService: StockService, @@ -71,24 +73,40 @@ export class ChatGateway implements OnGatewayConnection { } async handleConnection(client: Socket) { - const room = client.handshake.query.stockId; - if ( - !this.isString(room) || - !(await this.stockService.checkStockExist(room)) - ) { - client.emit('error', 'Invalid stockId'); - this.logger.warn(`client connected with invalid stockId: ${room}`); + try { + const { stockId, pageSize } = await this.getChatScrollQuery(client); + await this.validateExistStock(stockId); + client.join(stockId); + const messages = await this.chatService.scrollFirstChat({ + stockId, + pageSize, + }); + this.logger.info(`client joined room ${stockId}`); + client.emit('chat', messages); + } catch (e) { + const error = e as Error; + this.logger.warn(error.message); + client.emit('error', error.message); client.disconnect(); - return; } - 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 async validateExistStock(stockId: string): Promise { + if (!(await this.stockService.checkStockExist(stockId))) { + throw new Error(`Stock does not exist: ${stockId}`); + } + } + + private async getChatScrollQuery(client: Socket): Promise { + const query = client.handshake.query; + if (!isChatScrollQuery(query)) { + throw new Error('Invalid chat scroll query'); + } + return { + stockId: query.stockId, + latestChatId: query.latestChatId, + pageSize: query.pageSize, + }; } private toResponse(chat: Chat): chatResponse { diff --git a/packages/backend/src/chat/chat.service.spec.ts b/packages/backend/src/chat/chat.service.spec.ts index 7d181061..24c77fae 100644 --- a/packages/backend/src/chat/chat.service.spec.ts +++ b/packages/backend/src/chat/chat.service.spec.ts @@ -8,7 +8,11 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollNextChat('A005930', 1, 101), + chatService.scrollNextChat({ + stockId: 'A005930', + latestChatId: 1, + pageSize: 101, + }), ).rejects.toThrow('pageSize should be less than 100'); }); @@ -17,7 +21,7 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollFirstChat('A005930', 101), + chatService.scrollFirstChat({ stockId: 'A005930', pageSize: 101 }), ).rejects.toThrow('pageSize should be less than 100'); }); }); diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index ef262e7b..18230c2c 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; +import { ChatScrollQuery } from '@/chat/dto/chat.request'; export interface ChatMessage { message: string; @@ -22,19 +23,17 @@ 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); + async scrollFirstChat(chatScrollQuery: ChatScrollQuery, userId?: number) { + const { pageSize } = chatScrollQuery; + this.validatePageSize(pageSize); + const result = await this.findFirstChatScroll(chatScrollQuery, userId); + return await this.toScrollResponse(result, pageSize); } - async scrollNextChat( - stockId: string, - latestChatId?: number, - pageSize?: number, - ) { + async scrollNextChat(chatScrollQuery: ChatScrollQuery, userId?: number) { + const { pageSize } = chatScrollQuery; this.validatePageSize(pageSize); - const result = await this.findChatScroll(stockId, latestChatId, pageSize); + const result = await this.findChatScroll(chatScrollQuery, userId); return await this.toScrollResponse(result, pageSize); } @@ -54,39 +53,48 @@ export class ChatService { } private async findChatScroll( - stockId: string, - latestChatId?: number, - pageSize?: number, + chatScrollQuery: ChatScrollQuery, + userId?: number, ) { - if (!latestChatId) { - return await this.findFirstChatScroll(stockId, pageSize); + if (!chatScrollQuery.latestChatId) { + return await this.findFirstChatScroll(chatScrollQuery, userId); } else { - return await this.findNextChatScroll(stockId, latestChatId, pageSize); + return await this.findNextChatScroll(chatScrollQuery); } } - private async findFirstChatScroll(stockId: string, pageSize?: number) { + private async findFirstChatScroll( + chatScrollQuery: ChatScrollQuery, + userId?: number, + ) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!pageSize) { - pageSize = DEFAULT_PAGE_SIZE; + if (!chatScrollQuery.pageSize) { + chatScrollQuery.pageSize = DEFAULT_PAGE_SIZE; } + const { stockId, pageSize } = chatScrollQuery; return queryBuilder + .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { + userId, + }) .where('chat.stock_id = :stockId', { stockId }) .orderBy('chat.id', 'DESC') - .limit(pageSize + 1) + .take(pageSize + 1) .getMany(); } private async findNextChatScroll( - stockId: string, - latestChatId: number, - pageSize?: number, + chatScrollQuery: ChatScrollQuery, + userId?: number, ) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!pageSize) { - pageSize = DEFAULT_PAGE_SIZE; + if (!chatScrollQuery.pageSize) { + chatScrollQuery.pageSize = DEFAULT_PAGE_SIZE; } + const { stockId, latestChatId, pageSize } = chatScrollQuery; return queryBuilder + .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { + userId, + }) .where('chat.stock_id = :stockId and chat.id < :latestChatId', { stockId, latestChatId, diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index f3260509..35efb3ad 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -28,3 +28,28 @@ export class ChatScrollRequest { @IsNumber() readonly pageSize?: number; } + +export interface ChatScrollQuery { + stockId: string; + latestChatId?: number; + pageSize?: number; +} + +export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { + if (typeof object !== 'object' || object === null) { + return false; + } + + if (!('stockId' in object) || typeof object.stockId !== 'string') { + return false; + } + + if ( + 'latestChatId' in object && + !Number.isInteger(Number(object.latestChatId)) + ) { + return false; + } + + return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); +} diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index 48aacb0d..803b81e6 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -7,6 +7,7 @@ interface ChatResponse { likeCount: number; message: string; type: string; + liked: boolean; createdAt: Date; } @@ -25,6 +26,7 @@ export class ChatScrollResponse { likeCount: 0, message: '안녕하세요', type: ChatType.NORMAL, + isLiked: true, createdAt: new Date(), }, ], @@ -38,6 +40,7 @@ export class ChatScrollResponse { message: chat.message, type: chat.type, createdAt: chat.date!.createdAt, + liked: !!(chat.likes && chat.likes.length > 0), })); this.hasMore = hasMore; } From 28a201b39331f6784a5814efa2f4795803a768e4 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 22:19:21 +0900 Subject: [PATCH 021/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20stock?= =?UTF-8?q?=20=EB=82=B4=EB=B6=80=20=ED=95=84=EB=93=9C=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=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/stock/domain/stock.entity.ts | 12 ++++++------ packages/backend/src/stock/stock.controller.ts | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 3645b33e..429e8812 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -13,10 +13,10 @@ import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() export class Stock { @PrimaryColumn({ name: 'stock_id' }) - id?: string; + id: string; @Column({ name: 'stock_name' }) - name?: string; + name: string; @Column({ default: 0 }) views: number = 0; @@ -25,14 +25,14 @@ export class Stock { isTrading: boolean = true; @Column({ name: 'group_code' }) - groupCode?: string; - - @OneToMany(() => Like, (like) => like.chat) - likes?: Like[]; + groupCode: string; @Column(() => DateEmbedded, { prefix: '' }) date?: DateEmbedded; + @OneToMany(() => Like, (like) => like.chat) + likes?: Like[]; + @OneToMany(() => UserStock, (userStock) => userStock.stock) userStocks?: UserStock[]; diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index ed85f3c1..5aaf6707 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -164,7 +164,6 @@ export class StockController { }) @Get() async searchStock(@Query() request: StockSearchRequest) { - console.log(request.name); return await this.stockService.searchStock(request.name); } From afbf0b3c1f3db4b1923d63f400339f9da32e0724 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 22:23:37 +0900 Subject: [PATCH 022/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20chat?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=BF=BC=EB=A6=AC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20dto=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.controller.ts | 6 +++--- packages/backend/src/chat/chat.service.ts | 2 +- packages/backend/src/chat/dto/chat.request.ts | 12 +++--------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index 84c30070..d295f0c9 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -13,15 +13,15 @@ import { ApiOperation, } from '@nestjs/swagger'; import SessionGuard from '@/auth/session/session.guard'; +import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { ToggleLikeApi } from '@/chat/decorator/like.decorator'; -import { ChatScrollRequest } from '@/chat/dto/chat.request'; +import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; import { LikeRequest } from '@/chat/dto/like.request'; import { LikeService } from '@/chat/like.service'; import { GetUser } from '@/common/decorator/user.decorator'; import { User } from '@/user/domain/user.entity'; -import { ChatGateway } from '@/chat/chat.gateway'; @Controller('chat') export class ChatController { @@ -49,7 +49,7 @@ export class ChatController { }) @Get() async findChatList( - @Query() request: ChatScrollRequest, + @Query() request: ChatScrollQuery, @Req() req: Express.Request, ) { const user = req.user as User; diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 18230c2c..650c5fb4 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,8 +1,8 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; -import { ChatScrollResponse } from '@/chat/dto/chat.response'; import { ChatScrollQuery } from '@/chat/dto/chat.request'; +import { ChatScrollResponse } from '@/chat/dto/chat.response'; export interface ChatMessage { message: string; diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index 35efb3ad..0c68943b 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -1,13 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString } from 'class-validator'; -export class ChatScrollRequest { +export class ChatScrollQuery { @ApiProperty({ description: '종목 주식 id(종목방 id)', example: 'A005930', }) @IsString() - readonly stockId: string; + stockId: string; @ApiProperty({ description: '최신 채팅 id', @@ -16,7 +16,7 @@ export class ChatScrollRequest { }) @IsOptional() @IsNumber() - readonly latestChatId?: number; + latestChatId?: number; @ApiProperty({ description: '페이지 크기', @@ -26,12 +26,6 @@ export class ChatScrollRequest { }) @IsOptional() @IsNumber() - readonly pageSize?: number; -} - -export interface ChatScrollQuery { - stockId: string; - latestChatId?: number; pageSize?: number; } From 5efb9137b905e7f35771ef7005f9c94b5cc009b3 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 23:26:51 +0900 Subject: [PATCH 023/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=B4=88=EA=B8=B0=20=EC=A0=91=EC=86=8D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/google/googleAuth.service.spec.ts | 2 +- packages/backend/src/auth/session.module.ts | 2 +- .../auth/session/webSocketSession.guard..ts | 2 +- .../auth/session/websocketSession.service.ts | 25 +++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/auth/session/websocketSession.service.ts diff --git a/packages/backend/src/auth/google/googleAuth.service.spec.ts b/packages/backend/src/auth/google/googleAuth.service.spec.ts index 3d3f49be..1e714846 100644 --- a/packages/backend/src/auth/google/googleAuth.service.spec.ts +++ b/packages/backend/src/auth/google/googleAuth.service.spec.ts @@ -15,7 +15,7 @@ describe('GoogleAuthService 테스트', () => { }; test('oauthId와 type에 맞는 유저가 있으면 해당 객체를 반환한다.', async () => { - const user: User = { + const user: Partial = { id: 1, role: Role.USER, type: OauthType.GOOGLE, diff --git a/packages/backend/src/auth/session.module.ts b/packages/backend/src/auth/session.module.ts index 07c36bba..ddd58c70 100644 --- a/packages/backend/src/auth/session.module.ts +++ b/packages/backend/src/auth/session.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { MemoryStore } from 'express-session'; -export const MEMORY_STORE = 'memoryStore'; +export const MEMORY_STORE = Symbol('memoryStore'); @Module({ providers: [ diff --git a/packages/backend/src/auth/session/webSocketSession.guard..ts b/packages/backend/src/auth/session/webSocketSession.guard..ts index a19ff956..f2c766a3 100644 --- a/packages/backend/src/auth/session/webSocketSession.guard..ts +++ b/packages/backend/src/auth/session/webSocketSession.guard..ts @@ -15,7 +15,7 @@ export interface SessionSocket extends Socket { session?: User; } -interface PassportSession extends SessionData { +export interface PassportSession extends SessionData { passport: { user: User }; } diff --git a/packages/backend/src/auth/session/websocketSession.service.ts b/packages/backend/src/auth/session/websocketSession.service.ts new file mode 100644 index 00000000..edfd8ea9 --- /dev/null +++ b/packages/backend/src/auth/session/websocketSession.service.ts @@ -0,0 +1,25 @@ +import { MemoryStore } from 'express-session'; +import { Socket } from 'socket.io'; +import { websocketCookieParse } from '@/auth/session/cookieParser'; +import { PassportSession } from '@/auth/session/webSocketSession.guard.'; + +export class WebsocketSessionService { + constructor(private readonly sessionStore: MemoryStore) {} + + async getAuthenticatedUser(socket: Socket) { + const cookieValue = websocketCookieParse(socket); + const session = await this.getSession(cookieValue); + return session ? session.passport.user : undefined; + } + + private getSession(cookieValue: string) { + return new Promise((resolve) => { + this.sessionStore.get(cookieValue, (err: Error, session) => { + if (err || !session) { + resolve(undefined); + } + resolve(session as PassportSession); + }); + }); + } +} From 6c90439809e6ba6c80c1bc585c7911e73f23c47b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 19 Nov 2024 23:28:04 +0900 Subject: [PATCH 024/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=A0=91=EC=86=8D=20=EC=8B=9C=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=AC=EB=B6=80=EB=8F=84=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.gateway.ts | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index ad600a47..21ba2290 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -7,18 +7,21 @@ import { WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; +import { MemoryStore } from 'express-session'; import { Server, Socket } from 'socket.io'; import { Logger } from 'winston'; import { SessionSocket, WebSocketSessionGuard, } from '@/auth/session/webSocketSession.guard.'; +import { WebsocketSessionService } from '@/auth/session/websocketSession.service'; +import { MEMORY_STORE } from '@/auth/session.module'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; +import { ChatScrollQuery, isChatScrollQuery } from '@/chat/dto/chat.request'; +import { LikeResponse } from '@/chat/dto/like.response'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; -import { LikeResponse } from '@/chat/dto/like.response'; -import { ChatScrollQuery, isChatScrollQuery } from '@/chat/dto/chat.request'; interface chatMessage { room: string; @@ -37,12 +40,16 @@ interface chatResponse { export class ChatGateway implements OnGatewayConnection { @WebSocketServer() server: Server; + websocketSessionService: WebsocketSessionService; constructor( @Inject('winston') private readonly logger: Logger, private readonly stockService: StockService, private readonly chatService: ChatService, - ) {} + @Inject(MEMORY_STORE) sessionStore: MemoryStore, + ) { + this.websocketSessionService = new WebsocketSessionService(sessionStore); + } @UseGuards(WebSocketSessionGuard) @SubscribeMessage('chat') @@ -74,13 +81,18 @@ export class ChatGateway implements OnGatewayConnection { async handleConnection(client: Socket) { try { + const user = + await this.websocketSessionService.getAuthenticatedUser(client); const { stockId, pageSize } = await this.getChatScrollQuery(client); await this.validateExistStock(stockId); client.join(stockId); - const messages = await this.chatService.scrollFirstChat({ - stockId, - pageSize, - }); + const messages = await this.chatService.scrollFirstChat( + { + stockId, + pageSize, + }, + user?.id, + ); this.logger.info(`client joined room ${stockId}`); client.emit('chat', messages); } catch (e) { From 74220b1c191169d8d0257078d3ec9f1cd64fc016 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 10:40:35 +0900 Subject: [PATCH 025/223] =?UTF-8?q?=E2=9C=A8=20feat:=20cors=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=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 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 0be16e68..935fea99 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -19,6 +19,10 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true }, }), ); + app.enableCors({ + origin: true, + credentials: true, + }); useSwagger(app); app.use(passport.initialize()); app.use(passport.session()); From c2f20d0518921e4553de973b18c695c80607e37b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 10:41:52 +0900 Subject: [PATCH 026/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...sion.guard..ts => webSocketSession.guard.ts} | 0 .../auth/session/websocketSession.service.ts | 2 +- packages/backend/src/chat/chat.gateway.ts | 6 +++--- packages/backend/src/chat/chat.service.ts | 17 ++++++++--------- 4 files changed, 12 insertions(+), 13 deletions(-) rename packages/backend/src/auth/session/{webSocketSession.guard..ts => webSocketSession.guard.ts} (100%) diff --git a/packages/backend/src/auth/session/webSocketSession.guard..ts b/packages/backend/src/auth/session/webSocketSession.guard.ts similarity index 100% rename from packages/backend/src/auth/session/webSocketSession.guard..ts rename to packages/backend/src/auth/session/webSocketSession.guard.ts diff --git a/packages/backend/src/auth/session/websocketSession.service.ts b/packages/backend/src/auth/session/websocketSession.service.ts index edfd8ea9..10af73c2 100644 --- a/packages/backend/src/auth/session/websocketSession.service.ts +++ b/packages/backend/src/auth/session/websocketSession.service.ts @@ -1,7 +1,7 @@ import { MemoryStore } from 'express-session'; import { Socket } from 'socket.io'; import { websocketCookieParse } from '@/auth/session/cookieParser'; -import { PassportSession } from '@/auth/session/webSocketSession.guard.'; +import { PassportSession } from '@/auth/session/webSocketSession.guard'; export class WebsocketSessionService { constructor(private readonly sessionStore: MemoryStore) {} diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 21ba2290..77db725d 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -13,7 +13,7 @@ import { Logger } from 'winston'; import { SessionSocket, WebSocketSessionGuard, -} from '@/auth/session/webSocketSession.guard.'; +} from '@/auth/session/webSocketSession.guard'; import { WebsocketSessionService } from '@/auth/session/websocketSession.service'; import { MEMORY_STORE } from '@/auth/session.module'; import { ChatService } from '@/chat/chat.service'; @@ -116,8 +116,8 @@ export class ChatGateway implements OnGatewayConnection { } return { stockId: query.stockId, - latestChatId: query.latestChatId, - pageSize: query.pageSize, + latestChatId: query.latestChatId ? Number(query.latestChatId) : undefined, + pageSize: query.pageSize ? Number(query.pageSize) : undefined, }; } diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 650c5fb4..569a44ef 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -24,21 +24,20 @@ export class ChatService { } async scrollFirstChat(chatScrollQuery: ChatScrollQuery, userId?: number) { - const { pageSize } = chatScrollQuery; - this.validatePageSize(pageSize); + this.validatePageSize(chatScrollQuery); const result = await this.findFirstChatScroll(chatScrollQuery, userId); - return await this.toScrollResponse(result, pageSize); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); } async scrollNextChat(chatScrollQuery: ChatScrollQuery, userId?: number) { - const { pageSize } = chatScrollQuery; - this.validatePageSize(pageSize); + this.validatePageSize(chatScrollQuery); const result = await this.findChatScroll(chatScrollQuery, userId); - return await this.toScrollResponse(result, pageSize); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); } - private validatePageSize(scrollSize?: number) { - if (scrollSize && scrollSize > 100) { + private validatePageSize(chatScrollQuery: ChatScrollQuery) { + const { pageSize } = chatScrollQuery; + if (pageSize && pageSize > 100) { throw new BadRequestException('pageSize should be less than 100'); } } @@ -100,7 +99,7 @@ export class ChatService { latestChatId, }) .orderBy('chat.id', 'DESC') - .limit(pageSize + 1) + .take(pageSize + 1) .getMany(); } } From 16e7592c16e302d41ce8521de12632ec6f06ed68 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 11:16:29 +0900 Subject: [PATCH 027/223] =?UTF-8?q?=E2=9C=85=20test:=20datasource=20mock?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=97=90=EB=9F=AC=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/user/user.service.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index a0a3f19d..df80a82c 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -1,9 +1,9 @@ /* eslint-disable max-lines-per-function */ -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; -import { User } from './domain/user.entity'; -import { OauthType } from '@/user/domain/ouathType'; -import { UserService } from '@/user/user.service'; +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { DataSource, EntityManager } from "typeorm"; +import { User } from "./domain/user.entity"; +import { OauthType } from "@/user/domain/ouathType"; +import { UserService } from "@/user/user.service"; export function createDataSourceMock( managerMock?: Partial, @@ -15,7 +15,7 @@ export function createDataSourceMock( }; return { - getRepository: managerMock.getRepository, + getRepository: managerMock?.getRepository, transaction: jest.fn().mockImplementation(async (work) => { return work({ ...defaultManagerMock, ...managerMock }); }), From 5160dc5e151fc3915f27bbef9388325262e83f6f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 11:16:56 +0900 Subject: [PATCH 028/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.controller.ts | 2 +- packages/backend/src/chat/chat.gateway.ts | 2 +- .../backend/src/chat/chat.service.spec.ts | 5 +- packages/backend/src/chat/chat.service.ts | 54 +++++-------------- 4 files changed, 17 insertions(+), 46 deletions(-) diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index d295f0c9..0670c9fa 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -53,7 +53,7 @@ export class ChatController { @Req() req: Express.Request, ) { const user = req.user as User; - return await this.chatService.scrollNextChat(request, user?.id); + return await this.chatService.scrollChat(request, user?.id); } @UseGuards(SessionGuard) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 77db725d..65474edb 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -86,7 +86,7 @@ export class ChatGateway implements OnGatewayConnection { const { stockId, pageSize } = await this.getChatScrollQuery(client); await this.validateExistStock(stockId); client.join(stockId); - const messages = await this.chatService.scrollFirstChat( + const messages = await this.chatService.scrollChat( { stockId, pageSize, diff --git a/packages/backend/src/chat/chat.service.spec.ts b/packages/backend/src/chat/chat.service.spec.ts index 24c77fae..1057ffed 100644 --- a/packages/backend/src/chat/chat.service.spec.ts +++ b/packages/backend/src/chat/chat.service.spec.ts @@ -8,9 +8,8 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollNextChat({ + chatService.scrollChat({ stockId: 'A005930', - latestChatId: 1, pageSize: 101, }), ).rejects.toThrow('pageSize should be less than 100'); @@ -21,7 +20,7 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollFirstChat({ stockId: 'A005930', pageSize: 101 }), + chatService.scrollChat({ stockId: 'A005930', pageSize: 101 }), ).rejects.toThrow('pageSize should be less than 100'); }); }); diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 569a44ef..3b9ae9d3 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -23,13 +23,7 @@ export class ChatService { }); } - async scrollFirstChat(chatScrollQuery: ChatScrollQuery, userId?: number) { - this.validatePageSize(chatScrollQuery); - const result = await this.findFirstChatScroll(chatScrollQuery, userId); - return await this.toScrollResponse(result, chatScrollQuery.pageSize); - } - - async scrollNextChat(chatScrollQuery: ChatScrollQuery, userId?: number) { + async scrollChat(chatScrollQuery: ChatScrollQuery, userId?: number) { this.validatePageSize(chatScrollQuery); const result = await this.findChatScroll(chatScrollQuery, userId); return await this.toScrollResponse(result, chatScrollQuery.pageSize); @@ -55,51 +49,29 @@ export class ChatService { chatScrollQuery: ChatScrollQuery, userId?: number, ) { - if (!chatScrollQuery.latestChatId) { - return await this.findFirstChatScroll(chatScrollQuery, userId); - } else { - return await this.findNextChatScroll(chatScrollQuery); - } + const queryBuilder = this.buildChatScrollQuery(chatScrollQuery, userId); + return queryBuilder.getMany(); } - private async findFirstChatScroll( + private buildChatScrollQuery( chatScrollQuery: ChatScrollQuery, userId?: number, ) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!chatScrollQuery.pageSize) { - chatScrollQuery.pageSize = DEFAULT_PAGE_SIZE; - } - const { stockId, pageSize } = chatScrollQuery; - return queryBuilder + const { stockId, latestChatId, pageSize } = chatScrollQuery; + const size = pageSize ? pageSize : DEFAULT_PAGE_SIZE; + + queryBuilder .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { userId, }) .where('chat.stock_id = :stockId', { stockId }) .orderBy('chat.id', 'DESC') - .take(pageSize + 1) - .getMany(); - } - - private async findNextChatScroll( - chatScrollQuery: ChatScrollQuery, - userId?: number, - ) { - const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!chatScrollQuery.pageSize) { - chatScrollQuery.pageSize = DEFAULT_PAGE_SIZE; + .take(size + 1); + if (latestChatId) { + queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); } - const { stockId, latestChatId, pageSize } = chatScrollQuery; - return queryBuilder - .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { - userId, - }) - .where('chat.stock_id = :stockId and chat.id < :latestChatId', { - stockId, - latestChatId, - }) - .orderBy('chat.id', 'DESC') - .take(pageSize + 1) - .getMany(); + + return queryBuilder; } } From 6a56cc128fcf59e1aff88b7b3c31c80535b13dfb Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 11:26:27 +0900 Subject: [PATCH 029/223] =?UTF-8?q?=E2=9C=A8=20feat:=20detail=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 132 ++++++++++++------ .../openapi/api/openapiMinuteData.api.ts | 2 +- .../openapi/api/openapiPeriodData.api.ts | 4 +- .../scraper/openapi/openapi-scraper.module.ts | 26 +++- .../openapi/type/openapiDetailData.type.ts | 64 ++++----- test.js | 8 -- yarn.lock | 65 ++++----- 7 files changed, 174 insertions(+), 127 deletions(-) delete mode 100644 test.js diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 77fa6feb..fb07ec1d 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -1,38 +1,36 @@ import { Cron } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { Between, DataSource } from 'typeorm'; import { openApiConfig } from '../config/openapi.config'; import { getOpenApi } from '../openapiUtil.api'; import { DetailDataQuery, FinancialData, - FinancialDetail, isFinancialData, - isFinancialDetail, isProductDetail, ProductDetail, StockDetailQuery, } from '../type/openapiDetailData.type'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; -import { StockDetail } from '@/stock/domain/stockDetail.entity'; import { StockDaily } from '@/stock/domain/stockData.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; export class OpenapiDetailData { private readonly financialUrl: string = '/uapi/domestic-stock/v1/finance/financial-ratio'; private readonly defaultUrl: string = '/uapi/domestic-stock/v1/quotations/search-stock-info'; - private readonly incomeUrl: string = '/uapi/domestic-stock/v1/finance/income-statement'; - private readonly intervals = 4000; + private readonly incomeUrl: string = + '/uapi/domestic-stock/v1/finance/income-statement'; + private readonly intervals = 100; private readonly config: (typeof openApiConfig)[] = openApiToken.configs; constructor(private readonly datasource: DataSource) {} - @Cron('10 1 * * 1-5') + @Cron('0 8 * * 1-5') public async getDetailData() { const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); const configCount = this.config.length; - const chunkSize = Math.ceil(stocks.length / configCount); for (let i = 0; i < configCount; i++) { @@ -41,47 +39,104 @@ export class OpenapiDetailData { } } - private async saveDetailData(output1: FinancialData, output2: ProductDetail, output3 : StockDaily[]) { + private async saveDetailData(stockDetail: StockDetail) { const entityManager = this.datasource.manager; const entity = StockDetail; - entityManager.create(entity, output1); + entityManager.create(entity, stockDetail); + } + + private async calPer(eps: number): Promise { + if (eps <= 0) return NaN; + const manager = this.datasource.manager; + const latestResult = await manager.find(StockDaily, { + skip: 0, + take: 1, + order: { createdAt: 'desc' }, + }); + const currentPrice = latestResult[0].close; + const per = currentPrice / eps; + + return per; + } + + private async calMarketCap(lstg: number) { + const manager = this.datasource.manager; + const latestResult = await manager.find(StockDaily, { + skip: 0, + take: 1, + order: { createdAt: 'desc' }, + }); + const currentPrice = latestResult[0].close; + const marketCap = lstg * currentPrice; + return marketCap; } - private makeStockDetailObject( - output1: FinancialDetail, + private async get52WeeksLowHigh() { + const manager = this.datasource.manager; + const nowDate = new Date(); + const weeksAgoDate = this.getDate52WeeksAgo(); + // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 + const output = await manager.find(StockDaily, { + select: ['low', 'high'], + where: { + startTime: Between(weeksAgoDate, nowDate), + }, + }); + const result = output.reduce((prev, cur) => { + if (prev.low > cur.low) prev.low = cur.low; + if (prev.high < cur.high) prev.high = cur.high; + return cur; + }, new StockDaily()); + return { low: result.low, high: result.high }; + } + + private async makeStockDetailObject( + output1: FinancialData, output2: ProductDetail, - ): StockDetail { + ): Promise { const result = new StockDetail(); - result.marketCap = output2. + result.marketCap = + (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; + result.eps = parseInt(output1.eps); + const { low, high } = await this.get52WeeksLowHigh(); + result.low52w = low; + result.high52w = high; + result.eps = parseInt(output1.eps); + result.per = await this.calPer(parseInt(output1.eps)); + result.updatedAt = new Date(); return result; } + private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { + const dataQuery = this.getDetailDataQuery(stock.id!); + const defaultQuery = this.getDefaultDataQuery(stock.id!); + + // 여기서 가져올 건 eps -> eps와 per 계산하자. + const output1 = await getOpenApi( + this.incomeUrl, + conf, + dataQuery, + 'FHKST66430200', + ); + // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 + const output2 = await getOpenApi( + this.defaultUrl, + conf, + defaultQuery, + 'CTPF1002R', + ); + + if (isFinancialData(output1) && isProductDetail(output2)) { + const stockDetail = await this.makeStockDetailObject(output1, output2); + this.saveDetailData(stockDetail); + } + } + private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { - const manager = this.datasource.manager; + let delay = 0; for (const stock of chunk) { - const dataQuery = this.getDetailDataQuery(stock.id!); - const defaultQuery = this.getDefaultDataQuery(stock.id!); - // 여기서 가져올 건 eps -> eps와 per 계산하자. - const output1 = await getOpenApi(this.incomeUrl, conf, dataQuery, 'FHKST66430200'); - // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 - const output2 = await getOpenApi( - this.defaultUrl, - conf, - defaultQuery, - 'CTPF1002R', - ); - // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 - const output3 = await manager.find(StockDaily, { - select: { - - }, - where: { - - } - }) - // 주식 마지막 데이터 끌고 오기. 최신 데이터로. - if ( isProductDetail(output1)) { - } + setTimeout(() => this.getDetailDataDelay(stock, conf), delay); + delay += this.intervals; } } @@ -114,5 +169,4 @@ export class OpenapiDetailData { date52WeeksAgo.setHours(0, 0, 0, 0); return date52WeeksAgo; } - } diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index cbc43334..e1532afa 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { openApiConfig } from '../config/openapi.config'; @@ -10,7 +11,6 @@ import { import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; -import { Injectable } from '@nestjs/common'; @Injectable() export class OpenapiMinuteData { diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 3eac067f..5af605b1 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { getOpenApi, getPreviousDate, getTodayDate } from '../openapiUtil.api'; @@ -16,7 +17,6 @@ import { StockMonthly, StockYearly, } from '@/stock/domain/stockData.entity'; -import { Injectable } from '@nestjs/common'; const DATE_TO_ENTITY = { D: StockDaily, @@ -38,7 +38,7 @@ const INTERVALS = 4000; export class OpenapiPeriodData { private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; - public constructor(private readonly datasourse: DataSource) { + public constructor(private readonly datasource: DataSource) { this.getItemChartPriceCheck(); } diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 00dca421..3e79d214 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,16 +1,32 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiScraperService } from './openapi-scraper.service'; -import { DataSource } from 'typeorm'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { Stock } from '@/stock/domain/stock.entity'; -import { StockDaily, StockMinutely, StockMonthly, StockWeekly, StockYearly } from '@/stock/domain/stockData.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { + StockDaily, + StockMinutely, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Stock, StockMinutely , StockDaily, StockWeekly, StockMonthly, StockYearly, StockLiveData, StockDetail])], + imports: [ + TypeOrmModule.forFeature([ + Stock, + StockMinutely, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, + StockLiveData, + StockDetail, + ]), + ], controllers: [], providers: [OpenapiPeriodData, OpenapiMinuteData, OpenapiScraperService], }) diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index c5642b3a..f05edcea 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -178,36 +178,36 @@ export type StockDetailQuery = { code: string; }; -export type FinancialDetail = { - stac_yymm: string; // 결산 년월 - sale_account: string; // 매출액 - sale_cost: string; // 매출원가 - sale_totl_prfi: string; // 매출총이익 - depr_cost: string; // 감가상각비 - sell_mang: string; // 판매관리비 - bsop_prti: string; // 영업이익 - bsop_non_ernn: string; // 영업외수익 - bsop_non_expn: string; // 영업외비용 - op_prfi: string; // 영업이익 - spec_prfi: string; // 특별이익 - spec_loss: string; // 특별손실 - thtr_ntin: string; // 세전순이익 -}; +//export type FinancialDetail = { +// stac_yymm: string; // 결산 년월 +// sale_account: string; // 매출액 +// sale_cost: string; // 매출원가 +// sale_totl_prfi: string; // 매출총이익 +// depr_cost: string; // 감가상각비 +// sell_mang: string; // 판매관리비 +// bsop_prti: string; // 영업이익 +// bsop_non_ernn: string; // 영업외수익 +// bsop_non_expn: string; // 영업외비용 +// op_prfi: string; // 영업이익 +// spec_prfi: string; // 특별이익 +// spec_loss: string; // 특별손실 +// thtr_ntin: string; // 세전순이익 +//}; -export const isFinancialDetail = (data: any): data is FinancialDetail => { - return ( - typeof data.stac_yymm === 'string' && - typeof data.sale_account === 'string' && - typeof data.sale_cost === 'string' && - typeof data.sale_totl_prfi === 'string' && - typeof data.depr_cost === 'string' && - typeof data.sell_mang === 'string' && - typeof data.bsop_prti === 'string' && - typeof data.bsop_non_ernn === 'string' && - typeof data.bsop_non_expn === 'string' && - typeof data.op_prfi === 'string' && - typeof data.spec_prfi === 'string' && - typeof data.spec_loss === 'string' && - typeof data.thtr_ntin === 'string' - ); -}; +//export const isFinancialDetail = (data: any): data is FinancialDetail => { +// return ( +// typeof data.stac_yymm === 'string' && +// typeof data.sale_account === 'string' && +// typeof data.sale_cost === 'string' && +// typeof data.sale_totl_prfi === 'string' && +// typeof data.depr_cost === 'string' && +// typeof data.sell_mang === 'string' && +// typeof data.bsop_prti === 'string' && +// typeof data.bsop_non_ernn === 'string' && +// typeof data.bsop_non_expn === 'string' && +// typeof data.op_prfi === 'string' && +// typeof data.spec_prfi === 'string' && +// typeof data.spec_loss === 'string' && +// typeof data.thtr_ntin === 'string' +// ); +//}; diff --git a/test.js b/test.js deleted file mode 100644 index ea3c8120..00000000 --- a/test.js +++ /dev/null @@ -1,8 +0,0 @@ -function getDate52WeeksAgo() { - const today = new Date(); - const weeksAgo = 52 * 7; // 52주 * 7일 - const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); - return date52WeeksAgo; - } - - console.log(getDate52WeeksAgo()); diff --git a/yarn.lock b/yarn.lock index 10d4e991..b8b27206 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1199,11 +1199,6 @@ resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== -"@microsoft/tsdoc@^0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz#f29a55df17cb6e87cfbabce33ff6a14a9f85076d" - integrity sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA== - "@mdx-js/react@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-3.1.0.tgz#c4522e335b3897b9a845db1dbdd2f966ae8fb0ed" @@ -1211,6 +1206,11 @@ dependencies: "@types/mdx" "^2.0.0" +"@microsoft/tsdoc@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz#f29a55df17cb6e87cfbabce33ff6a14a9f85076d" + integrity sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA== + "@nestjs/cli@^10.0.0": version "10.4.5" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.4.5.tgz#d6563b87e8ca1d0f256c19a7847dbcc96c76a88e" @@ -2000,7 +2000,6 @@ dependencies: "@types/node" "*" -"@types/estree@1.0.6", "@types/estree@^1.0.5", "@types/estree@^1.0.6": "@types/doctrine@^0.0.9": version "0.0.9" resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.9.tgz#d86a5f452a15e3e3113b99e39616a9baa0f9863f" @@ -2260,16 +2259,16 @@ dependencies: "@types/node" "*" -"@types/validator@^13.11.8": - version "13.12.2" - resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" - integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== - "@types/uuid@^9.0.1": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== +"@types/validator@^13.11.8": + version "13.12.2" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" + integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -3022,13 +3021,6 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -better-opn@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" - integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ== - dependencies: - open "^8.0.4" - base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" @@ -3039,6 +3031,13 @@ base64url@3.x.x: resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== +better-opn@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" + integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ== + dependencies: + open "^8.0.4" + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -6151,13 +6150,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - jsdoc-type-pratt-parser@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" @@ -6734,6 +6726,13 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +nest-winston@^1.9.7: + version "1.9.7" + resolved "https://registry.yarnpkg.com/nest-winston/-/nest-winston-1.9.7.tgz#1ef6eb2459ce595655de37d5beb900d2e75b61d3" + integrity sha512-pTTgImRgv7urojsDvaTlenAjyJNLj7ywamfjzrhWKhLhp80AKLYNwf103dVHeqZWe+nzp/vd9DGRs/UN/YadOQ== + dependencies: + fast-safe-stringify "^2.1.1" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -6742,13 +6741,6 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" -nest-winston@^1.9.7: - version "1.9.7" - resolved "https://registry.yarnpkg.com/nest-winston/-/nest-winston-1.9.7.tgz#1ef6eb2459ce595655de37d5beb900d2e75b61d3" - integrity sha512-pTTgImRgv7urojsDvaTlenAjyJNLj7ywamfjzrhWKhLhp80AKLYNwf103dVHeqZWe+nzp/vd9DGRs/UN/YadOQ== - dependencies: - fast-safe-stringify "^2.1.1" - node-abort-controller@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" @@ -7485,7 +7477,6 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -reflect-metadata@^0.2.0: reflect-metadata@^0.2.0, reflect-metadata@^0.2.1: version "0.2.2" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" @@ -8705,7 +8696,6 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" @@ -8717,16 +8707,11 @@ util@^0.12.5: is-typed-array "^1.1.3" which-typed-array "^1.1.2" -utils-merge@1.0.1: +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - uuid@10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" From 8f5f7729fd612a129774320378c2be3bf2d6fb98 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 11:38:37 +0900 Subject: [PATCH 030/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=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.service.ts | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 3b9ae9d3..95412659 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -29,6 +29,26 @@ export class ChatService { return await this.toScrollResponse(result, chatScrollQuery.pageSize); } + async scrollChatByLike(chatScrollQuery: ChatScrollQuery, userId?: number) { + this.validatePageSize(chatScrollQuery); + const result = await this.findChatScrollOrderByLike( + chatScrollQuery, + userId, + ); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); + } + + async findChatScrollOrderByLike( + chatScrollQuery: ChatScrollQuery, + userId?: number, + ) { + const queryBuilder = await this.buildChatScrollByLikeQuery( + chatScrollQuery, + userId, + ); + return queryBuilder.getMany(); + } + private validatePageSize(chatScrollQuery: ChatScrollQuery) { const { pageSize } = chatScrollQuery; if (pageSize && pageSize > 100) { @@ -53,6 +73,41 @@ export class ChatService { return queryBuilder.getMany(); } + private async buildChatScrollByLikeQuery( + chatScrollQuery: ChatScrollQuery, + userId?: number, + ) { + const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); + const { stockId, latestChatId, pageSize } = chatScrollQuery; + const size = pageSize ? pageSize : DEFAULT_PAGE_SIZE; + + queryBuilder + .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { + userId, + }) + .where('chat.stock_id = :stockId', { stockId }) + .orderBy('chat.likeCount', 'DESC') + .addOrderBy('chat.id', 'DESC') + .take(size + 1); + if (latestChatId) { + const chat = await this.dataSource.manager.findOne(Chat, { + where: { id: latestChatId }, + select: ['likeCount'], + }); + if (chat) { + queryBuilder.andWhere( + 'chat.likeCount < :likeCount or (chat.likeCount = :likeCount and chat.id < :latestChatId)', + { + likeCount: chat.likeCount, + latestChatId, + }, + ); + } + } + + return queryBuilder; + } + private buildChatScrollQuery( chatScrollQuery: ChatScrollQuery, userId?: number, From cdcaf84a5b20446a82972053b47fd374f9066dfd Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 11:39:11 +0900 Subject: [PATCH 031/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=88=9C=20=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=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 | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index 0670c9fa..35ce3a3b 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -64,4 +64,29 @@ export class ChatController { this.chatGateWay.broadcastLike(result); return result; } + + @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('/like') + async findChatListByLike( + @Query() request: ChatScrollQuery, + @Req() req: Express.Request, + ) { + const user = req.user as User; + return await this.chatService.scrollChatByLike(request, user?.id); + } } From 8e27f1307ddbe1f951776ef7d6c8baba82ba0580 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 11:41:08 +0900 Subject: [PATCH 032/223] =?UTF-8?q?=E2=9C=A8=20feat:=20chat=20likeCount=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=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/chat/domain/chat.entity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index a1bab9ca..2a5ab380 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, + Index, JoinColumn, ManyToOne, OneToMany, @@ -34,6 +35,7 @@ export class Chat { @Column({ type: 'enum', enum: ChatType, default: ChatType.NORMAL }) type: ChatType = ChatType.NORMAL; + @Index() @Column({ name: 'like_count', default: 0 }) likeCount: number = 0; From 0dd1b70f9b90a9b32bd3bb4313bcfc12e0f6af19 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 12:02:56 +0900 Subject: [PATCH 033/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EB=B9=8C=EB=8D=94=20=EC=A4=91=EB=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.ts | 67 +++++++++++++---------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 95412659..c9431a8d 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, SelectQueryBuilder } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; @@ -9,6 +9,13 @@ export interface ChatMessage { stockId: string; } +const ORDER = { + LIKE: 'like', + LATEST: 'latest', +} as const; + +export type Order = (typeof ORDER)[keyof typeof ORDER]; + const DEFAULT_PAGE_SIZE = 20; @Injectable() @@ -42,9 +49,10 @@ export class ChatService { chatScrollQuery: ChatScrollQuery, userId?: number, ) { - const queryBuilder = await this.buildChatScrollByLikeQuery( + const queryBuilder = await this.buildChatScrollQuery( chatScrollQuery, userId, + ORDER.LIKE, ); return queryBuilder.getMany(); } @@ -69,13 +77,17 @@ export class ChatService { chatScrollQuery: ChatScrollQuery, userId?: number, ) { - const queryBuilder = this.buildChatScrollQuery(chatScrollQuery, userId); + const queryBuilder = await this.buildChatScrollQuery( + chatScrollQuery, + userId, + ); return queryBuilder.getMany(); } - private async buildChatScrollByLikeQuery( + private async buildChatScrollQuery( chatScrollQuery: ChatScrollQuery, userId?: number, + order: Order = ORDER.LATEST, ) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); const { stockId, latestChatId, pageSize } = chatScrollQuery; @@ -86,9 +98,26 @@ export class ChatService { userId, }) .where('chat.stock_id = :stockId', { stockId }) - .orderBy('chat.likeCount', 'DESC') - .addOrderBy('chat.id', 'DESC') .take(size + 1); + + if (order === ORDER.LIKE) { + return this.buildLikeCountQuery(queryBuilder, latestChatId); + } + queryBuilder.orderBy('chat.id', 'DESC'); + if (latestChatId) { + queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); + } + + return queryBuilder; + } + + private async buildLikeCountQuery( + queryBuilder: SelectQueryBuilder, + latestChatId?: number, + ) { + queryBuilder + .orderBy('chat.likeCount', 'DESC') + .addOrderBy('chat.id', 'DESC'); if (latestChatId) { const chat = await this.dataSource.manager.findOne(Chat, { where: { id: latestChatId }, @@ -96,7 +125,8 @@ export class ChatService { }); if (chat) { queryBuilder.andWhere( - 'chat.likeCount < :likeCount or (chat.likeCount = :likeCount and chat.id < :latestChatId)', + 'chat.likeCount < :likeCount or' + + ' (chat.likeCount = :likeCount and chat.id < :latestChatId)', { likeCount: chat.likeCount, latestChatId, @@ -104,29 +134,6 @@ export class ChatService { ); } } - - return queryBuilder; - } - - private buildChatScrollQuery( - chatScrollQuery: ChatScrollQuery, - userId?: number, - ) { - const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - const { stockId, latestChatId, pageSize } = chatScrollQuery; - const size = pageSize ? pageSize : DEFAULT_PAGE_SIZE; - - queryBuilder - .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { - userId, - }) - .where('chat.stock_id = :stockId', { stockId }) - .orderBy('chat.id', 'DESC') - .take(size + 1); - if (latestChatId) { - queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); - } - return queryBuilder; } } From 5e7e4b09c3c9974828f4093ef8d53e6d865d8341 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 12:04:21 +0900 Subject: [PATCH 034/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EC=86=8C=EC=9C=A0=20=ED=99=95=EC=9D=B8=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EB=8F=84=EB=A1=9D=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/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 5aaf6707..6f9e1dd4 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -140,7 +140,7 @@ export class StockController { }) @Get('user/ownership') async checkOwnership( - @Body() body: UserStockRequest, + @Query() body: UserStockRequest, @Req() request: Request, ) { const user = request.user as User; From ca6f156dec878a46375161aba90ebc04b5040ad3 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 12:39:36 +0900 Subject: [PATCH 035/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20tR=5FI?= =?UTF-8?q?DS=EB=A1=9C=20=EB=A6=AC=ED=84=B0=EB=9F=B4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/scraper/openapi/type/openapiUtil.type.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index df05e35a..bd2e3edd 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -1,7 +1,13 @@ export type TR_ID = | 'FHKST03010100' + | 'FHKST03010200' | 'FHKST66430200' | 'HHKDB669107C0' | 'CTPF1002R'; -export const DEFAULT_TR_ID: TR_ID = 'FHKST03010100'; +export const TR_IDS: Record = { + ITEM_CHART_PRICE: 'FHKST03010100', + MINUTE_DATA: 'FHKST03010200', + FINANCIAL_DATA: 'FHKST66430200', + PRODUCTION_DETAIL: 'CTPF1002R', +}; From 2429423cc9d7a46a95a0bf00cb05add5b76f2fe3 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 14:22:17 +0900 Subject: [PATCH 036/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20production=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=9D=BC=EB=95=8C=EC=97=90=EB=A7=8C=20?= =?UTF-8?q?=EC=9E=91=EB=8F=99=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/openapi/openapi-scraper.module.ts | 4 +--- .../scraper/openapi/openapi-scraper.service.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 3e79d214..9024497e 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,7 +1,5 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { OpenapiMinuteData } from './api/openapiMinuteData.api'; -import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiScraperService } from './openapi-scraper.service'; import { Stock } from '@/stock/domain/stock.entity'; import { @@ -28,6 +26,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; ]), ], controllers: [], - providers: [OpenapiPeriodData, OpenapiMinuteData, OpenapiScraperService], + providers: [OpenapiScraperService], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts index 98f27a34..eb2b727b 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts @@ -1,13 +1,19 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; @Injectable() export class OpenapiScraperService { - public constructor( - private readonly datasourse: DataSource, - private readonly openapiPeriodData: OpenapiPeriodData, - private readonly openapiMinuteData: OpenapiMinuteData, - ) {} + private readonly openapiPeriodData: OpenapiPeriodData; + private readonly openapiMinuteData: OpenapiMinuteData; + private readonly openapiDetailData: OpenapiDetailData; + public constructor(private datasource: DataSource) { + if (process.env.NODE_ENV === 'production') { + this.openapiPeriodData = new OpenapiPeriodData(datasource); + this.openapiMinuteData = new OpenapiMinuteData(datasource); + this.openapiDetailData = new OpenapiDetailData(datasource); + } + } } From b7731690fd6c3a5ea7b25121facf8450b5f55dc1 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 14:22:45 +0900 Subject: [PATCH 037/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=A6=AC=ED=84=B0=EB=9F=B4=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20tr=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korea-stock-info/entities/stock.entity.ts | 23 -------- .../korea-stock-info.service.ts | 2 +- .../openapi/api/openapiDetailData.api.ts | 9 ++-- .../openapi/api/openapiMinuteData.api.ts | 53 ++++++++++++++----- .../openapi/api/openapiPeriodData.api.ts | 2 + .../src/scraper/openapi/openapiUtil.api.ts | 4 +- 6 files changed, 49 insertions(+), 44 deletions(-) delete mode 100644 packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts diff --git a/packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts b/packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts deleted file mode 100644 index ad721147..00000000 --- a/packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; - -//TODO : entity update require -@Entity() -export class Master { - @PrimaryGeneratedColumn({ type: 'int', unsigned: true }) - id?: number; - - @Column() - shortCode?: string; - - @Column() - standardCode?: string; - - @Column() - koreanName?: string; - - @Column() - groupCode?: string; - - @Column() - marketCapSize?: string; -} diff --git a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts index 618bb349..430b9f6a 100644 --- a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts +++ b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts @@ -21,7 +21,7 @@ export class KoreaStockInfoService { private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { - //this.initKoreaStockInfo(); + this.initKoreaStockInfo(); } private async existsStockInfo(stockId: string, manager: EntityManager) { diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index fb07ec1d..f8e3cb92 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -10,6 +10,7 @@ import { ProductDetail, StockDetailQuery, } from '../type/openapiDetailData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily } from '@/stock/domain/stockData.entity'; @@ -40,9 +41,9 @@ export class OpenapiDetailData { } private async saveDetailData(stockDetail: StockDetail) { - const entityManager = this.datasource.manager; + const manager = this.datasource.manager; const entity = StockDetail; - entityManager.create(entity, stockDetail); + manager.save(entity, stockDetail); } private async calPer(eps: number): Promise { @@ -116,14 +117,14 @@ export class OpenapiDetailData { this.incomeUrl, conf, dataQuery, - 'FHKST66430200', + TR_IDS.FINANCIAL_DATA, ); // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 const output2 = await getOpenApi( this.defaultUrl, conf, defaultQuery, - 'CTPF1002R', + TR_IDS.PRODUCTION_DETAIL, ); if (isFinancialData(output1) && isProductDetail(output2)) { diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index e1532afa..26ea64e4 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -8,6 +8,7 @@ import { MinuteData, UpdateStockQuery, } from '../type/openapiMinuteData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; @@ -18,11 +19,14 @@ export class OpenapiMinuteData { private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - public constructor(private readonly datasourse: DataSource) {} + private readonly intervals: number = 60; + public constructor(private readonly datasource: DataSource) { + this.getStockData().then(() => this.getMinuteData()); + } @Cron('0 1 * * 1-5') private async getStockData() { - this.stock = await this.datasourse.manager.findBy(Stock, { + this.stock = await this.datasource.manager.findBy(Stock, { isTrading: true, }); } @@ -44,28 +48,49 @@ export class OpenapiMinuteData { return stockPeriod; } - private async saveMinuteData(stockPeriod: StockMinutely) { - const manager = this.datasourse.manager; - manager.create(this.entity, stockPeriod); + private async saveMinuteData(stockId: string, item: MinuteData) { + const manager = this.datasource.manager; + const stockPeriod = this.convertResToMinuteData(stockId, item); + manager.save(this.entity, stockPeriod); + } + + private async getMinuteDataInterval( + stockId: string, + time: string, + config: typeof openApiConfig, + ) { + const query = this.getUpdateStockQuery(stockId, time); + console.log(query); + const response = await getOpenApi( + this.url, + config, + query, + TR_IDS.MINUTE_DATA, + ); + let output; + if (response.output2) output = response.output2; + if (output && output[0] && isMinuteData(output[0])) { + this.saveMinuteData(stockId, output[0]); + } } private async getMinuteDataChunk( chunk: Stock[], config: typeof openApiConfig, ) { + const time = getCurrentTime(); + let interval = 0; for await (const stock of chunk) { - const time = getCurrentTime(); - const query = this.getUpdateStockQuery(stock.id!, time); - const response = await getOpenApi(this.url, config, query); - const output = (await response.data).output2[0] as MinuteData; - if (output && isMinuteData(output)) { - const stockPeriod = this.convertResToMinuteData(stock.id!, output); - this.saveMinuteData(stockPeriod); - } + setTimeout( + () => this.getMinuteDataInterval(stock.id!, time, config), + interval, + ); + interval += this.intervals; } } - @Cron('* 9-16 * * 1-5') + @Cron('* 9-14 * * 1-5') + @Cron('0-30 15 * * 1-5') private getMinuteData() { const configCount = openApiToken.configs.length; const chunkSize = Math.ceil(this.stock.length / configCount); diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 5af605b1..c65b7789 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -8,6 +8,7 @@ import { ItemChartPriceQuery, Period, } from '../type/openapiPeriodData'; +import { TR_IDS } from '../type/openapiUtil.type'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { @@ -123,6 +124,7 @@ export class OpenapiPeriodData { this.url, openApiToken.configs[configIdx], query, + TR_IDS.ITEM_CHART_PRICE, ); return response.output2 as ChartData[]; } diff --git a/packages/backend/src/scraper/openapi/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/openapiUtil.api.ts index 06ae8c23..9ce04bd2 100644 --- a/packages/backend/src/scraper/openapi/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/openapiUtil.api.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import { openApiConfig } from './config/openapi.config'; -import { DEFAULT_TR_ID, TR_ID } from './type/openapiUtil.type'; +import { TR_ID } from './type/openapiUtil.type'; const postOpenApi = async ( url: string, @@ -19,7 +19,7 @@ const getOpenApi = async ( url: string, config: typeof openApiConfig, query: object, - tr_id: TR_ID = DEFAULT_TR_ID, + tr_id: TR_ID, ) => { try { const response = await axios.get(config.STOCK_URL + url, { From 8f30d02592da26024ce4da1a8bb3a5a23025038e Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 14:56:39 +0900 Subject: [PATCH 038/223] =?UTF-8?q?=E2=9C=A8=20feat:=20token=20retry=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/openapi/api/openapiToken.api.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index c4df3cdf..e5c230cf 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,8 +1,9 @@ -import { Inject, NotFoundException } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { postOpenApi } from '../openapiUtil.api'; +import { OpenapiException } from '../util/openapiCustom.error'; +import { postOpenApi } from '../util/openapiUtil.api'; import { logger } from '@/configs/logger.config'; class OpenapiTokenApi { @@ -34,8 +35,26 @@ class OpenapiTokenApi { } private async initAuthenValue() { - await this.initAccessToken(); - await this.initWebSocketKey(); + const delay = 60000; + const delayMinute = delay / 1000 / 60; + + try { + await this.initAccessToken(); + await this.initWebSocketKey(); + } catch (error) { + if (error instanceof Error) { + this.logger.warn( + `Request failed: ${error.message}. Retrying in ${delayMinute} minute...`, + ); + } else { + this.logger.warn( + `Request failed. Retrying in ${delayMinute} minute...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + await this.initAccessToken(); + await this.initWebSocketKey(); + } + } } @Cron('50 0 * * 1-5') @@ -64,7 +83,7 @@ class OpenapiTokenApi { }; const tmp = await postOpenApi('/oauth2/tokenP', config, body); if (!tmp.access_token) { - throw new NotFoundException('Access Token Failed'); + throw new OpenapiException('Access Token Failed', 403); } return tmp.access_token as string; } @@ -77,7 +96,7 @@ class OpenapiTokenApi { }; const tmp = await postOpenApi('/oauth2/Approval', config, body); if (!tmp.approval_key) { - throw new NotFoundException('WebSocket Key Failed'); + throw new OpenapiException('WebSocket Key Failed', 403); } return tmp.approval_key as string; } From 364f833bb9d7b1d3e0130df91657d5869d79030e Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 15:11:10 +0900 Subject: [PATCH 039/223] =?UTF-8?q?=F0=9F=92=84=20style:=20stock=20control?= =?UTF-8?q?ler=20import=20=EC=88=9C=EC=84=9C=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 6f9e1dd4..3f6ef023 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -32,6 +32,7 @@ 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 { StockSearchRequest } from '@/stock/dto/stock.request'; import { StockSearchResponse, StockViewsResponse, @@ -46,7 +47,6 @@ 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 { From b097178a9ff14c90c97ba19ce5b413ce17733f48 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 15:14:33 +0900 Subject: [PATCH 040/223] =?UTF-8?q?=E2=9C=A8=20feat:=20custom=20filter=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20exception=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Decorator/openapiException.filter.ts | 32 +++++++++++++++++++ .../openapi/api/openapiDetailData.api.ts | 5 ++- .../openapi/api/openapiMinuteData.api.ts | 6 ++-- .../openapi/api/openapiPeriodData.api.ts | 10 ++++-- .../scraper/openapi/api/openapiToken.api.ts | 5 ++- .../openapi/util/openapiCustom.error.ts | 13 ++++++++ .../openapi/{ => util}/openapiUtil.api.ts | 27 +++++++++++++--- 7 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts create mode 100644 packages/backend/src/scraper/openapi/util/openapiCustom.error.ts rename packages/backend/src/scraper/openapi/{ => util}/openapiUtil.api.ts (67%) diff --git a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts new file mode 100644 index 00000000..50f91173 --- /dev/null +++ b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts @@ -0,0 +1,32 @@ +import { + ExceptionFilter, + Catch, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { OpenapiException } from '../util/openapiCustom.error'; + +@Catch() +export class OpenapiExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(OpenapiExceptionFilter.name); + + catch(exception: unknown) { + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.getResponse() + : 'Internal server error'; + + const error = + exception instanceof OpenapiException ? exception.getError() : ''; + + this.logger.error( + `HTTP Status: ${status} Error Message: ${JSON.stringify(message)} Error : ${error}`, + ); + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index f8e3cb92..a758df55 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -1,7 +1,8 @@ +import { UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Between, DataSource } from 'typeorm'; import { openApiConfig } from '../config/openapi.config'; -import { getOpenApi } from '../openapiUtil.api'; +import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { DetailDataQuery, FinancialData, @@ -11,6 +12,7 @@ import { StockDetailQuery, } from '../type/openapiDetailData.type'; import { TR_IDS } from '../type/openapiUtil.type'; +import { getOpenApi } from '../util/openapiUtil.api'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily } from '@/stock/domain/stockData.entity'; @@ -28,6 +30,7 @@ export class OpenapiDetailData { constructor(private readonly datasource: DataSource) {} @Cron('0 8 * * 1-5') + @UseFilters(OpenapiExceptionFilter) public async getDetailData() { const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 26ea64e4..60bc90b0 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,14 +1,15 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { openApiConfig } from '../config/openapi.config'; -import { getCurrentTime, getOpenApi } from '../openapiUtil.api'; +import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { isMinuteData, MinuteData, UpdateStockQuery, } from '../type/openapiMinuteData.type'; import { TR_IDS } from '../type/openapiUtil.type'; +import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; @@ -25,6 +26,7 @@ export class OpenapiMinuteData { } @Cron('0 1 * * 1-5') + @UseFilters(OpenapiExceptionFilter) private async getStockData() { this.stock = await this.datasource.manager.findBy(Stock, { isTrading: true, diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index c65b7789..fa4b2b9b 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; -import { getOpenApi, getPreviousDate, getTodayDate } from '../openapiUtil.api'; +import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { ChartData, isChartData, @@ -9,6 +9,11 @@ import { Period, } from '../type/openapiPeriodData'; import { TR_IDS } from '../type/openapiUtil.type'; +import { + getOpenApi, + getPreviousDate, + getTodayDate, +} from '../util/openapiUtil.api'; import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { @@ -44,6 +49,7 @@ export class OpenapiPeriodData { } @Cron('0 1 * * 1-5') + @UseFilters(OpenapiExceptionFilter) public async getItemChartPriceCheck() { const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index e5c230cf..36936c89 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,7 +1,8 @@ -import { Inject } from '@nestjs/common'; +import { Inject, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; +import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; import { logger } from '@/configs/logger.config'; @@ -58,6 +59,7 @@ class OpenapiTokenApi { } @Cron('50 0 * * 1-5') + @UseFilters(OpenapiExceptionFilter) private async initAccessToken() { const updatedConfig = await Promise.all( this.config.map(async (val) => { @@ -69,6 +71,7 @@ class OpenapiTokenApi { } @Cron('50 0 * * 1-5') + @UseFilters(OpenapiExceptionFilter) private async initWebSocketKey() { this.config.forEach(async (val) => { val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; diff --git a/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts new file mode 100644 index 00000000..1e0c3913 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts @@ -0,0 +1,13 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class OpenapiException extends HttpException { + private error: unknown; + constructor(message: string, status: HttpStatus, error?: unknown) { + super(message, status); + this.error = error; + } + + public getError() { + return this.error; + } +} diff --git a/packages/backend/src/scraper/openapi/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts similarity index 67% rename from packages/backend/src/scraper/openapi/openapiUtil.api.ts rename to packages/backend/src/scraper/openapi/util/openapiUtil.api.ts index 9ce04bd2..7c177443 100644 --- a/packages/backend/src/scraper/openapi/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts @@ -1,6 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +import { HttpStatus } from '@nestjs/common'; import axios from 'axios'; -import { openApiConfig } from './config/openapi.config'; -import { TR_ID } from './type/openapiUtil.type'; +import { openApiConfig } from '../config/openapi.config'; +import { TR_ID } from '../type/openapiUtil.type'; +import { OpenapiException } from './openapiCustom.error'; + +const throwOpenapiException = (error: any) => { + if (error.message && error.response && error.response.status) { + throw new OpenapiException( + `Request failed: ${error.message}`, + error.response.status, + error, + ); + } else { + throw new OpenapiException( + `Unknown error: ${error.message || 'No message'}`, + HttpStatus.INTERNAL_SERVER_ERROR, + error, + ); + } +}; const postOpenApi = async ( url: string, @@ -11,7 +30,7 @@ const postOpenApi = async ( const response = await axios.post(config.STOCK_URL + url, body); return response.data; } catch (error) { - throw new Error(`Request failed: ${error}`); + throwOpenapiException(error); } }; @@ -34,7 +53,7 @@ const getOpenApi = async ( }); return response.data; } catch (error) { - throw new Error(`Request failed: ${error}`); + throwOpenapiException(error); } }; From adf6ac533a46baffc18d3db64711b92608cbe063 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 15:53:21 +0900 Subject: [PATCH 041/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=EC=86=8C=EC=9C=A0=ED=95=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EB=A7=8C=20=EC=B1=84=ED=8C=85=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=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.service.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index c9431a8d..fbb5ac45 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,8 +1,10 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { DataSource, SelectQueryBuilder } from 'typeorm'; +import { WsException } from '@nestjs/websockets'; +import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; +import { UserStock } from '@/stock/domain/userStock.entity'; export interface ChatMessage { message: string; @@ -23,10 +25,15 @@ export class ChatService { constructor(private readonly dataSource: DataSource) {} async saveChat(userId: number, chatMessage: ChatMessage) { - return this.dataSource.manager.save(Chat, { - user: { id: userId }, - stock: { id: chatMessage.stockId }, - message: chatMessage.message, + return this.dataSource.transaction(async (manager) => { + if (!(await this.hasStock(userId, chatMessage.stockId, manager))) { + throw new WsException('not have stock'); + } + return manager.save(Chat, { + user: { id: userId }, + stock: { id: chatMessage.stockId }, + message: chatMessage.message, + }); }); } @@ -57,6 +64,12 @@ export class ChatService { return queryBuilder.getMany(); } + private hasStock(userId: number, stockId: string, manager: EntityManager) { + return manager.exists(UserStock, { + where: { user: { id: userId }, stock: { id: stockId } }, + }); + } + private validatePageSize(chatScrollQuery: ChatScrollQuery) { const { pageSize } = chatScrollQuery; if (pageSize && pageSize > 100) { From 38c158b17829907edd2b44d3aae79821de5821a5 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 17:04:28 +0900 Subject: [PATCH 042/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EC=84=9C=EB=B8=8C=EB=84=A4=EC=9E=84=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/user/domain/user.entity.ts | 4 + .../backend/src/user/user.service.spec.ts | 74 +++++++++++++++---- packages/backend/src/user/user.service.ts | 42 ++++++++--- 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 26cdb345..04f964cf 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -10,6 +10,7 @@ import { UserStock } from '@/stock/domain/userStock.entity'; import { OauthType } from '@/user/domain/ouathType'; import { Role } from '@/user/domain/role'; +@Index('nickname_sub_name', ['nickname', 'subName'], { unique: true }) @Index('type_oauth_id', ['type', 'oauthId'], { unique: true }) @Entity({ name: 'users' }) export class User { @@ -19,6 +20,9 @@ export class User { @Column({ length: 50 }) nickname: string; + @Column({ length: 10 }) + subName: string; + @Column({ length: 50 }) email: string; diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index df80a82c..e36dafd2 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -1,19 +1,18 @@ -/* eslint-disable max-lines-per-function */ -import { BadRequestException, NotFoundException } from "@nestjs/common"; -import { DataSource, EntityManager } from "typeorm"; -import { User } from "./domain/user.entity"; -import { OauthType } from "@/user/domain/ouathType"; -import { UserService } from "@/user/user.service"; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { User } from './domain/user.entity'; +import { OauthType } from '@/user/domain/ouathType'; +import { UserService } from '@/user/user.service'; + +const defaultManagerMock: Partial = { + findOne: jest.fn(), + save: jest.fn(), + exists: jest.fn(), +}; export function createDataSourceMock( managerMock?: Partial, ): Partial { - const defaultManagerMock: Partial = { - findOne: jest.fn(), - save: jest.fn(), - exists: jest.fn(), - }; - return { getRepository: managerMock?.getRepository, transaction: jest.fn().mockImplementation(async (work) => { @@ -22,6 +21,14 @@ export function createDataSourceMock( }; } +export function createManagerDataSourceMock( + managerMock?: Partial, +) { + return { + manager: managerMock, + }; +} + describe('UserService 테스트', () => { const registerRequest = { email: 'test@naver.com', @@ -58,6 +65,45 @@ describe('UserService 테스트', () => { ).rejects.toThrow('user already exists'); }); + test('같은 닉네임이 없을 때 기본 서브 닉네임을 생성한다.', async () => { + const managerMock = { + exists: jest.fn().mockResolvedValueOnce(false), + save: jest.fn().mockResolvedValue(registerRequest), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const subName = await userService.createSubName('test'); + + expect(subName).toBe('0001'); + }); + + test.each([ + ['0001', '0002'], + ['0009', '0010'], + ['0099', '0100'], + ['0999', '1000'], + ])( + '같은 닉네임이 있을 때 현제 서브네임 최대 값에서 1을 더한 값이 생성', + async (maxSubName, newSubName) => { + const managerMock = { + exists: jest.fn().mockResolvedValue(true), + save: jest.fn().mockResolvedValue(registerRequest), + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: maxSubName }), + }), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const subName = await userService.createSubName('test'); + + expect(subName).toBe(newSubName); + }, + ); + test('유저 테마를 업데이트한다', async () => { const userId = 1; const isLight = false; @@ -117,7 +163,7 @@ describe('UserService 테스트', () => { const managerMock = { findOne: jest.fn().mockResolvedValue(mockUser), }; - const dataSource = createDataSourceMock(managerMock); + const dataSource = createManagerDataSourceMock(managerMock); const userService = new UserService(dataSource as DataSource); const result = await userService.getUserTheme(userId); @@ -135,7 +181,7 @@ describe('UserService 테스트', () => { const managerMock = { findOne: jest.fn().mockResolvedValue(null), }; - const dataSource = createDataSourceMock(managerMock); + const dataSource = createManagerDataSourceMock(managerMock); const userService = new UserService(dataSource as DataSource); await expect(userService.getUserTheme(userId)).rejects.toThrow( diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index c1eebd52..1b7d701f 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -18,31 +18,43 @@ export class UserService { async register({ nickname, email, type, oauthId }: RegisterRequest) { return await this.dataSource.transaction(async (manager) => { await this.validateUserExists(type, oauthId, manager); + const subName = await this.createSubName(nickname); return await manager.save(User, { nickname, email, type, oauthId, + subName, }); }); } + async createSubName(nickname: string) { + return this.dataSource.transaction(async (manager) => { + if (!(await this.existsUserByNickname(nickname, manager))) { + return '0001'; + } + + const maxSubName = await manager + .createQueryBuilder(User, 'user') + .select('MAX(user.subName)', 'max') + .where('user.nickname = :nickname', { nickname }) + .getRawOne(); + + return (parseInt(maxSubName.max, 10) + 1).toString().padStart(4, '0'); + }); + } + + existsUserByNickname(nickname: string, manager: EntityManager) { + return manager.exists(User, { where: { nickname } }); + } + async findUserByOauthIdAndType(oauthId: string, type: OauthType) { return await this.dataSource.manager.findOne(User, { where: { oauthId, type }, }); } - private async validateUserExists( - type: OauthType, - oauthId: string, - manager: EntityManager, - ) { - if (await manager.exists(User, { where: { oauthId, type } })) { - throw new BadRequestException('user already exists'); - } - } - async updateUserTheme(userId: number, isLight?: boolean): Promise { return await this.dataSource.transaction(async (manager) => { if (isLight === undefined) { @@ -72,4 +84,14 @@ export class UserService { return user.isLight; } + + private async validateUserExists( + type: OauthType, + oauthId: string, + manager: EntityManager, + ) { + if (await manager.exists(User, { where: { oauthId, type } })) { + throw new BadRequestException('user already exists'); + } + } } From faa77af4fc2060f619d3761a9eb0fe3a95095a90 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 18:17:30 +0900 Subject: [PATCH 043/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A9=98=EC=85=98?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/chat/domain/mention.entity.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/backend/src/chat/domain/mention.entity.ts diff --git a/packages/backend/src/chat/domain/mention.entity.ts b/packages/backend/src/chat/domain/mention.entity.ts new file mode 100644 index 00000000..c68ca8fb --- /dev/null +++ b/packages/backend/src/chat/domain/mention.entity.ts @@ -0,0 +1,28 @@ +import { + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { User } from '@/user/domain/user.entity'; + +@Index('chat_user_unique', ['chat', 'user']) +@Entity() +export class Mention { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Chat, (chat) => chat.id) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} From 8b80865ff8ed4abe656baed4db7a8c265af88884 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 18:19:00 +0900 Subject: [PATCH 044/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=8A=B9=EC=A0=95?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=EB=A5=BC=20=EB=A9=98=EC=85=98=ED=95=98?= =?UTF-8?q?=EB=8A=94=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.module.ts | 10 ++++-- .../backend/src/chat/domain/like.entity.ts | 2 +- packages/backend/src/chat/mention.service.ts | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/chat/mention.service.ts diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 62dc1c29..b58dc484 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -6,12 +6,18 @@ import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; import { Like } from '@/chat/domain/like.entity'; +import { Mention } from '@/chat/domain/mention.entity'; import { LikeService } from '@/chat/like.service'; +import { MentionService } from '@/chat/mention.service'; import { StockModule } from '@/stock/stock.module'; @Module({ - imports: [TypeOrmModule.forFeature([Chat, Like]), StockModule, SessionModule], + imports: [ + TypeOrmModule.forFeature([Chat, Like, Mention]), + StockModule, + SessionModule, + ], controllers: [ChatController], - providers: [ChatGateway, ChatService, LikeService], + providers: [ChatGateway, ChatService, LikeService, MentionService], }) export class ChatModule {} diff --git a/packages/backend/src/chat/domain/like.entity.ts b/packages/backend/src/chat/domain/like.entity.ts index 84238b15..261e96e8 100644 --- a/packages/backend/src/chat/domain/like.entity.ts +++ b/packages/backend/src/chat/domain/like.entity.ts @@ -23,6 +23,6 @@ export class Like { @JoinColumn({ name: 'user_id' }) user: User; - @CreateDateColumn({ name: 'created_at' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) createdAt: Date; } diff --git a/packages/backend/src/chat/mention.service.ts b/packages/backend/src/chat/mention.service.ts new file mode 100644 index 00000000..3b5a36a2 --- /dev/null +++ b/packages/backend/src/chat/mention.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Mention } from '@/chat/domain/mention.entity'; +import { User } from '@/user/domain/user.entity'; + +@Injectable() +export class MentionService { + constructor(private readonly dataSource: DataSource) {} + + async createMention(chatId: number, userId: number) { + return this.dataSource.transaction(async (manager) => { + if (!(await this.existsChatAndUser(chatId, userId, manager))) { + return null; + } + return await this.dataSource.manager.save(Mention, { + chat: { id: chatId }, + user: { id: userId }, + }); + }); + } + + async existsChatAndUser( + chatId: number, + userId: number, + manager: EntityManager, + ) { + if (!(await manager.exists(User, { where: { id: userId } }))) { + return false; + } + return await manager.exists(Mention, { + where: { chat: { id: chatId }, user: { id: userId } }, + }); + } +} From 9f28b3abe3d71c9fcffc68f2624d66efd70a12e7 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 18:22:21 +0900 Subject: [PATCH 045/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20minute=20fix=20pe?= =?UTF-8?q?r=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- output | 99 +++++++++++++++++++ .../Decorator/openapiException.filter.ts | 4 +- .../openapi/api/openapiDetailData.api.ts | 1 + .../openapi/api/openapiMinuteData.api.ts | 67 +++++++++---- .../openapi/api/openapiPeriodData.api.ts | 3 +- .../scraper/openapi/api/openapiToken.api.ts | 5 +- .../scraper/openapi/openapi-scraper.module.ts | 10 +- .../openapi/openapi-scraper.service.ts | 18 ++-- 8 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 output diff --git a/output b/output new file mode 100644 index 00000000..cdd2d49a --- /dev/null +++ b/output @@ -0,0 +1,99 @@ +yarn workspace v1.22.22 +yarn run v1.22.22 +$ nest start --watch +[5:32:54 PM] Starting compilation in watch mode... + +[5:32:56 PM] Found 0 errors. Watching for file changes. + +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [NestFactory] Starting Nest application... +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] AppModule dependencies initialized +41ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ScraperModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] SessionModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] WinstonModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] DiscoveryModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ScheduleModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +57ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] KoreaStockInfoModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] OpenapiScraperModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] UserModule dependencies initialized +1ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] StockModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ChatModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] AuthModule dependencies initialized +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [WebSocketsController] StockGateway subscribed to the "connectStock" message +13ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [WebSocketsController] ChatGateway subscribed to the "chat" message +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RoutesResolver] StockController {/api/stock}: +1ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/view, POST} route +1ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/user, POST} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/user, DELETE} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/user/ownership, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/minutely, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/daily, GET} route +1ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/weekly, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/mothly, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/yearly, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/detail, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/topViews, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/topGainers, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/topLosers, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RoutesResolver] UserController {/api/user}: +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, PATCH} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RoutesResolver] GoogleAuthController {/api/auth/google}: +1ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/auth/google/login, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/auth/google/redirect, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/auth/google/status, GET} route +0ms +[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [NestApplication] Nest application successfully started +5ms +[5:34:34 PM] File change detected. Starting incremental compilation... + +[5:34:34 PM] Found 0 errors. Watching for file changes. + +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [NestFactory] Starting Nest application... +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] AppModule dependencies initialized +40ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ScraperModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] SessionModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] WinstonModule dependencies initialized +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] DiscoveryModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ScheduleModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +56ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] KoreaStockInfoModule dependencies initialized +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] OpenapiScraperModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] UserModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] StockModule dependencies initialized +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ChatModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] AuthModule dependencies initialized +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [WebSocketsController] StockGateway subscribed to the "connectStock" message +11ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [WebSocketsController] ChatGateway subscribed to the "chat" message +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RoutesResolver] StockController {/api/stock}: +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/view, POST} route +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/user, POST} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/user, DELETE} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/user/ownership, GET} route +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/minutely, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/daily, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/weekly, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/mothly, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/yearly, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/detail, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/topViews, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/topGainers, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/topLosers, GET} route +1ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RoutesResolver] UserController {/api/user}: +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, PATCH} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RoutesResolver] GoogleAuthController {/api/auth/google}: +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/auth/google/login, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/auth/google/redirect, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/auth/google/status, GET} route +0ms +[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [NestApplication] Nest application successfully started +5ms diff --git a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts index 50f91173..e86915b3 100644 --- a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts +++ b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts @@ -3,13 +3,13 @@ import { Catch, HttpException, HttpStatus, - Logger, } from '@nestjs/common'; +import { Logger } from 'winston'; import { OpenapiException } from '../util/openapiCustom.error'; @Catch() export class OpenapiExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(OpenapiExceptionFilter.name); + private readonly logger = new Logger(); catch(exception: unknown) { const status = diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index a758df55..1745d0d6 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -32,6 +32,7 @@ export class OpenapiDetailData { @Cron('0 8 * * 1-5') @UseFilters(OpenapiExceptionFilter) public async getDetailData() { + if (process.env.NODE_ENV !== 'production') return; const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); const configCount = this.config.length; diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 60bc90b0..e055de6e 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -14,23 +14,37 @@ import { openApiToken } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; +const STOCK_CUT = 4; + @Injectable() export class OpenapiMinuteData { - private stock: Stock[]; + private stock: Stock[][] = []; private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - private readonly intervals: number = 60; + private readonly intervals: number = 130; + private flip: number = 0; public constructor(private readonly datasource: DataSource) { - this.getStockData().then(() => this.getMinuteData()); + this.getStockData(); } @Cron('0 1 * * 1-5') @UseFilters(OpenapiExceptionFilter) private async getStockData() { - this.stock = await this.datasource.manager.findBy(Stock, { + if (process.env.NODE_ENV !== 'production') return; + const stock = await this.datasource.manager.findBy(Stock, { isTrading: true, }); + const stockSize = Math.ceil(stock.length / STOCK_CUT); + let i = 0; + this.stock = []; + while (i < STOCK_CUT) { + this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; + } + console.log(stock.length); + console.log(this.stock.length); + console.log(this.stock[0].length); } private convertResToMinuteData(stockId: string, item: MinuteData) { @@ -50,9 +64,11 @@ export class OpenapiMinuteData { return stockPeriod; } - private async saveMinuteData(stockId: string, item: MinuteData) { + private async saveMinuteData(stockId: string, item: MinuteData[]) { const manager = this.datasource.manager; - const stockPeriod = this.convertResToMinuteData(stockId, item); + const stockPeriod = item.map((val) => + this.convertResToMinuteData(stockId, val), + ); manager.save(this.entity, stockPeriod); } @@ -63,19 +79,24 @@ export class OpenapiMinuteData { ) { const query = this.getUpdateStockQuery(stockId, time); console.log(query); - const response = await getOpenApi( - this.url, - config, - query, - TR_IDS.MINUTE_DATA, - ); - let output; - if (response.output2) output = response.output2; - if (output && output[0] && isMinuteData(output[0])) { - this.saveMinuteData(stockId, output[0]); + try { + const response = await getOpenApi( + this.url, + config, + query, + TR_IDS.MINUTE_DATA, + ); + let output; + if (response.output2) output = response.output2; + if (output && output[0] && isMinuteData(output[0])) { + this.saveMinuteData(stockId, output); + } + } catch (error) { + console.error(error); } } + @UseFilters(OpenapiExceptionFilter) private async getMinuteDataChunk( chunk: Stock[], config: typeof openApiConfig, @@ -91,14 +112,18 @@ export class OpenapiMinuteData { } } - @Cron('* 9-14 * * 1-5') - @Cron('0-30 15 * * 1-5') + @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) + @UseFilters(OpenapiExceptionFilter) private getMinuteData() { + console.error('hello'); + if (process.env.NODE_ENV !== 'production') return; + console.error('not hello'); const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(this.stock.length / configCount); - + const stock = this.stock[this.flip % STOCK_CUT]; + this.flip++; + const chunkSize = Math.ceil(stock.length / configCount); for (let i = 0; i < configCount; i++) { - const chunk = this.stock.slice(i * chunkSize, (i + 1) * chunkSize); + const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); this.getMinuteDataChunk(chunk, openApiToken.configs[i]); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index fa4b2b9b..5e35f3b9 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -45,12 +45,13 @@ export class OpenapiPeriodData { private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; public constructor(private readonly datasource: DataSource) { - this.getItemChartPriceCheck(); + //this.getItemChartPriceCheck(); } @Cron('0 1 * * 1-5') @UseFilters(OpenapiExceptionFilter) public async getItemChartPriceCheck() { + if (process.env.NODE_ENV !== 'production') return; const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); const configCount = openApiToken.configs.length; diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 36936c89..e5c230cf 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,8 +1,7 @@ -import { Inject, UseFilters } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; import { logger } from '@/configs/logger.config'; @@ -59,7 +58,6 @@ class OpenapiTokenApi { } @Cron('50 0 * * 1-5') - @UseFilters(OpenapiExceptionFilter) private async initAccessToken() { const updatedConfig = await Promise.all( this.config.map(async (val) => { @@ -71,7 +69,6 @@ class OpenapiTokenApi { } @Cron('50 0 * * 1-5') - @UseFilters(OpenapiExceptionFilter) private async initWebSocketKey() { this.config.forEach(async (val) => { val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 9024497e..7d2f2d39 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiMinuteData } from './api/openapiMinuteData.api'; +import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiScraperService } from './openapi-scraper.service'; import { Stock } from '@/stock/domain/stock.entity'; import { @@ -26,6 +29,11 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; ]), ], controllers: [], - providers: [OpenapiScraperService], + providers: [ + OpenapiPeriodData, + OpenapiMinuteData, + OpenapiDetailData, + OpenapiScraperService, + ], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts index eb2b727b..7f1e2d81 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts @@ -3,17 +3,15 @@ import { DataSource } from 'typeorm'; import { OpenapiDetailData } from './api/openapiDetailData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; +import { openApiToken } from './api/openapiToken.api'; @Injectable() export class OpenapiScraperService { - private readonly openapiPeriodData: OpenapiPeriodData; - private readonly openapiMinuteData: OpenapiMinuteData; - private readonly openapiDetailData: OpenapiDetailData; - public constructor(private datasource: DataSource) { - if (process.env.NODE_ENV === 'production') { - this.openapiPeriodData = new OpenapiPeriodData(datasource); - this.openapiMinuteData = new OpenapiMinuteData(datasource); - this.openapiDetailData = new OpenapiDetailData(datasource); - } - } + private readonly token = openApiToken; + public constructor( + private datasource: DataSource, + private readonly openapiPeriodData: OpenapiPeriodData, + private readonly openapiMinuteData: OpenapiMinuteData, + private readonly openapiDetailData: OpenapiDetailData, + ) {} } From 54651bf34b2329e09ace9f1d3d74ede4ad5b3fad Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 18:28:52 +0900 Subject: [PATCH 046/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20minute=20data=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiMinuteData.api.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index e055de6e..529dd75b 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -42,18 +42,21 @@ export class OpenapiMinuteData { this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); i++; } - console.log(stock.length); - console.log(this.stock.length); - console.log(this.stock[0].length); } - private convertResToMinuteData(stockId: string, item: MinuteData) { + private convertResToMinuteData( + stockId: string, + item: MinuteData, + time: string, + ) { const stockPeriod = new StockData(); stockPeriod.stock = { id: stockId } as Stock; stockPeriod.startTime = new Date( parseInt(item.stck_bsop_date.slice(0, 4)), parseInt(item.stck_bsop_date.slice(4, 6)) - 1, parseInt(item.stck_bsop_date.slice(6, 8)), + parseInt(time.slice(0, 2)), + parseInt(time.slice(2, 4)), ); stockPeriod.close = parseInt(item.stck_prpr); stockPeriod.open = parseInt(item.stck_oprc); @@ -64,10 +67,20 @@ export class OpenapiMinuteData { return stockPeriod; } - private async saveMinuteData(stockId: string, item: MinuteData[]) { + private isMarketOpenTime(time: string) { + const numberTime = parseInt(time); + return numberTime >= 90000 && numberTime <= 153000; + } + + private async saveMinuteData( + stockId: string, + item: MinuteData[], + time: string, + ) { const manager = this.datasource.manager; + if (this.isMarketOpenTime(time)) return; const stockPeriod = item.map((val) => - this.convertResToMinuteData(stockId, val), + this.convertResToMinuteData(stockId, val, time), ); manager.save(this.entity, stockPeriod); } @@ -78,7 +91,6 @@ export class OpenapiMinuteData { config: typeof openApiConfig, ) { const query = this.getUpdateStockQuery(stockId, time); - console.log(query); try { const response = await getOpenApi( this.url, @@ -89,7 +101,7 @@ export class OpenapiMinuteData { let output; if (response.output2) output = response.output2; if (output && output[0] && isMinuteData(output[0])) { - this.saveMinuteData(stockId, output); + this.saveMinuteData(stockId, output, time); } } catch (error) { console.error(error); @@ -115,9 +127,7 @@ export class OpenapiMinuteData { @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) @UseFilters(OpenapiExceptionFilter) private getMinuteData() { - console.error('hello'); if (process.env.NODE_ENV !== 'production') return; - console.error('not hello'); const configCount = openApiToken.configs.length; const stock = this.stock[this.flip % STOCK_CUT]; this.flip++; From 433cc93160f1678ec1bfe9536105e99b740d47c4 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 21:15:00 +0900 Subject: [PATCH 047/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.gateway.ts | 2 +- packages/backend/src/stock/stock.gateway.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 65474edb..0fd6a1f0 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -35,7 +35,7 @@ interface chatResponse { createdAt: Date; } -@WebSocketGateway({ namespace: 'chat' }) +@WebSocketGateway({ namespace: '/api/chat/realtime' }) @UseFilters(WebSocketExceptionFilter) export class ChatGateway implements OnGatewayConnection { @WebSocketServer() diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index cf98907c..1f4ab32d 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -1,14 +1,14 @@ import { + ConnectedSocket, + MessageBody, + SubscribeMessage, WebSocketGateway, WebSocketServer, - SubscribeMessage, - MessageBody, - ConnectedSocket, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; @WebSocketGateway({ - namespace: '/stock/realtime', + namespace: '/api/stock/realtime', }) export class StockGateway { @WebSocketServer() From 3b543a6d7d8aac75f33c727b78f314dad67bdc9b Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 20 Nov 2024 22:33:11 +0900 Subject: [PATCH 048/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20output=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C,=20DI,=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD(isMarketOpenTime=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=95=EB=B0=98=EB=8C=80=EB=A1=9C=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EB=90=98=EC=96=B4=20=EC=9E=88=EC=97=88=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- output | 99 ------------------- .../backend/src/chat/like.service.spec.ts | 8 +- .../Decorator/openapiException.filter.ts | 3 +- .../openapi/api/openapiDetailData.api.ts | 2 +- .../openapi/api/openapiMinuteData.api.ts | 2 +- .../openapi/type/openapiDetailData.type.ts | 1 + 6 files changed, 9 insertions(+), 106 deletions(-) delete mode 100644 output diff --git a/output b/output deleted file mode 100644 index cdd2d49a..00000000 --- a/output +++ /dev/null @@ -1,99 +0,0 @@ -yarn workspace v1.22.22 -yarn run v1.22.22 -$ nest start --watch -[5:32:54 PM] Starting compilation in watch mode... - -[5:32:56 PM] Found 0 errors. Watching for file changes. - -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [NestFactory] Starting Nest application... -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] AppModule dependencies initialized +41ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ScraperModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] SessionModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] WinstonModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] DiscoveryModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ScheduleModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +57ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] KoreaStockInfoModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] OpenapiScraperModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] UserModule dependencies initialized +1ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] StockModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] ChatModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [InstanceLoader] AuthModule dependencies initialized +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [WebSocketsController] StockGateway subscribed to the "connectStock" message +13ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [WebSocketsController] ChatGateway subscribed to the "chat" message +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RoutesResolver] StockController {/api/stock}: +1ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/view, POST} route +1ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/user, POST} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/user, DELETE} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/user/ownership, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/minutely, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/daily, GET} route +1ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/weekly, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/mothly, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/yearly, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/detail, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/topViews, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/topGainers, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/stock/topLosers, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RoutesResolver] UserController {/api/user}: +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, PATCH} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RoutesResolver] GoogleAuthController {/api/auth/google}: +1ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/auth/google/login, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/auth/google/redirect, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [RouterExplorer] Mapped {/api/auth/google/status, GET} route +0ms -[Nest] 26105 - 11/20/2024, 5:32:57 PM  LOG [NestApplication] Nest application successfully started +5ms -[5:34:34 PM] File change detected. Starting incremental compilation... - -[5:34:34 PM] Found 0 errors. Watching for file changes. - -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [NestFactory] Starting Nest application... -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] AppModule dependencies initialized +40ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ScraperModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] SessionModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] WinstonModule dependencies initialized +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] DiscoveryModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ScheduleModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +56ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] KoreaStockInfoModule dependencies initialized +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] OpenapiScraperModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] UserModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] StockModule dependencies initialized +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] ChatModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [InstanceLoader] AuthModule dependencies initialized +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [WebSocketsController] StockGateway subscribed to the "connectStock" message +11ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [WebSocketsController] ChatGateway subscribed to the "chat" message +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RoutesResolver] StockController {/api/stock}: +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/view, POST} route +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/user, POST} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/user, DELETE} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/user/ownership, GET} route +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/minutely, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/daily, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/weekly, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/mothly, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/yearly, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/:stockId/detail, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/topViews, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/topGainers, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/stock/topLosers, GET} route +1ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RoutesResolver] UserController {/api/user}: +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, PATCH} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/user/:id/theme, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RoutesResolver] GoogleAuthController {/api/auth/google}: +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/auth/google/login, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/auth/google/redirect, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [RouterExplorer] Mapped {/api/auth/google/status, GET} route +0ms -[Nest] 26726 - 11/20/2024, 5:34:35 PM  LOG [NestApplication] Nest application successfully started +5ms diff --git a/packages/backend/src/chat/like.service.spec.ts b/packages/backend/src/chat/like.service.spec.ts index 1099b7bd..5df6fed8 100644 --- a/packages/backend/src/chat/like.service.spec.ts +++ b/packages/backend/src/chat/like.service.spec.ts @@ -1,10 +1,10 @@ -import { createDataSourceMock } from '@/user/user.service.spec'; import { DataSource } from 'typeorm'; -import { LikeService } from '@/chat/like.service'; import { Chat } from '@/chat/domain/chat.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { LikeService } from '@/chat/like.service'; import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; -import { Like } from '@/chat/domain/like.entity'; +import { createDataSourceMock } from '@/user/user.service.spec'; function createChat(): Chat { return { @@ -65,4 +65,4 @@ describe('LikeService 테스트', () => { expect(response.likeCount).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts index e86915b3..a6c45ae9 100644 --- a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts +++ b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts @@ -3,13 +3,14 @@ import { Catch, HttpException, HttpStatus, + Inject, } from '@nestjs/common'; import { Logger } from 'winston'; import { OpenapiException } from '../util/openapiCustom.error'; @Catch() export class OpenapiExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(); + constructor(@Inject('winston') private readonly logger: Logger) {} catch(exception: unknown) { const status = diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 1745d0d6..2f7678ff 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -139,7 +139,7 @@ export class OpenapiDetailData { private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { let delay = 0; - for (const stock of chunk) { + for await (const stock of chunk) { setTimeout(() => this.getDetailDataDelay(stock, conf), delay); delay += this.intervals; } diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 529dd75b..e050e3cd 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -78,7 +78,7 @@ export class OpenapiMinuteData { time: string, ) { const manager = this.datasource.manager; - if (this.isMarketOpenTime(time)) return; + if (!this.isMarketOpenTime(time)) return; const stockPeriod = item.map((val) => this.convertResToMinuteData(stockId, val, time), ); diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index f05edcea..772d8952 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -6,6 +6,7 @@ export type DetailDataQuery = { fid_input_iscd: string; fid_div_cls_code: '0' | '1'; }; + export type FinancialData = { stac_yymm: string; // 결산 년월 grs: string; // 매출액 증가율 From d6e573948c98a8be3b2bbc6c2abafdcb1bcb5937 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 20 Nov 2024 23:03:18 +0900 Subject: [PATCH 049/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=9B=B9=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EB=B9=84=EC=96=B4=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=97=90=EB=9F=AC=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/auth/session/websocketSession.service.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/auth/session/websocketSession.service.ts b/packages/backend/src/auth/session/websocketSession.service.ts index 10af73c2..c6c248cc 100644 --- a/packages/backend/src/auth/session/websocketSession.service.ts +++ b/packages/backend/src/auth/session/websocketSession.service.ts @@ -7,16 +7,21 @@ export class WebsocketSessionService { constructor(private readonly sessionStore: MemoryStore) {} async getAuthenticatedUser(socket: Socket) { - const cookieValue = websocketCookieParse(socket); - const session = await this.getSession(cookieValue); - return session ? session.passport.user : undefined; + try { + const cookieValue = websocketCookieParse(socket); + const session = await this.getSession(cookieValue); + return session ? session.passport.user : null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return null; + } } private getSession(cookieValue: string) { - return new Promise((resolve) => { + return new Promise((resolve) => { this.sessionStore.get(cookieValue, (err: Error, session) => { if (err || !session) { - resolve(undefined); + resolve(null); } resolve(session as PassportSession); }); From 4dcaa3109ebbf50869e7c8f999de641cf29f89e5 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 01:02:23 +0900 Subject: [PATCH 050/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20openap?= =?UTF-8?q?i=20scraper=20service=20=EC=97=90=EC=84=9C=20=EC=95=88=20?= =?UTF-8?q?=EC=93=B0=EC=9D=B4=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts | 0 packages/backend/src/scraper/openapi/openapi-scraper.service.ts | 2 -- 2 files changed, 2 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts index 7f1e2d81..52c90179 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts @@ -3,11 +3,9 @@ import { DataSource } from 'typeorm'; import { OpenapiDetailData } from './api/openapiDetailData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; -import { openApiToken } from './api/openapiToken.api'; @Injectable() export class OpenapiScraperService { - private readonly token = openApiToken; public constructor( private datasource: DataSource, private readonly openapiPeriodData: OpenapiPeriodData, From 31b77ffb81c95434a73e72c1eaa637c4926f623e Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 13:02:38 +0900 Subject: [PATCH 051/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20injectable?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD,=20=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=20websocket=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=E3=85=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiDetailData.api.ts | 3 ++- .../src/scraper/openapi/api/openapiLiveData.api.ts | 0 .../src/scraper/openapi/api/openapiToken.api.ts | 11 +++++++---- 3 files changed, 9 insertions(+), 5 deletions(-) delete mode 100644 packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 2f7678ff..7b365780 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -1,4 +1,4 @@ -import { UseFilters } from '@nestjs/common'; +import { Injectable, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Between, DataSource } from 'typeorm'; import { openApiConfig } from '../config/openapi.config'; @@ -18,6 +18,7 @@ import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; +@Injectable() export class OpenapiDetailData { private readonly financialUrl: string = '/uapi/domestic-stock/v1/finance/financial-ratio'; diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index e5c230cf..13ac793c 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,7 +1,8 @@ -import { Inject } from '@nestjs/common'; +import { Inject, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; +import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; import { logger } from '@/configs/logger.config'; @@ -34,6 +35,7 @@ class OpenapiTokenApi { return this.config; } + @UseFilters(OpenapiExceptionFilter) private async initAuthenValue() { const delay = 60000; const delayMinute = delay / 1000 / 60; @@ -50,9 +52,10 @@ class OpenapiTokenApi { this.logger.warn( `Request failed. Retrying in ${delayMinute} minute...`, ); - await new Promise((resolve) => setTimeout(resolve, delay)); - await this.initAccessToken(); - await this.initWebSocketKey(); + setTimeout(async () => { + await this.initAccessToken(); + await this.initWebSocketKey(); + }, delay); } } } From 3978d00bc16fbbe6354e7790c77024253be3a8ef Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 13:24:56 +0900 Subject: [PATCH 052/223] =?UTF-8?q?=E2=9C=A8=20feat:=20websocket=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/websocketClient.service.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/backend/src/scraper/openapi/websocketClient.service.ts diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts new file mode 100644 index 00000000..5071bff3 --- /dev/null +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { WebSocket } from 'ws'; + +@Injectable() +export class WebsocketClient { + private client: WebSocket; + private readonly reconnectInterval = 60000; + private readonly url = + process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + + constructor(@Inject('winston') private readonly logger: Logger) { + this.connect(); + } + + @Cron('0 2 * * 1-5') + private connect() { + this.client = new WebSocket(this.url); + + this.client.on('open', () => { + this.logger.log('WebSocket connection established'); + this.sendMessage('Initial message'); + }); + + this.client.on('message', (data: any) => { + this.logger.log(`Received message: ${data}`); + }); + + this.client.on('close', () => { + this.logger.warn( + `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, + ); + setTimeout(() => this.connect(), this.reconnectInterval); + }); + + this.client.on('error', (error: any) => { + this.logger.error(`WebSocket error: ${error.message}`); + }); + } + + private sendMessage(message: string) { + if (this.client.readyState === WebSocket.OPEN) { + this.client.send(message); + this.logger.log(`Sent message: ${message}`); + } else { + this.logger.warn('WebSocket is not open. Message not sent.'); + } + } +} From d157f1ef2c536eb0ea27f4c0d4b8acdc94166caf Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 15:35:13 +0900 Subject: [PATCH 053/223] =?UTF-8?q?=E2=9C=A8=20feat:=20live=20data=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + packages/backend/.gitignore | 4 + packages/backend/package.json | 4 +- .../openapi/api/openapiLiveData.api.ts | 107 ++++++++++++ .../scraper/openapi/api/openapiToken.api.ts | 10 +- .../scraper/openapi/openapi-scraper.module.ts | 4 + .../openapi/type/openapiLiveData.type.ts | 152 ++++++++++++++++++ .../scraper/openapi/util/openapiUtil.api.ts | 16 ++ .../openapi/websocketClient.service.ts | 37 ++++- yarn.lock | 25 +-- 10 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts diff --git a/.gitignore b/.gitignore index 66b03b45..57004910 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # vscode setting .vscode + diff --git a/packages/backend/.gitignore b/packages/backend/.gitignore index 2f6b899c..cd939265 100644 --- a/packages/backend/.gitignore +++ b/packages/backend/.gitignore @@ -54,3 +54,7 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# backup file +.backup +.bak diff --git a/packages/backend/package.json b/packages/backend/package.json index c41b6046..26da1fd9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -25,11 +25,11 @@ "@nestjs/core": "^10.0.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", - "@nestjs/platform-socket.io": "^10.4.7", + "@nestjs/platform-socket.io": "^10.4.8", "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", - "@nestjs/websockets": "^10.4.7", + "@nestjs/websockets": "^10.4.8", "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts new file mode 100644 index 00000000..f0589c93 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -0,0 +1,107 @@ +import { Inject } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { + MessageResponse, + StockData, + isMessageResponse, + parseStockData, +} from '../type/openapiLiveData.type'; +import { decryptAES256 } from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { KospiStock } from '@/stock/domain/kospiStock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +export class OpenapiLiveData { + public readonly TR_ID: string = 'H0STCNT0'; + private readonly WEBSOCKET_MAX: number = 40; + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly manager: EntityManager, + ) {} + + public async getMessage(): Promise { + const kospi = await this.getKospiStockId(); + const config = openApiToken.configs; + const configLength = config.length; + const ret: string[] = []; + + for (let i = 0; i < configLength; i++) { + const stocks = kospi.splice( + i * this.WEBSOCKET_MAX, + (i + 1) * this.WEBSOCKET_MAX, + ); + for (const stock of stocks) { + ret.push(this.convertObjectToMessage(config[i], stock.id!)); + } + } + + return ret; + } + + private convertObjectToMessage( + config: typeof openApiConfig, + stockId: string, + ): string { + const message = { + header: { + approval_key: config.STOCK_WEBSOCKET_KEY!, + custtype: 'P', + tr_type: '1', + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: this.TR_ID, + tr_key: stockId, + }, + }, + }; + return JSON.stringify(message); + } + + private async getKospiStockId() { + const kospi = await this.manager.find(KospiStock); + return kospi; + } + + private async saveLiveData(data: StockLiveData) { + await this.manager.save(StockLiveData, data); + } + + private convertLiveData(message: string[]): StockLiveData { + const stockData: StockData = parseStockData(message); + const stockLiveData = new StockLiveData(); + stockLiveData.currentPrice = parseFloat(stockData.STCK_PRPR); + stockLiveData.changeRate = parseFloat(stockData.PRDY_CTRT); + stockLiveData.volume = parseInt(stockData.CNTG_VOL); + stockLiveData.high = parseFloat(stockData.STCK_HGPR); + stockLiveData.low = parseFloat(stockData.STCK_LWPR); + stockLiveData.open = parseFloat(stockData.STCK_OPRC); + stockLiveData.previousClose = parseFloat(stockData.WGHN_AVRG_STCK_PRC); + stockLiveData.updatedAt = new Date(); + + return stockLiveData; + } + + public async output(message: Buffer, iv?: string, key?: string) { + const str = message.toString(); + if (str.split('|').length < 3) return; + const parsed = str.split('|'); + if (parsed.length > 0) { + if (parsed[0] == '1' && iv && key) + parsed[4] = decryptAES256(parsed[4], iv, key); + if (parsed[1] !== this.TR_ID) return; + const stockData = parsed[4].split('^'); + const length = stockData.length / parseInt(parsed[3]); + const size = parseInt(parsed[2]); + const i = 0; + while (i < size) { + const data = stockData.splice(i * length, (i + 1) * length); + const liveData = this.convertLiveData(data); + this.saveLiveData(liveData); + } + } + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 13ac793c..275730bc 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -73,9 +73,13 @@ class OpenapiTokenApi { @Cron('50 0 * * 1-5') private async initWebSocketKey() { - this.config.forEach(async (val) => { - val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; - }); + const updatedConfig = await Promise.all( + this.config.map(async (val) => { + val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; + return val; + }), + ); + this.config = updatedConfig; } private async getToken(config: typeof openApiConfig): Promise { diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 7d2f2d39..cb45c91c 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiScraperService } from './openapi-scraper.service'; +import { WebsocketClient } from './websocketClient.service'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily, @@ -34,6 +36,8 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiMinuteData, OpenapiDetailData, OpenapiScraperService, + OpenapiLiveData, + WebsocketClient, ], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts new file mode 100644 index 00000000..d8041e7b --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-lines-per-function */ + +export type StockData = { + MKSC_SHRN_ISCD: string; // 유가증권 단축 종목코드 + STCK_CNTG_HOUR: string; // 주식 체결 시간 + STCK_PRPR: string; // 주식 현재가 + PRDY_VRSS_SIGN: string; // 전일 대비 부호 + PRDY_VRSS: string; // 전일 대비 + PRDY_CTRT: string; // 전일 대비율 + WGHN_AVRG_STCK_PRC: string; // 가중 평균 주식 가격 + STCK_OPRC: string; // 주식 시가 + STCK_HGPR: string; // 주식 최고가 + STCK_LWPR: string; // 주식 최저가 + ASKP1: string; // 매도호가1 + BIDP1: string; // 매수호가1 + CNTG_VOL: string; // 체결 거래량 + ACML_VOL: string; // 누적 거래량 + ACML_TR_PBMN: string; // 누적 거래 대금 + SELN_CNTG_CSNU: string; // 매도 체결 건수 + SHNU_CNTG_CSNU: string; // 매수 체결 건수 + NTBY_CNTG_CSNU: string; // 순매수 체결 건수 + CTTR: string; // 체결강도 + SELN_CNTG_SMTN: string; // 총 매도 수량 + SHNU_CNTG_SMTN: string; // 총 매수 수량 + CCLD_DVSN: string; // 체결구분 + SHNU_RATE: string; // 매수비율 + PRDY_VOL_VRSS_ACML_VOL_RATE: string; // 전일 거래량 대비 등락율 + OPRC_HOUR: string; // 시가 시간 + OPRC_VRSS_PRPR_SIGN: string; // 시가대비구분 + OPRC_VRSS_PRPR: string; // 시가대비 + HGPR_HOUR: string; // 최고가 시간 + HGPR_VRSS_PRPR_SIGN: string; // 고가대비구분 + HGPR_VRSS_PRPR: string; // 고가대비 + LWPR_HOUR: string; // 최저가 시간 + LWPR_VRSS_PRPR_SIGN: string; // 저가대비구분 + LWPR_VRSS_PRPR: string; // 저가대비 + BSOP_DATE: string; // 영업 일자 + NEW_MKOP_CLS_CODE: string; // 신 장운영 구분 코드 + TRHT_YN: string; // 거래정지 여부 + ASKP_RSQN1: string; // 매도호가 잔량1 + BIDP_RSQN1: string; // 매수호가 잔량1 + TOTAL_ASKP_RSQN: string; // 총 매도호가 잔량 + TOTAL_BIDP_RSQN: string; // 총 매수호가 잔량 + VOL_TNRT: string; // 거래량 회전율 + PRDY_SMNS_HOUR_ACML_VOL: string; // 전일 동시간 누적 거래량 + PRDY_SMNS_HOUR_ACML_VOL_RATE: string; // 전일 동시간 누적 거래량 비율 + HOUR_CLS_CODE: string; // 시간 구분 코드 + MRKT_TRTM_CLS_CODE: string; // 임의종료구분코드 + VI_STND_PRC: string; // 정적VI발동기준가 +}; + +export function parseStockData(message: string[]): StockData { + return { + MKSC_SHRN_ISCD: message[0], + STCK_CNTG_HOUR: message[1], + STCK_PRPR: message[2], + PRDY_VRSS_SIGN: message[3], + PRDY_VRSS: message[4], + PRDY_CTRT: message[5], + WGHN_AVRG_STCK_PRC: message[6], + STCK_OPRC: message[7], + STCK_HGPR: message[8], + STCK_LWPR: message[9], + ASKP1: message[10], + BIDP1: message[11], + CNTG_VOL: message[12], + ACML_VOL: message[13], + ACML_TR_PBMN: message[14], + SELN_CNTG_CSNU: message[15], + SHNU_CNTG_CSNU: message[16], + NTBY_CNTG_CSNU: message[17], + CTTR: message[18], + SELN_CNTG_SMTN: message[19], + SHNU_CNTG_SMTN: message[20], + CCLD_DVSN: message[21], + SHNU_RATE: message[22], + PRDY_VOL_VRSS_ACML_VOL_RATE: message[23], + OPRC_HOUR: message[24], + OPRC_VRSS_PRPR_SIGN: message[25], + OPRC_VRSS_PRPR: message[26], + HGPR_HOUR: message[27], + HGPR_VRSS_PRPR_SIGN: message[28], + HGPR_VRSS_PRPR: message[29], + LWPR_HOUR: message[30], + LWPR_VRSS_PRPR_SIGN: message[31], + LWPR_VRSS_PRPR: message[32], + BSOP_DATE: message[33], + NEW_MKOP_CLS_CODE: message[34], + TRHT_YN: message[35], + ASKP_RSQN1: message[36], + BIDP_RSQN1: message[37], + TOTAL_ASKP_RSQN: message[38], + TOTAL_BIDP_RSQN: message[39], + VOL_TNRT: message[40], + PRDY_SMNS_HOUR_ACML_VOL: message[41], + PRDY_SMNS_HOUR_ACML_VOL_RATE: message[42], + HOUR_CLS_CODE: message[43], + MRKT_TRTM_CLS_CODE: message[44], + VI_STND_PRC: message[45], + }; +} + +export type OpenApiMessage = { + header: { + approval_key: string; + custtype: string; + tr_type: string; + 'content-type': string; + }; + body: { + input: { + tr_id: string; + tr_key: string; + }; + }; +}; + +export type MessageResponse = { + header: { + tr_id: string; + tr_key: string; + encrypt: string; + }; + body: { + rt_cd: string; + msg_cd: string; + msg1: string; + output?: { + iv: string; + key: string; + }; + }; +}; + +export function isMessageResponse(data: any): data is MessageResponse { + return ( + typeof data === 'object' && + data !== null && + typeof data.header === 'object' && + data.header !== null && + typeof data.header.tr_id === 'object' && + typeof data.header.tr_key === 'object' && + typeof data.header.encrypt === 'object' && + typeof data.body === 'object' && + data.body !== null && + typeof data.body.rt_cd === 'object' && + typeof data.body.msg_cd === 'object' && + typeof data.body.msg1 === 'object' && + typeof data.body.output === 'object' + ); +} diff --git a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts index 7c177443..4ec0b29d 100644 --- a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any*/ +import * as crypto from 'crypto'; import { HttpStatus } from '@nestjs/common'; import axios from 'axios'; import { openApiConfig } from '../config/openapi.config'; @@ -77,6 +78,20 @@ const getCurrentTime = () => { const seconds = String(now.getSeconds()).padStart(2, '0'); return `${hours}${minutes}${seconds}`; }; +const decryptAES256 = ( + encryptedText: string, + key: string, + iv: string, +): string => { + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + Buffer.from(key, 'hex'), + Buffer.from(iv, 'hex'), + ); + let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +}; export { postOpenApi, @@ -84,4 +99,5 @@ export { getTodayDate, getPreviousDate, getCurrentTime, + decryptAES256, }; diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index 5071bff3..c91b0e71 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; import { WebSocket } from 'ws'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; @Injectable() export class WebsocketClient { @@ -10,21 +12,36 @@ export class WebsocketClient { private readonly url = process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; - constructor(@Inject('winston') private readonly logger: Logger) { + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly openapiLiveData: OpenapiLiveData, + ) { this.connect(); } + // TODO : subscribe 구조로 리팩토링 + private subscribe() {} + @Cron('0 2 * * 1-5') private connect() { this.client = new WebSocket(this.url); this.client.on('open', () => { - this.logger.log('WebSocket connection established'); - this.sendMessage('Initial message'); + this.logger.info('WebSocket connection established'); + this.openapiLiveData.getMessage().then((val) => { + val.forEach((message) => this.sendMessage(message)); + }); }); this.client.on('message', (data: any) => { - this.logger.log(`Received message: ${data}`); + this.logger.info(`Received message: ${data}`); + const message = JSON.parse(data); + if (message.header && message.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(message)}`); + this.sendPong(); + return; + } + this.openapiLiveData.output(data); }); this.client.on('close', () => { @@ -39,10 +56,18 @@ export class WebsocketClient { }); } + private sendPong() { + const pongMessage = { + header: { tr_id: 'PINGPONG', datetime: new Date().toISOString() }, + }; + this.client.send(JSON.stringify(pongMessage)); + this.logger.info(`Sent PONG: ${JSON.stringify(pongMessage)}`); + } + private sendMessage(message: string) { if (this.client.readyState === WebSocket.OPEN) { this.client.send(message); - this.logger.log(`Sent message: ${message}`); + this.logger.info(`Sent message: ${message}`); } else { this.logger.warn('WebSocket is not open. Message not sent.'); } diff --git a/yarn.lock b/yarn.lock index b8b27206..624dd31a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1287,10 +1287,10 @@ multer "1.4.4-lts.1" tslib "2.7.0" -"@nestjs/platform-socket.io@^10.4.7": - version "10.4.7" - resolved "https://registry.yarnpkg.com/@nestjs/platform-socket.io/-/platform-socket.io-10.4.7.tgz#0c22c204e72aba83dff5b517dcd4802fc0f17594" - integrity sha512-CpmrqswpD/O4SyF/IUzKj14BUf0eTLyDja9svPCRIJX8AdF47mKCMbz5vtU6vpJtxVnq1e1Xd+xcdZ6FIf6HtQ== +"@nestjs/platform-socket.io@^10.4.8": + version "10.4.8" + resolved "https://registry.yarnpkg.com/@nestjs/platform-socket.io/-/platform-socket.io-10.4.8.tgz#cf483794f3b1831d804a3ac3a3f7b999664489d4" + integrity sha512-KzCL+P037HiaW3iODueJ/vw5a8bSr6uIturgGSuRz6c8WR+SfqKC6jNXw0JTW5NVqEqX8tOunEVXoI3MFnWz/w== dependencies: socket.io "4.8.0" tslib "2.7.0" @@ -1340,10 +1340,10 @@ dependencies: uuid "9.0.1" -"@nestjs/websockets@^10.4.7": - version "10.4.7" - resolved "https://registry.yarnpkg.com/@nestjs/websockets/-/websockets-10.4.7.tgz#20d4da5e38a1f1dff866f780e694c907eaa23b8f" - integrity sha512-ajuoptYLYm+l3+KtaA9Ed+cO9yB34PtBE8UObavRT8Euh/f7QfeJiKcrU3+BQSAiTWM3nF2qfuV4CfEkP9uKuw== +"@nestjs/websockets@^10.4.8": + version "10.4.8" + resolved "https://registry.yarnpkg.com/@nestjs/websockets/-/websockets-10.4.8.tgz#9c2b982059e850a56999f56c87ac3a88acbce4ea" + integrity sha512-IpObWsZvjjUxmBuIF/AkcyXrFFzwNYNsw2reZXHy7C31wJsYAjwr6rHMSRGyqsxfqTA2DqjCczorewM6BAEXig== dependencies: iterare "1.2.1" object-hash "3.0.0" @@ -2269,6 +2269,13 @@ resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== +"@types/ws@^8.5.13": + version "8.5.13" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" + integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -8971,7 +8978,7 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@^8.2.3: +ws@^8.18.0, ws@^8.2.3: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== From f00641d700220d46c03c2dfa59c2c526970de686 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 15:38:10 +0900 Subject: [PATCH 054/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=BD=94=EB=93=9C=EC=97=90=EC=84=9C=20=20object=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/scraper/openapi/api/openapiLiveData.api.ts | 4 +--- .../backend/src/scraper/openapi/websocketClient.service.ts | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index f0589c93..5e4e9acf 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -86,9 +86,7 @@ export class OpenapiLiveData { } public async output(message: Buffer, iv?: string, key?: string) { - const str = message.toString(); - if (str.split('|').length < 3) return; - const parsed = str.split('|'); + const parsed = message.toString().split('|'); if (parsed.length > 0) { if (parsed[0] == '1' && iv && key) parsed[4] = decryptAES256(parsed[4], iv, key); diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index c91b0e71..d2b4a9d8 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -41,6 +41,9 @@ export class WebsocketClient { this.sendPong(); return; } + if (message.header && message.header.tr_id === 'H0STCNT0') { + return; + } this.openapiLiveData.output(data); }); From 51cf7202cc03eb34265761de5df03f502279e575 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 15:39:57 +0900 Subject: [PATCH 055/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20eslint?= =?UTF-8?q?=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiLiveData.api.ts | 7 +---- .../openapi/websocketClient.service.ts | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 5e4e9acf..77a31c67 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -2,12 +2,7 @@ import { Inject } from '@nestjs/common'; import { EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { - MessageResponse, - StockData, - isMessageResponse, - parseStockData, -} from '../type/openapiLiveData.type'; +import { StockData, parseStockData } from '../type/openapiLiveData.type'; import { decryptAES256 } from '../util/openapiUtil.api'; import { openApiToken } from './openapiToken.api'; import { KospiStock } from '@/stock/domain/kospiStock.entity'; diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index d2b4a9d8..47bafad1 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -22,6 +22,20 @@ export class WebsocketClient { // TODO : subscribe 구조로 리팩토링 private subscribe() {} + private message(data: any) { + this.logger.info(`Received message: ${data}`); + const message = JSON.parse(data); + if (message.header && message.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(message)}`); + this.sendPong(); + return; + } + if (message.header && message.header.tr_id === 'H0STCNT0') { + return; + } + this.openapiLiveData.output(data); + } + @Cron('0 2 * * 1-5') private connect() { this.client = new WebSocket(this.url); @@ -34,17 +48,7 @@ export class WebsocketClient { }); this.client.on('message', (data: any) => { - this.logger.info(`Received message: ${data}`); - const message = JSON.parse(data); - if (message.header && message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(message)}`); - this.sendPong(); - return; - } - if (message.header && message.header.tr_id === 'H0STCNT0') { - return; - } - this.openapiLiveData.output(data); + this.message(data); }); this.client.on('close', () => { From 6135a8792d76b8c73fe39fe7f4a58c943c1fe462 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 21 Nov 2024 15:42:35 +0900 Subject: [PATCH 056/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stock/decorator/stockData.decorator.ts | 14 ++- .../backend/src/stock/stock.controller.ts | 119 ++++++++++-------- 2 files changed, 78 insertions(+), 55 deletions(-) diff --git a/packages/backend/src/stock/decorator/stockData.decorator.ts b/packages/backend/src/stock/decorator/stockData.decorator.ts index 19eb3969..d48dccc1 100644 --- a/packages/backend/src/stock/decorator/stockData.decorator.ts +++ b/packages/backend/src/stock/decorator/stockData.decorator.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable max-lines-per-function */ -import { applyDecorators } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { StockDataResponse } from '../dto/stockData.response'; +import { applyDecorators } from "@nestjs/common"; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from "@nestjs/swagger"; +import { StockDataResponse } from "../dto/stockData.response"; export function ApiGetStockData(summary: string, type: string) { return applyDecorators( @@ -22,6 +22,14 @@ export function ApiGetStockData(summary: string, type: string) { type: String, format: 'date-time', }), + ApiQuery({ + name: 'timeunit', + required: false, + description: '시간 단위', + example: 'minute', + type: String, + enum: ['minute', 'day', 'week', 'month', 'year'], + }), ApiResponse({ status: 200, description: `주식의 ${type} 단위 데이터 성공적으로 조회`, diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 5aaf6707..8d75c87c 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -32,6 +32,7 @@ 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 { StockSearchRequest } from '@/stock/dto/stock.request'; import { StockSearchResponse, StockViewsResponse, @@ -46,7 +47,16 @@ import { UserStockResponse, } from '@/stock/dto/userStock.response'; import { User } from '@/user/domain/user.entity'; -import { StockSearchRequest } from '@/stock/dto/stock.request'; + +const TIME_UNIT = { + MINUTE: 'minute', + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', +} as const; + +type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; @Controller('stock') export class StockController { @@ -167,61 +177,25 @@ export class StockController { return await this.stockService.searchStock(request.name); } - @Get(':stockId/minutely') - @ApiGetStockData('주식 분 단위 데이터 조회 API', '분') - async getStockDataMinutely( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataMinutelyService.getStockDataMinutely( - stockId, - lastStartTime, - ); - } - - @Get(':stockId/daily') - @ApiGetStockData('주식 일 단위 데이터 조회 API', '일') + @Get('/:stockId') + @ApiGetStockData('주식 시간 단위 데이터 조회 API', '일') async getStockDataDaily( @Param('stockId') stockId: string, @Query('lastStartTime') lastStartTime?: string, + @Query('timeunit') timeunit: TIME_UNIT = TIME_UNIT.MINUTE, ) { - return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); - } - - @Get(':stockId/weekly') - @ApiGetStockData('주식 주 단위 데이터 조회 API', '주') - async getStockDataWeekly( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataWeeklyService.getStockDataWeekly( - stockId, - lastStartTime, - ); - } - - @Get(':stockId/mothly') - @ApiGetStockData('주식 월 단위 데이터 조회 API', '월') - async getStockDataMonthly( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataMonthlyService.getStockDataMonthly( - stockId, - lastStartTime, - ); - } - - @Get(':stockId/yearly') - @ApiGetStockData('주식 연 단위 데이터 조회 API', '연') - async getStockDataYearly( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataYearlyService.getStockDataYearly( - stockId, - lastStartTime, - ); + switch (timeunit) { + case TIME_UNIT.MINUTE: + return this.getMinutelyData(stockId, lastStartTime); + case TIME_UNIT.DAY: + return this.getDailyData(stockId, lastStartTime); + case TIME_UNIT.MONTH: + return this.getStockDataMonthly(stockId, lastStartTime); + case TIME_UNIT.WEEK: + return this.getStockDataWeekly(stockId, lastStartTime); + default: + return this.getStockDataYearly(stockId, lastStartTime); + } } @ApiOperation({ @@ -257,4 +231,45 @@ export class StockController { async getTopStocksByLosers(@LimitQuery(20) limit: number) { return await this.stockService.getTopStocksByLosers(limit); } + + private getStockDataYearly( + stockId: string, + lastStartTime: string | undefined, + ) { + return this.stockDataYearlyService.getStockDataYearly( + stockId, + lastStartTime, + ); + } + + private getStockDataWeekly( + stockId: string, + lastStartTime: string | undefined, + ) { + return this.stockDataWeeklyService.getStockDataWeekly( + stockId, + lastStartTime, + ); + } + + private getStockDataMonthly( + stockId: string, + lastStartTime: string | undefined, + ) { + return this.stockDataMonthlyService.getStockDataMonthly( + stockId, + lastStartTime, + ); + } + + private getMinutelyData(stockId: string, lastStartTime?: string) { + return this.stockDataMinutelyService.getStockDataMinutely( + stockId, + lastStartTime, + ); + } + + private getDailyData(stockId: string, lastStartTime?: string) { + return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); + } } From 46ba8aace1cf1d8967d251e5ba4bdf542566943c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 21 Nov 2024 15:56:24 +0900 Subject: [PATCH 057/223] =?UTF-8?q?=F0=9F=92=84=20style:=20=ED=81=B0=20?= =?UTF-8?q?=EB=94=B0=EC=98=B4=ED=91=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/decorator/stockData.decorator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/stock/decorator/stockData.decorator.ts b/packages/backend/src/stock/decorator/stockData.decorator.ts index d48dccc1..4941fa61 100644 --- a/packages/backend/src/stock/decorator/stockData.decorator.ts +++ b/packages/backend/src/stock/decorator/stockData.decorator.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable max-lines-per-function */ -import { applyDecorators } from "@nestjs/common"; -import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from "@nestjs/swagger"; -import { StockDataResponse } from "../dto/stockData.response"; +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { StockDataResponse } from '../dto/stockData.response'; export function ApiGetStockData(summary: string, type: string) { return applyDecorators( From 386f3243d542bc89d50a70f0c3d3e3426a9a9ea1 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 16:10:10 +0900 Subject: [PATCH 058/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20kospi=20stock=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80,=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 40 +++++++++++++++++-- .../src/stock/domain/kospiStock.entity.ts | 3 +- .../backend/src/stock/domain/stock.entity.ts | 2 +- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 7b365780..2ef8f6ae 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -14,6 +14,7 @@ import { import { TR_IDS } from '../type/openapiUtil.type'; import { getOpenApi } from '../util/openapiUtil.api'; import { openApiToken } from './openapiToken.api'; +import { KospiStock } from '@/stock/domain/kospiStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; @@ -51,6 +52,12 @@ export class OpenapiDetailData { manager.save(entity, stockDetail); } + private async saveKospiData(stockDetail: KospiStock) { + const manager = this.datasource.manager; + const entity = KospiStock; + manager.save(entity, stockDetail); + } + private async calPer(eps: number): Promise { if (eps <= 0) return NaN; const manager = this.datasource.manager; @@ -99,8 +106,10 @@ export class OpenapiDetailData { private async makeStockDetailObject( output1: FinancialData, output2: ProductDetail, + stockId: string, ): Promise { const result = new StockDetail(); + result.stock = { id: stockId } as Stock; result.marketCap = (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; result.eps = parseInt(output1.eps); @@ -113,10 +122,15 @@ export class OpenapiDetailData { return result; } - private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { - const dataQuery = this.getDetailDataQuery(stock.id!); - const defaultQuery = this.getDefaultDataQuery(stock.id!); + private async makeKospiStockObject(output: ProductDetail, stockId: string) { + const ret = new KospiStock(); + ret.isKospi = output.kospi200_item_yn === 'Y' ? true : false; + ret.stock = { id: stockId } as Stock; + return ret; + } + private async getFinancialData(stock: Stock, conf: typeof openApiConfig) { + const dataQuery = this.getDetailDataQuery(stock.id!); // 여기서 가져올 건 eps -> eps와 per 계산하자. const output1 = await getOpenApi( this.incomeUrl, @@ -124,6 +138,12 @@ export class OpenapiDetailData { dataQuery, TR_IDS.FINANCIAL_DATA, ); + return output1; + } + + private async getProductData(stock: Stock, conf: typeof openApiConfig) { + const defaultQuery = this.getDefaultDataQuery(stock.id!); + // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 const output2 = await getOpenApi( this.defaultUrl, @@ -131,10 +151,22 @@ export class OpenapiDetailData { defaultQuery, TR_IDS.PRODUCTION_DETAIL, ); + return output2; + } + + private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { + const output1 = await this.getFinancialData(stock, conf); + const output2 = await this.getProductData(stock, conf); if (isFinancialData(output1) && isProductDetail(output2)) { - const stockDetail = await this.makeStockDetailObject(output1, output2); + const stockDetail = await this.makeStockDetailObject( + output1, + output2, + stock.id!, + ); this.saveDetailData(stockDetail); + const kospiStock = await this.makeKospiStockObject(output2, stock.id!); + this.saveKospiData(kospiStock); } } diff --git a/packages/backend/src/stock/domain/kospiStock.entity.ts b/packages/backend/src/stock/domain/kospiStock.entity.ts index 7d96a992..8f45a87c 100644 --- a/packages/backend/src/stock/domain/kospiStock.entity.ts +++ b/packages/backend/src/stock/domain/kospiStock.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, OneToOne, PrimaryColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; import { Stock } from './stock.entity'; @Entity() @@ -10,5 +10,6 @@ export class KospiStock { isKospi: boolean; @OneToOne(() => Stock, (stock) => stock.id) + @JoinColumn({ name: 'stock_id' }) stock: Stock; } diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 66dfb583..be321f55 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -1,4 +1,5 @@ import { Column, Entity, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; +import { KospiStock } from './kospiStock.entity'; import { StockDaily, StockMinutely, @@ -9,7 +10,6 @@ import { import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; -import { KospiStock } from './kospiStock.entity'; @Entity() export class Stock { From 15549267506b82e0416107b150a522a0ab4134d1 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 21 Nov 2024 18:08:51 +0900 Subject: [PATCH 059/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20sameSite=20=EC=98=B5=EC=85=98=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/configs/session.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/configs/session.config.ts b/packages/backend/src/configs/session.config.ts index 65c49b96..456bda54 100644 --- a/packages/backend/src/configs/session.config.ts +++ b/packages/backend/src/configs/session.config.ts @@ -8,6 +8,8 @@ export const sessionConfig = { resave: false, saveUninitialized: false, name: process.env.COOKIE_NAME, + secure: true, + sameSite: 'none', cookie: { maxAge: Number(process.env.COOKIE_MAX_AGE), }, From d4abdcbcc16447b21f53d094638512cc1338ac3a Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 21 Nov 2024 19:03:37 +0900 Subject: [PATCH 060/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20sameSite=20=EC=98=B5=EC=85=98=20=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/configs/session.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/configs/session.config.ts b/packages/backend/src/configs/session.config.ts index 456bda54..310aae4d 100644 --- a/packages/backend/src/configs/session.config.ts +++ b/packages/backend/src/configs/session.config.ts @@ -2,15 +2,15 @@ import { randomUUID } from 'node:crypto'; import * as dotenv from 'dotenv'; dotenv.config(); - +type none = 'none'; export const sessionConfig = { secret: process.env.COOKIE_SECRET || randomUUID().toString(), resave: false, saveUninitialized: false, name: process.env.COOKIE_NAME, - secure: true, - sameSite: 'none', cookie: { maxAge: Number(process.env.COOKIE_MAX_AGE), + secure: true, + sameSite: 'none' as none, }, }; From 8e7e73c228673ea2c2b202fa2a8606d9a2be421a Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 19:04:17 +0900 Subject: [PATCH 061/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20detail=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 169 ++++++++++++------ .../openapi/type/openapiDetailData.type.ts | 6 +- .../scraper/openapi/type/openapiUtil.type.ts | 4 +- .../scraper/openapi/util/openapiUtil.api.ts | 12 ++ .../openapi/websocketClient.service.ts | 4 +- 5 files changed, 135 insertions(+), 60 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 2ef8f6ae..add3caec 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -1,12 +1,13 @@ -import { Injectable, UseFilters } from '@nestjs/common'; +import { Inject, Injectable, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { Between, DataSource } from 'typeorm'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { DetailDataQuery, - FinancialData, - isFinancialData, + FinancialRatio, + isFinancialRatioData, isProductDetail, ProductDetail, StockDetailQuery, @@ -23,39 +24,69 @@ import { StockDetail } from '@/stock/domain/stockDetail.entity'; export class OpenapiDetailData { private readonly financialUrl: string = '/uapi/domestic-stock/v1/finance/financial-ratio'; - private readonly defaultUrl: string = + private readonly productUrl: string = '/uapi/domestic-stock/v1/quotations/search-stock-info'; - private readonly incomeUrl: string = - '/uapi/domestic-stock/v1/finance/income-statement'; - private readonly intervals = 100; - private readonly config: (typeof openApiConfig)[] = openApiToken.configs; - constructor(private readonly datasource: DataSource) {} + private readonly intervals = 1000; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + setTimeout(() => this.getDetailData(), 5000); + } @Cron('0 8 * * 1-5') @UseFilters(OpenapiExceptionFilter) public async getDetailData() { - if (process.env.NODE_ENV !== 'production') return; + //if (process.env.NODE_ENV !== 'production') return; const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); - const configCount = this.config.length; + const configCount = openApiToken.configs.length; const chunkSize = Math.ceil(stocks.length / configCount); for (let i = 0; i < configCount; i++) { + this.logger.info(openApiToken.configs[i]); const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getDetailDataChunk(chunk, this.config[i]); + this.getDetailDataChunk(chunk, openApiToken.configs[i]); } } private async saveDetailData(stockDetail: StockDetail) { const manager = this.datasource.manager; const entity = StockDetail; - manager.save(entity, stockDetail); + const existingStockDetail = await manager.findOne(entity, { + where: { + stock: { id: stockDetail.stock.id }, + }, + }); + if (existingStockDetail) { + manager.update( + entity, + { stock: { id: stockDetail.stock.id } }, + stockDetail, + ); + } else { + manager.save(entity, stockDetail); + } } private async saveKospiData(stockDetail: KospiStock) { const manager = this.datasource.manager; const entity = KospiStock; - manager.save(entity, stockDetail); + const existingStockDetail = await manager.findOne(entity, { + where: { + stock: { id: stockDetail.stock.id }, + }, + }); + + if (existingStockDetail) { + manager.update( + entity, + { stock: { id: stockDetail.stock.id } }, + stockDetail, + ); + } else { + manager.save(entity, stockDetail); + } } private async calPer(eps: number): Promise { @@ -66,10 +97,16 @@ export class OpenapiDetailData { take: 1, order: { createdAt: 'desc' }, }); - const currentPrice = latestResult[0].close; - const per = currentPrice / eps; + // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 + if (latestResult && latestResult[0] && latestResult[0].close) { + const currentPrice = latestResult[0].close; + const per = currentPrice / eps; - return per; + if (isNaN(per)) return 0; + else return per; + } else { + return 0; + } } private async calMarketCap(lstg: number) { @@ -79,32 +116,41 @@ export class OpenapiDetailData { take: 1, order: { createdAt: 'desc' }, }); - const currentPrice = latestResult[0].close; - const marketCap = lstg * currentPrice; - return marketCap; + + // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 + if (latestResult && latestResult[0] && latestResult[0].close) { + const currentPrice = latestResult[0].close; + const marketCap = lstg * currentPrice; + + if (isNaN(marketCap)) return 0; + else return marketCap; + } else { + return 0; + } } private async get52WeeksLowHigh() { - const manager = this.datasource.manager; - const nowDate = new Date(); - const weeksAgoDate = this.getDate52WeeksAgo(); - // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 - const output = await manager.find(StockDaily, { - select: ['low', 'high'], - where: { - startTime: Between(weeksAgoDate, nowDate), - }, - }); - const result = output.reduce((prev, cur) => { - if (prev.low > cur.low) prev.low = cur.low; - if (prev.high < cur.high) prev.high = cur.high; - return cur; - }, new StockDaily()); - return { low: result.low, high: result.high }; + //const manager = this.datasource.manager; + //const nowDate = new Date(); + //const weeksAgoDate = this.getDate52WeeksAgo(); + //// 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 + //const output = await manager.find(StockDaily, { + // select: ['low', 'high'], + // where: { + // startTime: Between(weeksAgoDate, nowDate), + // }, + //}); + //const result = output.reduce((prev, cur) => { + // if (prev.low > cur.low) prev.low = cur.low; + // if (prev.high < cur.high) prev.high = cur.high; + // return cur; + //}, new StockDaily()); + //return { low: result.low, high: result.high }; + return { low: 0, high: 0 }; } private async makeStockDetailObject( - output1: FinancialData, + output1: FinancialRatio, output2: ProductDetail, stockId: string, ): Promise { @@ -116,8 +162,12 @@ export class OpenapiDetailData { const { low, high } = await this.get52WeeksLowHigh(); result.low52w = low; result.high52w = high; - result.eps = parseInt(output1.eps); - result.per = await this.calPer(parseInt(output1.eps)); + const eps = parseInt(output1.eps); + if (isNaN(eps)) result.eps = 0; + else result.eps = eps; + const per = await this.calPer(eps); + if (isNaN(per)) result.per = 0; + else result.per = per; result.updatedAt = new Date(); return result; } @@ -129,36 +179,45 @@ export class OpenapiDetailData { return ret; } - private async getFinancialData(stock: Stock, conf: typeof openApiConfig) { + private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { const dataQuery = this.getDetailDataQuery(stock.id!); // 여기서 가져올 건 eps -> eps와 per 계산하자. - const output1 = await getOpenApi( - this.incomeUrl, + const response = await getOpenApi( + this.financialUrl, conf, dataQuery, TR_IDS.FINANCIAL_DATA, ); - return output1; + if (response.output) { + const output1 = response.output; + return output1[0]; + } } private async getProductData(stock: Stock, conf: typeof openApiConfig) { - const defaultQuery = this.getDefaultDataQuery(stock.id!); + const defaultQuery = this.getFinancialDataQuery(stock.id!); // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 - const output2 = await getOpenApi( - this.defaultUrl, + const response = await getOpenApi( + this.productUrl, conf, defaultQuery, TR_IDS.PRODUCTION_DETAIL, ); - return output2; + if (response.output) { + const output2 = response.output; + return output2; + //return bufferToObject(output2); + } } private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { - const output1 = await this.getFinancialData(stock, conf); + const output1 = await this.getFinancialRatio(stock, conf); const output2 = await this.getProductData(stock, conf); - if (isFinancialData(output1) && isProductDetail(output2)) { + this.logger.info(JSON.stringify(output1)); + this.logger.info(JSON.stringify(output2)); + if (isFinancialRatioData(output1) && isProductDetail(output2)) { const stockDetail = await this.makeStockDetailObject( output1, output2, @@ -167,6 +226,8 @@ export class OpenapiDetailData { this.saveDetailData(stockDetail); const kospiStock = await this.makeKospiStockObject(output2, stock.id!); this.saveKospiData(kospiStock); + + this.logger.info(`${stock.id!} is saved`); } } @@ -178,25 +239,25 @@ export class OpenapiDetailData { } } - private getDefaultDataQuery( + private getFinancialDataQuery( stockId: string, code: '300' | '301' | '302' | '306' = '300', ): StockDetailQuery { return { pdno: stockId, - code: code, + prdt_type_cd: code, }; } private getDetailDataQuery( stockId: string, divCode: 'J' = 'J', - classify: '0' | '1' = '1', + classify: '0' | '1' = '0', ): DetailDataQuery { return { + fid_div_cls_code: classify, fid_cond_mrkt_div_code: divCode, fid_input_iscd: stockId, - fid_div_cls_code: classify, }; } diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index 772d8952..38015d48 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -7,7 +7,7 @@ export type DetailDataQuery = { fid_div_cls_code: '0' | '1'; }; -export type FinancialData = { +export type FinancialRatio = { stac_yymm: string; // 결산 년월 grs: string; // 매출액 증가율 bsop_prfi_inrt: string; // 영업 이익 증가율 @@ -20,7 +20,7 @@ export type FinancialData = { lblt_rate: string; // 부채 비율 }; -export function isFinancialData(data: any): data is FinancialData { +export function isFinancialRatioData(data: any): data is FinancialRatio { return ( data && typeof data.stac_yymm === 'string' && @@ -176,7 +176,7 @@ export const isProductDetail = (data: any): data is ProductDetail => { export type StockDetailQuery = { pdno: string; - code: string; + prdt_type_cd: string; }; //export type FinancialDetail = { diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index bd2e3edd..6df0ca19 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -1,13 +1,13 @@ export type TR_ID = | 'FHKST03010100' | 'FHKST03010200' - | 'FHKST66430200' + | 'FHKST66430300' | 'HHKDB669107C0' | 'CTPF1002R'; export const TR_IDS: Record = { ITEM_CHART_PRICE: 'FHKST03010100', MINUTE_DATA: 'FHKST03010200', - FINANCIAL_DATA: 'FHKST66430200', + FINANCIAL_DATA: 'FHKST66430300', PRODUCTION_DETAIL: 'CTPF1002R', }; diff --git a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts index 4ec0b29d..fa8f75b4 100644 --- a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts @@ -78,6 +78,7 @@ const getCurrentTime = () => { const seconds = String(now.getSeconds()).padStart(2, '0'); return `${hours}${minutes}${seconds}`; }; + const decryptAES256 = ( encryptedText: string, key: string, @@ -93,6 +94,16 @@ const decryptAES256 = ( return decrypted; }; +const bufferToObject = (buffer: Buffer): any => { + try { + const jsonString = buffer.toString('utf-8'); + return JSON.parse(jsonString); + } catch (error) { + console.error('Failed to convert buffer to object:', error); + throw error; + } +}; + export { postOpenApi, getOpenApi, @@ -100,4 +111,5 @@ export { getPreviousDate, getCurrentTime, decryptAES256, + bufferToObject, }; diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index 47bafad1..1e0d6124 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -16,7 +16,9 @@ export class WebsocketClient { @Inject('winston') private readonly logger: Logger, private readonly openapiLiveData: OpenapiLiveData, ) { - this.connect(); + if (process.env.NODE_ENV === 'production') { + this.connect(); + } } // TODO : subscribe 구조로 리팩토링 From b92e3e6830bfabbda174abf7660f148982b7fbe9 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 19:09:04 +0900 Subject: [PATCH 062/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20detail=20NaN=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index add3caec..7420e5de 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { Between, DataSource } from 'typeorm'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; @@ -130,23 +130,26 @@ export class OpenapiDetailData { } private async get52WeeksLowHigh() { - //const manager = this.datasource.manager; - //const nowDate = new Date(); - //const weeksAgoDate = this.getDate52WeeksAgo(); - //// 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 - //const output = await manager.find(StockDaily, { - // select: ['low', 'high'], - // where: { - // startTime: Between(weeksAgoDate, nowDate), - // }, - //}); - //const result = output.reduce((prev, cur) => { - // if (prev.low > cur.low) prev.low = cur.low; - // if (prev.high < cur.high) prev.high = cur.high; - // return cur; - //}, new StockDaily()); - //return { low: result.low, high: result.high }; - return { low: 0, high: 0 }; + const manager = this.datasource.manager; + const nowDate = new Date(); + const weeksAgoDate = this.getDate52WeeksAgo(); + // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 + const output = await manager.find(StockDaily, { + select: ['low', 'high'], + where: { + startTime: Between(weeksAgoDate, nowDate), + }, + }); + const result = output.reduce((prev, cur) => { + if (prev.low > cur.low) prev.low = cur.low; + if (prev.high < cur.high) prev.high = cur.high; + return cur; + }, new StockDaily()); + let low = 0; + let high = 0; + if (result.low && !isNaN(result.low)) low = result.low; + if (result.high && !isNaN(result.high)) high = result.high; + return { low, high }; } private async makeStockDetailObject( From a393cbb6fcf25350387cf425d3ce5a76a3092fd6 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 20:46:55 +0900 Subject: [PATCH 063/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20type=20ws=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 26da1fd9..a5e45463 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -45,7 +45,8 @@ "typeorm": "^0.3.20", "unzipper": "^0.12.3", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0" + "winston-daily-rotate-file": "^5.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -58,6 +59,7 @@ "@types/passport-google-oauth20": "^2.0.16", "@types/supertest": "^6.0.0", "@types/unzipper": "^0.10.10", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "cz-emoji-conventional": "^1.1.0", From 85d8ae025974769a9bdbffa000672b4ca94e9d4b Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 21:14:50 +0900 Subject: [PATCH 064/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20try=20catch=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 7420e5de..57b007a3 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -31,13 +31,13 @@ export class OpenapiDetailData { private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { - setTimeout(() => this.getDetailData(), 5000); + //setTimeout(() => this.getDetailData(), 5000); } @Cron('0 8 * * 1-5') @UseFilters(OpenapiExceptionFilter) public async getDetailData() { - //if (process.env.NODE_ENV !== 'production') return; + if (process.env.NODE_ENV !== 'production') return; const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); const configCount = openApiToken.configs.length; @@ -185,15 +185,19 @@ export class OpenapiDetailData { private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { const dataQuery = this.getDetailDataQuery(stock.id!); // 여기서 가져올 건 eps -> eps와 per 계산하자. - const response = await getOpenApi( - this.financialUrl, - conf, - dataQuery, - TR_IDS.FINANCIAL_DATA, - ); - if (response.output) { - const output1 = response.output; - return output1[0]; + try { + const response = await getOpenApi( + this.financialUrl, + conf, + dataQuery, + TR_IDS.FINANCIAL_DATA, + ); + if (response.output) { + const output1 = response.output; + return output1[0]; + } + } catch (error) { + this.logger.error(error); } } @@ -201,16 +205,20 @@ export class OpenapiDetailData { const defaultQuery = this.getFinancialDataQuery(stock.id!); // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 - const response = await getOpenApi( - this.productUrl, - conf, - defaultQuery, - TR_IDS.PRODUCTION_DETAIL, - ); - if (response.output) { - const output2 = response.output; - return output2; - //return bufferToObject(output2); + try { + const response = await getOpenApi( + this.productUrl, + conf, + defaultQuery, + TR_IDS.PRODUCTION_DETAIL, + ); + if (response.output) { + const output2 = response.output; + return output2; + //return bufferToObject(output2); + } + } catch (error) { + this.logger.error(error); } } From e46e1039e5a3dc634c6abe5dab94b999f91291b3 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Thu, 21 Nov 2024 21:19:45 +0900 Subject: [PATCH 065/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=ED=95=84=ED=84=B0=EC=97=90=20try-catch=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiPeriodData.api.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 5e35f3b9..64db1d9b 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,6 +1,7 @@ -import { Injectable, UseFilters } from '@nestjs/common'; +import { Inject, Injectable, UseFilters } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { ChartData, @@ -44,7 +45,10 @@ const INTERVALS = 4000; export class OpenapiPeriodData { private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; - public constructor(private readonly datasource: DataSource) { + public constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { //this.getItemChartPriceCheck(); } @@ -123,17 +127,18 @@ export class OpenapiPeriodData { ); } - private async fetchChartData( - query: ItemChartPriceQuery, - configIdx: number, - ): Promise { - const response = await getOpenApi( - this.url, - openApiToken.configs[configIdx], - query, - TR_IDS.ITEM_CHART_PRICE, - ); - return response.output2 as ChartData[]; + private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { + try { + const response = await getOpenApi( + this.url, + openApiToken.configs[configIdx], + query, + TR_IDS.ITEM_CHART_PRICE, + ); + return response.output2 as ChartData[]; + } catch (error) { + this.logger.error(error); + } } private updateDates( From 4a1ad278ea138f649334ce9b7cac1b9a358519f3 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 21 Nov 2024 22:51:08 +0900 Subject: [PATCH 066/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20sameSite=20=EC=98=B5=EC=85=98=20=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/configs/session.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/configs/session.config.ts b/packages/backend/src/configs/session.config.ts index 310aae4d..25910f44 100644 --- a/packages/backend/src/configs/session.config.ts +++ b/packages/backend/src/configs/session.config.ts @@ -10,7 +10,9 @@ export const sessionConfig = { name: process.env.COOKIE_NAME, cookie: { maxAge: Number(process.env.COOKIE_MAX_AGE), + httpOnly: true, secure: true, + domain: 'juchum.info', sameSite: 'none' as none, }, }; From bf741722ba994ca405a96736e7526a9577e0108d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Fri, 22 Nov 2024 00:12:55 +0900 Subject: [PATCH 067/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.ts | 1 + packages/backend/src/chat/dto/chat.response.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index fbb5ac45..80417656 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -110,6 +110,7 @@ export class ChatService { .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { userId, }) + .leftJoinAndSelect('chat.user', 'user') .where('chat.stock_id = :stockId', { stockId }) .take(size + 1); diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index 803b81e6..bf42c903 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -8,6 +8,7 @@ interface ChatResponse { message: string; type: string; liked: boolean; + nickname: string; createdAt: Date; } @@ -25,6 +26,7 @@ export class ChatScrollResponse { id: 1, likeCount: 0, message: '안녕하세요', + nickname: '초보 주주', type: ChatType.NORMAL, isLiked: true, createdAt: new Date(), @@ -41,6 +43,7 @@ export class ChatScrollResponse { type: chat.type, createdAt: chat.date!.createdAt, liked: !!(chat.likes && chat.likes.length > 0), + nickname: chat.user.nickname, })); this.hasMore = hasMore; } From 9e978586d488d51446752a3abc6c6f1f1a166b91 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Fri, 22 Nov 2024 00:29:52 +0900 Subject: [PATCH 068/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/configs/session.config.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/backend/src/configs/session.config.ts b/packages/backend/src/configs/session.config.ts index 25910f44..6cf984cd 100644 --- a/packages/backend/src/configs/session.config.ts +++ b/packages/backend/src/configs/session.config.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto'; import * as dotenv from 'dotenv'; dotenv.config(); -type none = 'none'; export const sessionConfig = { secret: process.env.COOKIE_SECRET || randomUUID().toString(), resave: false, @@ -11,8 +10,5 @@ export const sessionConfig = { cookie: { maxAge: Number(process.env.COOKIE_MAX_AGE), httpOnly: true, - secure: true, - domain: 'juchum.info', - sameSite: 'none' as none, }, }; From e1c702a49c3d051b564a8b8f02781a02bb704a07 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Fri, 22 Nov 2024 14:28:08 +0900 Subject: [PATCH 069/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20websocket=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .remote/version02/docker-compose.yml | 55 +++++++++++ .remote/version02/frontend.nginx | 36 +++++++ .remote/version02/reverse-proxy.nginx | 77 +++++++++++++++ .remote/version03/docker-compose.yml | 0 .remote/version03/new.conf | 36 +++++++ .remote/version03/reverseproxy.nginx | 86 +++++++++++++++++ .remote/version04/docker-compose.yml | 43 +++++++++ .remote/version04/frontend.nginx | 86 +++++++++++++++++ .remote/version04/new-reverse_proxy.nginx | 93 +++++++++++++++++++ .remote/version04/newfrontend.nginx | 73 +++++++++++++++ .remote/version04/reverse-proxy.nginx | 86 +++++++++++++++++ .remote/version05/docker-compose.yml | 42 +++++++++ .remote/version05/frontend.nginx | 80 ++++++++++++++++ packages/backend/package.json | 3 +- .../openapi/api/openapiLiveData.api.ts | 13 ++- .../scraper/openapi/api/openapiToken.api.ts | 1 + .../openapi/websocketClient.service.ts | 13 ++- 17 files changed, 813 insertions(+), 10 deletions(-) create mode 100644 .remote/version02/docker-compose.yml create mode 100644 .remote/version02/frontend.nginx create mode 100644 .remote/version02/reverse-proxy.nginx create mode 100644 .remote/version03/docker-compose.yml create mode 100644 .remote/version03/new.conf create mode 100644 .remote/version03/reverseproxy.nginx create mode 100644 .remote/version04/docker-compose.yml create mode 100644 .remote/version04/frontend.nginx create mode 100644 .remote/version04/new-reverse_proxy.nginx create mode 100644 .remote/version04/newfrontend.nginx create mode 100644 .remote/version04/reverse-proxy.nginx create mode 100644 .remote/version05/docker-compose.yml create mode 100644 .remote/version05/frontend.nginx diff --git a/.remote/version02/docker-compose.yml b/.remote/version02/docker-compose.yml new file mode 100644 index 00000000..32fa5759 --- /dev/null +++ b/.remote/version02/docker-compose.yml @@ -0,0 +1,55 @@ +networks: + corp: + driver: bridge + +services: + nginx_proxy: + image: nginx:1.27.2-alpine + container_name: nginx_proxy + ports: + - '80:80' + - '443:443' + depends_on: #proxy가 먼저 켜지는 경우 죽어버리는 문제가 있었음. 의존성 추가 + - frontend + - backend + volumes: + - ./nginx/reverse-proxy.conf:/etc/nginx/nginx.conf + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + networks: + - corp + + certbot: + image: certbot/certbot + volumes: + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + + frontend: + image: sunghwki/frontend:latest + env_file: + - .env + environment: + - NODE_ENV=production + depends_on: + - backend + volumes: + - ./nginx/frontend.conf:/etc/nginx/nginx.conf + networks: + - corp + + backend: + image: sunghwki/backend:latest + env_file: + - .env + environment: + - DATABASE_URL=mysql://${DB_USER}:${DB_PASS}@db:${DB_PORT}/${DB_NAME} + volumes: + - ./logs:/packages/packages/logs + networks: + - corp + +volumes: + db-data: diff --git a/.remote/version02/frontend.nginx b/.remote/version02/frontend.nginx new file mode 100644 index 00000000..f26a67ed --- /dev/null +++ b/.remote/version02/frontend.nginx @@ -0,0 +1,36 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + + server { + listen 8080; + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; # 이게 문제였다. 여기서 80포트를 포함시켜서 이미 있는 포트를 점유하는 중이었고, 그러다보니 문제가 발생했다. +} diff --git a/.remote/version02/reverse-proxy.nginx b/.remote/version02/reverse-proxy.nginx new file mode 100644 index 00000000..9887d1fd --- /dev/null +++ b/.remote/version02/reverse-proxy.nginx @@ -0,0 +1,77 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + server { + listen 80; + server_name juchum.info; + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + location / { + return 301 https://$host$request_uri; + } + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + proxy_pass http://static-server/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/.remote/version03/docker-compose.yml b/.remote/version03/docker-compose.yml new file mode 100644 index 00000000..e69de29b diff --git a/.remote/version03/new.conf b/.remote/version03/new.conf new file mode 100644 index 00000000..f26a67ed --- /dev/null +++ b/.remote/version03/new.conf @@ -0,0 +1,36 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + + server { + listen 8080; + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; # 이게 문제였다. 여기서 80포트를 포함시켜서 이미 있는 포트를 점유하는 중이었고, 그러다보니 문제가 발생했다. +} diff --git a/.remote/version03/reverseproxy.nginx b/.remote/version03/reverseproxy.nginx new file mode 100644 index 00000000..2be13402 --- /dev/null +++ b/.remote/version03/reverseproxy.nginx @@ -0,0 +1,86 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + + server { + listen 80; + server_name juchum.info; + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + proxy_pass http://static-server/; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cookie_domain nest-api-server juchum.info; + proxy_cookie_path / /; + } + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/.remote/version04/docker-compose.yml b/.remote/version04/docker-compose.yml new file mode 100644 index 00000000..0feca385 --- /dev/null +++ b/.remote/version04/docker-compose.yml @@ -0,0 +1,43 @@ +networks: + corp: + driver: bridge + +services: + + frontend: + image: sunghwki/frontend:latest + env_file: + - .env + environment: + - NODE_ENV=production + depends_on: + - backend + volumes: + - ./nginx/frontend.conf:/etc/nginx/nginx.conf + - ./nginx/reverse-proxy.conf:/etc/nginx/nginx.conf + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"''' + networks: + - corp + + certbot: + image: certbot/certbot + volumes: + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + + backend: + image: sunghwki/backend:latest + env_file: + - .env + environment: + - DATABASE_URL=mysql://${DB_USER}:${DB_PASS}@db:${DB_PORT}/${DB_NAME} + volumes: + - ./logs:/packages/packages/logs + networks: + - corp + +volumes: + db-data: diff --git a/.remote/version04/frontend.nginx b/.remote/version04/frontend.nginx new file mode 100644 index 00000000..2be13402 --- /dev/null +++ b/.remote/version04/frontend.nginx @@ -0,0 +1,86 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + + server { + listen 80; + server_name juchum.info; + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + proxy_pass http://static-server/; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cookie_domain nest-api-server juchum.info; + proxy_cookie_path / /; + } + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/.remote/version04/new-reverse_proxy.nginx b/.remote/version04/new-reverse_proxy.nginx new file mode 100644 index 00000000..c75785bf --- /dev/null +++ b/.remote/version04/new-reverse_proxy.nginx @@ -0,0 +1,93 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + + server { + listen 80; + server_name juchum.info; + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + proxy_pass http://static-server/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://juchum.info + proxy_redirect off; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cookie_domain nest-api-server juchum.info; + proxy_cookie_path / /; + } + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/.remote/version04/newfrontend.nginx b/.remote/version04/newfrontend.nginx new file mode 100644 index 00000000..e3a75870 --- /dev/null +++ b/.remote/version04/newfrontend.nginx @@ -0,0 +1,73 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + server { + listen 80; + server_name juchum.info; + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cookie_domain nest-api-server juchum.info; + proxy_cookie_path / /; + } + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/.remote/version04/reverse-proxy.nginx b/.remote/version04/reverse-proxy.nginx new file mode 100644 index 00000000..2be13402 --- /dev/null +++ b/.remote/version04/reverse-proxy.nginx @@ -0,0 +1,86 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + # 프론트엔드 upstream 설정 + upstream static-server { + server frontend:8080; + } + + server { + listen 80; + server_name juchum.info; + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + proxy_pass http://static-server/; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cookie_domain nest-api-server juchum.info; + proxy_cookie_path / /; + } + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/.remote/version05/docker-compose.yml b/.remote/version05/docker-compose.yml new file mode 100644 index 00000000..68a36250 --- /dev/null +++ b/.remote/version05/docker-compose.yml @@ -0,0 +1,42 @@ +networks: + corp: + driver: bridge +services: + frontend: + image: sunghwki/frontend:latest + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "80:80" + - "443:443" + env_file: + - .env + depends_on: + - backend + volumes: + - ./nginx/frontend.conf:/etc/nginx/nginx.conf + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"''' + networks: + - corp + + certbot: + image: certbot/certbot + volumes: + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + + backend: + image: sunghwki/backend:latest + env_file: + - .env + volumes: + - ./logs:/packages/packages/logs + networks: + - corp + +volumes: + db-data: diff --git a/.remote/version05/frontend.nginx b/.remote/version05/frontend.nginx new file mode 100644 index 00000000..7d343da3 --- /dev/null +++ b/.remote/version05/frontend.nginx @@ -0,0 +1,80 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 백엔드 upstream 설정 + upstream nest-api-server { + server backend:3000; + } + + server { + listen 80; + server_name juchum.info; + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + } + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name juchum.info; + + ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } + + location /api { + proxy_pass http://nest-api-server/api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cookie_domain nest-api-server juchum.info; + proxy_cookie_path / /; + } + + location /socket.io { + proxy_pass http://nest-api-server/socket.io; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + #include /etc/nginx/conf.d/*.conf; +} diff --git a/packages/backend/package.json b/packages/backend/package.json index a5e45463..5db955b3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "mem": "node ../../dist/main --inspect" }, "dependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 77a31c67..456cff90 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -57,7 +57,11 @@ export class OpenapiLiveData { } private async getKospiStockId() { - const kospi = await this.manager.find(KospiStock); + const kospi = await this.manager.find(KospiStock, { + where: { + isKospi: true, + }, + }); return kospi; } @@ -82,12 +86,13 @@ export class OpenapiLiveData { public async output(message: Buffer, iv?: string, key?: string) { const parsed = message.toString().split('|'); + console.log(message.toString()); if (parsed.length > 0) { if (parsed[0] == '1' && iv && key) - parsed[4] = decryptAES256(parsed[4], iv, key); + parsed[3] = decryptAES256(parsed[3], iv, key); if (parsed[1] !== this.TR_ID) return; - const stockData = parsed[4].split('^'); - const length = stockData.length / parseInt(parsed[3]); + const stockData = parsed[3].split('^'); + const length = stockData.length / parseInt(parsed[2]); const size = parseInt(parsed[2]); const i = 0; while (i < size) { diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 275730bc..fa4901a8 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -32,6 +32,7 @@ class OpenapiTokenApi { } public get configs() { + //TODO : 현재 구조에서 받아올 때마다 확인후 할당으로 변경 return this.config; } diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index 1e0d6124..554c3eff 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -26,13 +26,12 @@ export class WebsocketClient { private message(data: any) { this.logger.info(`Received message: ${data}`); - const message = JSON.parse(data); - if (message.header && message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(message)}`); + if (data.header && data.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(data)}`); this.sendPong(); return; } - if (message.header && message.header.tr_id === 'H0STCNT0') { + if (data.header && data.header.tr_id === 'H0STCNT0') { return; } this.openapiLiveData.output(data); @@ -50,7 +49,11 @@ export class WebsocketClient { }); this.client.on('message', (data: any) => { - this.message(data); + try { + this.message(data); + } catch (error) { + this.logger.info(error); + } }); this.client.on('close', () => { From e55cadd6feb1d00b8cd2473112a3bfec9a509c9c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:46:14 +0900 Subject: [PATCH 070/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=9C=A0=EC=A0=80=20=EC=83=9D=EC=84=B1=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 --- packages/backend/src/user/user.service.ts | 72 +++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index c1eebd52..8d2a5d23 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -27,22 +27,25 @@ export class UserService { }); } + async registerTester() { + return await this.dataSource.transaction(async (manager) => { + return await manager.save(User, { + nickname: this.generateRandomNickname(), + email: 'tester@nav', + type: OauthType.LOCAL, + oauthId: String( + (await this.getMaxOauthId(OauthType.LOCAL, manager)) + 1, + ), + }); + }); + } + async findUserByOauthIdAndType(oauthId: string, type: OauthType) { return await this.dataSource.manager.findOne(User, { where: { oauthId, type }, }); } - private async validateUserExists( - type: OauthType, - oauthId: string, - manager: EntityManager, - ) { - if (await manager.exists(User, { where: { oauthId, type } })) { - throw new BadRequestException('user already exists'); - } - } - async updateUserTheme(userId: number, isLight?: boolean): Promise { return await this.dataSource.transaction(async (manager) => { if (isLight === undefined) { @@ -72,4 +75,53 @@ export class UserService { return user.isLight; } + + private generateRandomNickname(): string { + const adjectives = [ + '강력한', + '지혜로운', + '소중한', + '빛나는', + '고요한', + '용감한', + '행운의', + '신비로운', + ]; + const animals = [ + '호랑이', + '독수리', + '용', + '사슴', + '백호', + '하늘새', + '백두산 호랑이', + '붉은 여우', + ]; + + const randomAdjective = + adjectives[Math.floor(Math.random() * adjectives.length)]; + const randomAnimal = animals[Math.floor(Math.random() * animals.length)]; + + return `${randomAdjective} ${randomAnimal}`; + } + + private async getMaxOauthId(oauthType: OauthType, manager: EntityManager) { + const result = await manager + .createQueryBuilder(User, 'user') + .select('MAX(user.oauthId)', 'max') + .where('user.type = :oauthType', { oauthType }) + .getRawOne(); + + return result ? Number(result.max) : 1; + } + + private async validateUserExists( + type: OauthType, + oauthId: string, + manager: EntityManager, + ) { + if (await manager.exists(User, { where: { oauthId, type } })) { + throw new BadRequestException('user already exists'); + } + } } From fc9adbd336bd87efdcb2a1eb72a90b6986d73f9e Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:51:45 +0900 Subject: [PATCH 071/223] =?UTF-8?q?=F0=9F=9A=9A=20chore:=20passport=20loca?= =?UTF-8?q?l=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package.json | 2 ++ yarn.lock | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/backend/package.json b/packages/backend/package.json index c41b6046..fd70879e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -39,6 +39,7 @@ "nest-winston": "^1.9.7", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", + "passport-local": "^1.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", @@ -56,6 +57,7 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/passport-google-oauth20": "^2.0.16", + "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", "@types/unzipper": "^0.10.10", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index b8b27206..1d75dcf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2156,6 +2156,15 @@ "@types/passport" "*" "@types/passport-oauth2" "*" +"@types/passport-local@^1.0.38": + version "1.0.38" + resolved "https://registry.yarnpkg.com/@types/passport-local/-/passport-local-1.0.38.tgz#8073758188645dde3515808999b1c218a6fe7141" + integrity sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-strategy" "*" + "@types/passport-oauth2@*": version "1.4.17" resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz#d5d54339d44f6883d03e69dc0cc0e2114067abb4" @@ -2165,6 +2174,14 @@ "@types/oauth" "*" "@types/passport" "*" +"@types/passport-strategy@*": + version "0.2.38" + resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3" + integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport@*": version "1.0.17" resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6" @@ -7039,6 +7056,13 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" +passport-local@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" + integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow== + dependencies: + passport-strategy "1.x.x" + passport-oauth2@1.x.x: version "1.8.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" From 07d2a8234cf575ae1cac9a290f44ef66ff11b225 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:54:03 +0900 Subject: [PATCH 072/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=9C=A0=EC=A0=80=20service=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/auth/tester/testerAuth.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/backend/src/auth/tester/testerAuth.service.ts diff --git a/packages/backend/src/auth/tester/testerAuth.service.ts b/packages/backend/src/auth/tester/testerAuth.service.ts new file mode 100644 index 00000000..0982c4b0 --- /dev/null +++ b/packages/backend/src/auth/tester/testerAuth.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { UserService } from '@/user/user.service'; + +@Injectable() +export class TesterAuthService { + constructor(private readonly userService: UserService) {} + + async attemptAuthentication() { + return await this.userService.registerTester(); + } +} From 2bc72478280d6c43b8b01e6e931438005830b3cc Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:54:30 +0900 Subject: [PATCH 073/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=9C=A0=EC=A0=80=20strategy=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/auth/tester/strategy/tester.strategy.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/backend/src/auth/tester/strategy/tester.strategy.ts diff --git a/packages/backend/src/auth/tester/strategy/tester.strategy.ts b/packages/backend/src/auth/tester/strategy/tester.strategy.ts new file mode 100644 index 00000000..2c6939ca --- /dev/null +++ b/packages/backend/src/auth/tester/strategy/tester.strategy.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { TesterAuthService } from '@/auth/tester/testerAuth.service'; + +@Injectable() +export class TesterStrategy extends PassportStrategy(Strategy) { + constructor(private readonly testerAuthService: TesterAuthService) { + super(); + } + + async validate(username: string, password: string, done: CallableFunction) { + const user = await this.testerAuthService.attemptAuthentication(); + done(null, user); + } +} From 1de6bc2f450faf504f6c20246599a5ccec8cc64d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:54:45 +0900 Subject: [PATCH 074/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=9C=A0=EC=A0=80=20guard=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/auth/tester/guard/tester.guard.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/backend/src/auth/tester/guard/tester.guard.ts diff --git a/packages/backend/src/auth/tester/guard/tester.guard.ts b/packages/backend/src/auth/tester/guard/tester.guard.ts new file mode 100644 index 00000000..46f461d3 --- /dev/null +++ b/packages/backend/src/auth/tester/guard/tester.guard.ts @@ -0,0 +1,16 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class TestAuthGuard extends AuthGuard('local') { + constructor() { + super(); + } + + async canActivate(context: ExecutionContext) { + const isActivate = (await super.canActivate(context)) as boolean; + const request = context.switchToHttp().getRequest(); + await super.logIn(request); + return isActivate; + } +} From 258044beeb15da43e3c68a0c48e653bb9e4fbce0 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:55:04 +0900 Subject: [PATCH 075/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=9C=A0=EC=A0=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=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/auth/auth.module.ts | 16 +++++++-- .../src/auth/tester/testerAuth.controller.ts | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/auth/tester/testerAuth.controller.ts diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index f513d613..c32261b7 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -1,13 +1,23 @@ import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; import { SessionSerializer } from '@/auth/session/session.serializer'; +import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; +import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; +import { TesterAuthService } from '@/auth/tester/testerAuth.service'; import { UserModule } from '@/user/user.module'; @Module({ - imports: [UserModule], - controllers: [GoogleAuthController], - providers: [GoogleStrategy, GoogleAuthService, SessionSerializer], + imports: [UserModule, PassportModule.register({ session: true })], + controllers: [GoogleAuthController, TesterAuthController], + providers: [ + GoogleStrategy, + GoogleAuthService, + SessionSerializer, + TesterAuthService, + TesterStrategy, + ], }) export class AuthModule {} diff --git a/packages/backend/src/auth/tester/testerAuth.controller.ts b/packages/backend/src/auth/tester/testerAuth.controller.ts new file mode 100644 index 00000000..d4381db9 --- /dev/null +++ b/packages/backend/src/auth/tester/testerAuth.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { TestAuthGuard } from '@/auth/tester/guard/tester.guard'; + +@ApiTags('Auth') +@Controller('auth/tester') +export class TesterAuthController { + constructor() {} + + @ApiOperation({ + summary: '테스터 로그인 api', + description: '테스터로 로그인합니다.', + }) + @Get('/login') + @UseGuards(TestAuthGuard) + async handleLogin(@Res() response: Response) { + response.redirect('/'); + } + + @ApiOperation({ + summary: '로그인 상태 확인', + description: '로그인 상태를 확인합니다.', + }) + @ApiOkResponse({ + description: '로그인된 상태', + example: { message: 'Authenticated' }, + }) + @Get('/status') + async user(@Req() request: Request) { + if (request.user) { + return { message: 'Authenticated' }; + } + return { message: 'Not Authenticated' }; + } +} From afb3bf339b857948441ce825eae8e75e03971ce8 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 23 Nov 2024 21:55:59 +0900 Subject: [PATCH 076/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20google?= =?UTF-8?q?=20strategy=20=EB=8D=94=EC=9D=B4=EC=83=81=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/auth/google/strategy/google.strategy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/auth/google/strategy/google.strategy.ts b/packages/backend/src/auth/google/strategy/google.strategy.ts index b87b7945..28295a2d 100644 --- a/packages/backend/src/auth/google/strategy/google.strategy.ts +++ b/packages/backend/src/auth/google/strategy/google.strategy.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { OauthType } from '@/user/domain/ouathType'; -import { Logger } from 'winston'; export interface OauthUserInfo { type: OauthType; @@ -15,7 +14,7 @@ export interface OauthUserInfo { @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy) { - constructor(private readonly googleAuthService: GoogleAuthService, @Inject('winston') private readonly logger: Logger) { + constructor(private readonly googleAuthService: GoogleAuthService) { super({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, From 8cbd2e4a8d665c50d519ab19a8037a8b6a0d936a Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 10:49:42 +0900 Subject: [PATCH 077/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20openapiPeriodData?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20-=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20private,=20filter=20=EC=82=AD=EC=A0=9C,=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .remote/version02/docker-compose.yml | 55 ---- .remote/version02/frontend.nginx | 36 --- .remote/version02/reverse-proxy.nginx | 77 ----- .remote/version03/docker-compose.yml | 0 .remote/version03/new.conf | 36 --- .remote/version03/reverseproxy.nginx | 86 ------ .remote/version04/docker-compose.yml | 43 --- .remote/version04/frontend.nginx | 86 ------ .remote/version04/new-reverse_proxy.nginx | 93 ------ .remote/version04/newfrontend.nginx | 73 ----- .remote/version04/reverse-proxy.nginx | 86 ------ .remote/version05/docker-compose.yml | 42 --- .remote/version05/frontend.nginx | 80 ----- .../openapi/api/openapiDetailData.api.ts | 280 ++++++++++++++++++ .../openapi/api/openapiLiveData.api.ts | 104 +++++++ .../openapi/api/openapiMinuteData.api.ts | 156 ++++++++++ .../openapi/api/openapiPeriodData.api.ts | 218 ++++++++++++++ .../openapi/api/openapiToken.api.ts | 114 +++++++ .../openapi/config/openapi.config.ts | 17 ++ .../openapi/openapi-scraper.module.ts | 43 +++ .../openapi/openapi-scraper.service.ts | 15 + .../openapi/type/openapiDetailData.type.ts | 214 +++++++++++++ .../openapi/type/openapiLiveData.type.ts | 152 ++++++++++ .../openapi/type/openapiMinuteData.type.ts | 33 +++ .../openapi/type/openapiPeriodData.ts | 45 +++ .../openapi/type/openapiUtil.type.ts | 13 + .../openapi/util/openapiCustom.error.ts | 13 + .../openapi/util/openapiUtil.api.ts | 115 +++++++ .../openapi/websocketClient.service.ts | 87 ++++++ 30 files changed, 1621 insertions(+), 793 deletions(-) delete mode 100644 .remote/version02/docker-compose.yml delete mode 100644 .remote/version02/frontend.nginx delete mode 100644 .remote/version02/reverse-proxy.nginx delete mode 100644 .remote/version03/docker-compose.yml delete mode 100644 .remote/version03/new.conf delete mode 100644 .remote/version03/reverseproxy.nginx delete mode 100644 .remote/version04/docker-compose.yml delete mode 100644 .remote/version04/frontend.nginx delete mode 100644 .remote/version04/new-reverse_proxy.nginx delete mode 100644 .remote/version04/newfrontend.nginx delete mode 100644 .remote/version04/reverse-proxy.nginx delete mode 100644 .remote/version05/docker-compose.yml delete mode 100644 .remote/version05/frontend.nginx create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts diff --git a/.gitignore b/.gitignore index 57004910..bdc0ede0 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # vscode setting .vscode +# remote +.remote diff --git a/.remote/version02/docker-compose.yml b/.remote/version02/docker-compose.yml deleted file mode 100644 index 32fa5759..00000000 --- a/.remote/version02/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -networks: - corp: - driver: bridge - -services: - nginx_proxy: - image: nginx:1.27.2-alpine - container_name: nginx_proxy - ports: - - '80:80' - - '443:443' - depends_on: #proxy가 먼저 켜지는 경우 죽어버리는 문제가 있었음. 의존성 추가 - - frontend - - backend - volumes: - - ./nginx/reverse-proxy.conf:/etc/nginx/nginx.conf - - ./data/certbot/conf:/etc/letsencrypt - - ./data/certbot/www:/var/www/certbot - command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" - networks: - - corp - - certbot: - image: certbot/certbot - volumes: - - ./data/certbot/conf:/etc/letsencrypt - - ./data/certbot/www:/var/www/certbot - entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" - - frontend: - image: sunghwki/frontend:latest - env_file: - - .env - environment: - - NODE_ENV=production - depends_on: - - backend - volumes: - - ./nginx/frontend.conf:/etc/nginx/nginx.conf - networks: - - corp - - backend: - image: sunghwki/backend:latest - env_file: - - .env - environment: - - DATABASE_URL=mysql://${DB_USER}:${DB_PASS}@db:${DB_PORT}/${DB_NAME} - volumes: - - ./logs:/packages/packages/logs - networks: - - corp - -volumes: - db-data: diff --git a/.remote/version02/frontend.nginx b/.remote/version02/frontend.nginx deleted file mode 100644 index f26a67ed..00000000 --- a/.remote/version02/frontend.nginx +++ /dev/null @@ -1,36 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - - server { - listen 8080; - - location / { - root /usr/share/nginx/html; - try_files $uri /index.html; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; # 이게 문제였다. 여기서 80포트를 포함시켜서 이미 있는 포트를 점유하는 중이었고, 그러다보니 문제가 발생했다. -} diff --git a/.remote/version02/reverse-proxy.nginx b/.remote/version02/reverse-proxy.nginx deleted file mode 100644 index 9887d1fd..00000000 --- a/.remote/version02/reverse-proxy.nginx +++ /dev/null @@ -1,77 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - server { - listen 80; - server_name juchum.info; - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - } - location / { - return 301 https://$host$request_uri; - } - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - proxy_pass http://static-server/; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/.remote/version03/docker-compose.yml b/.remote/version03/docker-compose.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/.remote/version03/new.conf b/.remote/version03/new.conf deleted file mode 100644 index f26a67ed..00000000 --- a/.remote/version03/new.conf +++ /dev/null @@ -1,36 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - - server { - listen 8080; - - location / { - root /usr/share/nginx/html; - try_files $uri /index.html; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; # 이게 문제였다. 여기서 80포트를 포함시켜서 이미 있는 포트를 점유하는 중이었고, 그러다보니 문제가 발생했다. -} diff --git a/.remote/version03/reverseproxy.nginx b/.remote/version03/reverseproxy.nginx deleted file mode 100644 index 2be13402..00000000 --- a/.remote/version03/reverseproxy.nginx +++ /dev/null @@ -1,86 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - - server { - listen 80; - server_name juchum.info; - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - } - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - proxy_pass http://static-server/; - proxy_http_version 1.1; - proxy_set_header Host $host; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_cookie_domain nest-api-server juchum.info; - proxy_cookie_path / /; - } - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/.remote/version04/docker-compose.yml b/.remote/version04/docker-compose.yml deleted file mode 100644 index 0feca385..00000000 --- a/.remote/version04/docker-compose.yml +++ /dev/null @@ -1,43 +0,0 @@ -networks: - corp: - driver: bridge - -services: - - frontend: - image: sunghwki/frontend:latest - env_file: - - .env - environment: - - NODE_ENV=production - depends_on: - - backend - volumes: - - ./nginx/frontend.conf:/etc/nginx/nginx.conf - - ./nginx/reverse-proxy.conf:/etc/nginx/nginx.conf - - ./data/certbot/conf:/etc/letsencrypt - - ./data/certbot/www:/var/www/certbot - command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"''' - networks: - - corp - - certbot: - image: certbot/certbot - volumes: - - ./data/certbot/conf:/etc/letsencrypt - - ./data/certbot/www:/var/www/certbot - entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" - - backend: - image: sunghwki/backend:latest - env_file: - - .env - environment: - - DATABASE_URL=mysql://${DB_USER}:${DB_PASS}@db:${DB_PORT}/${DB_NAME} - volumes: - - ./logs:/packages/packages/logs - networks: - - corp - -volumes: - db-data: diff --git a/.remote/version04/frontend.nginx b/.remote/version04/frontend.nginx deleted file mode 100644 index 2be13402..00000000 --- a/.remote/version04/frontend.nginx +++ /dev/null @@ -1,86 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - - server { - listen 80; - server_name juchum.info; - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - } - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - proxy_pass http://static-server/; - proxy_http_version 1.1; - proxy_set_header Host $host; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_cookie_domain nest-api-server juchum.info; - proxy_cookie_path / /; - } - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/.remote/version04/new-reverse_proxy.nginx b/.remote/version04/new-reverse_proxy.nginx deleted file mode 100644 index c75785bf..00000000 --- a/.remote/version04/new-reverse_proxy.nginx +++ /dev/null @@ -1,93 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - - server { - listen 80; - server_name juchum.info; - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - } - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - proxy_pass http://static-server/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://juchum.info - proxy_redirect off; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_cookie_domain nest-api-server juchum.info; - proxy_cookie_path / /; - } - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/.remote/version04/newfrontend.nginx b/.remote/version04/newfrontend.nginx deleted file mode 100644 index e3a75870..00000000 --- a/.remote/version04/newfrontend.nginx +++ /dev/null @@ -1,73 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - server { - listen 80; - server_name juchum.info; - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - root /usr/share/nginx/html; - index index.html; - try_files $uri $uri/ /index.html; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_cookie_domain nest-api-server juchum.info; - proxy_cookie_path / /; - } - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/.remote/version04/reverse-proxy.nginx b/.remote/version04/reverse-proxy.nginx deleted file mode 100644 index 2be13402..00000000 --- a/.remote/version04/reverse-proxy.nginx +++ /dev/null @@ -1,86 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - # 프론트엔드 upstream 설정 - upstream static-server { - server frontend:8080; - } - - server { - listen 80; - server_name juchum.info; - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - } - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - proxy_pass http://static-server/; - proxy_http_version 1.1; - proxy_set_header Host $host; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_cookie_domain nest-api-server juchum.info; - proxy_cookie_path / /; - } - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/.remote/version05/docker-compose.yml b/.remote/version05/docker-compose.yml deleted file mode 100644 index 68a36250..00000000 --- a/.remote/version05/docker-compose.yml +++ /dev/null @@ -1,42 +0,0 @@ -networks: - corp: - driver: bridge -services: - frontend: - image: sunghwki/frontend:latest - build: - context: ./frontend - dockerfile: Dockerfile - ports: - - "80:80" - - "443:443" - env_file: - - .env - depends_on: - - backend - volumes: - - ./nginx/frontend.conf:/etc/nginx/nginx.conf - - ./data/certbot/conf:/etc/letsencrypt - - ./data/certbot/www:/var/www/certbot - command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"''' - networks: - - corp - - certbot: - image: certbot/certbot - volumes: - - ./data/certbot/conf:/etc/letsencrypt - - ./data/certbot/www:/var/www/certbot - entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" - - backend: - image: sunghwki/backend:latest - env_file: - - .env - volumes: - - ./logs:/packages/packages/logs - networks: - - corp - -volumes: - db-data: diff --git a/.remote/version05/frontend.nginx b/.remote/version05/frontend.nginx deleted file mode 100644 index 7d343da3..00000000 --- a/.remote/version05/frontend.nginx +++ /dev/null @@ -1,80 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # 백엔드 upstream 설정 - upstream nest-api-server { - server backend:3000; - } - - server { - listen 80; - server_name juchum.info; - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - } - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name juchum.info; - - ssl_certificate /etc/letsencrypt/live/juchum.info/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/juchum.info/privkey.pem; - - location / { - root /usr/share/nginx/html; - try_files $uri /index.html; - } - - location /api { - proxy_pass http://nest-api-server/api; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_cookie_domain nest-api-server juchum.info; - proxy_cookie_path / /; - } - - location /socket.io { - proxy_pass http://nest-api-server/socket.io; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - #include /etc/nginx/conf.d/*.conf; -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts new file mode 100644 index 00000000..1d1fa103 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts @@ -0,0 +1,280 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Between, DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { + DetailDataQuery, + FinancialRatio, + isFinancialRatioData, + isProductDetail, + ProductDetail, + StockDetailQuery, +} from '../type/openapiDetailData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getOpenApi } from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { KospiStock } from '@/stock/domain/kospiStock.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockDaily } from '@/stock/domain/stockData.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; + +@Injectable() +export class OpenapiDetailData { + private readonly financialUrl: string = + '/uapi/domestic-stock/v1/finance/financial-ratio'; + private readonly productUrl: string = + '/uapi/domestic-stock/v1/quotations/search-stock-info'; + private readonly intervals = 1000; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + //setTimeout(() => this.getDetailData(), 5000); + } + + @Cron('0 8 * * 1-5') + async getDetailData() { + if (process.env.NODE_ENV !== 'production') return; + const entityManager = this.datasource.manager; + const stocks = await entityManager.find(Stock); + const configCount = openApiToken.configs.length; + const chunkSize = Math.ceil(stocks.length / configCount); + + for (let i = 0; i < configCount; i++) { + this.logger.info(openApiToken.configs[i]); + const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); + this.getDetailDataChunk(chunk, openApiToken.configs[i]); + } + } + + private async saveDetailData(stockDetail: StockDetail) { + const manager = this.datasource.manager; + const entity = StockDetail; + const existingStockDetail = await manager.findOne(entity, { + where: { + stock: { id: stockDetail.stock.id }, + }, + }); + if (existingStockDetail) { + manager.update( + entity, + { stock: { id: stockDetail.stock.id } }, + stockDetail, + ); + } else { + manager.save(entity, stockDetail); + } + } + + private async saveKospiData(stockDetail: KospiStock) { + const manager = this.datasource.manager; + const entity = KospiStock; + const existingStockDetail = await manager.findOne(entity, { + where: { + stock: { id: stockDetail.stock.id }, + }, + }); + + if (existingStockDetail) { + manager.update( + entity, + { stock: { id: stockDetail.stock.id } }, + stockDetail, + ); + } else { + manager.save(entity, stockDetail); + } + } + + private async calPer(eps: number): Promise { + if (eps <= 0) return NaN; + const manager = this.datasource.manager; + const latestResult = await manager.find(StockDaily, { + skip: 0, + take: 1, + order: { createdAt: 'desc' }, + }); + // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 + if (latestResult && latestResult[0] && latestResult[0].close) { + const currentPrice = latestResult[0].close; + const per = currentPrice / eps; + + if (isNaN(per)) return 0; + else return per; + } else { + return 0; + } + } + + private async calMarketCap(lstg: number) { + const manager = this.datasource.manager; + const latestResult = await manager.find(StockDaily, { + skip: 0, + take: 1, + order: { createdAt: 'desc' }, + }); + + // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 + if (latestResult && latestResult[0] && latestResult[0].close) { + const currentPrice = latestResult[0].close; + const marketCap = lstg * currentPrice; + + if (isNaN(marketCap)) return 0; + else return marketCap; + } else { + return 0; + } + } + + private async get52WeeksLowHigh() { + const manager = this.datasource.manager; + const nowDate = new Date(); + const weeksAgoDate = this.getDate52WeeksAgo(); + // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 + const output = await manager.find(StockDaily, { + select: ['low', 'high'], + where: { + startTime: Between(weeksAgoDate, nowDate), + }, + }); + const result = output.reduce((prev, cur) => { + if (prev.low > cur.low) prev.low = cur.low; + if (prev.high < cur.high) prev.high = cur.high; + return cur; + }, new StockDaily()); + let low = 0; + let high = 0; + if (result.low && !isNaN(result.low)) low = result.low; + if (result.high && !isNaN(result.high)) high = result.high; + return { low, high }; + } + + private async makeStockDetailObject( + output1: FinancialRatio, + output2: ProductDetail, + stockId: string, + ): Promise { + const result = new StockDetail(); + result.stock = { id: stockId } as Stock; + result.marketCap = + (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; + result.eps = parseInt(output1.eps); + const { low, high } = await this.get52WeeksLowHigh(); + result.low52w = low; + result.high52w = high; + const eps = parseInt(output1.eps); + if (isNaN(eps)) result.eps = 0; + else result.eps = eps; + const per = await this.calPer(eps); + if (isNaN(per)) result.per = 0; + else result.per = per; + result.updatedAt = new Date(); + return result; + } + + private async makeKospiStockObject(output: ProductDetail, stockId: string) { + const ret = new KospiStock(); + ret.isKospi = output.kospi200_item_yn === 'Y' ? true : false; + ret.stock = { id: stockId } as Stock; + return ret; + } + + private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { + const dataQuery = this.getDetailDataQuery(stock.id!); + // 여기서 가져올 건 eps -> eps와 per 계산하자. + try { + const response = await getOpenApi( + this.financialUrl, + conf, + dataQuery, + TR_IDS.FINANCIAL_DATA, + ); + if (response.output) { + const output1 = response.output; + return output1[0]; + } + } catch (error) { + this.logger.error(error); + } + } + + private async getProductData(stock: Stock, conf: typeof openApiConfig) { + const defaultQuery = this.getFinancialDataQuery(stock.id!); + + // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 + try { + const response = await getOpenApi( + this.productUrl, + conf, + defaultQuery, + TR_IDS.PRODUCTION_DETAIL, + ); + if (response.output) { + const output2 = response.output; + return output2; + //return bufferToObject(output2); + } + } catch (error) { + this.logger.error(error); + } + } + + private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { + const output1 = await this.getFinancialRatio(stock, conf); + const output2 = await this.getProductData(stock, conf); + + this.logger.info(JSON.stringify(output1)); + this.logger.info(JSON.stringify(output2)); + if (isFinancialRatioData(output1) && isProductDetail(output2)) { + const stockDetail = await this.makeStockDetailObject( + output1, + output2, + stock.id!, + ); + this.saveDetailData(stockDetail); + const kospiStock = await this.makeKospiStockObject(output2, stock.id!); + this.saveKospiData(kospiStock); + + this.logger.info(`${stock.id!} is saved`); + } + } + + private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { + let delay = 0; + for await (const stock of chunk) { + setTimeout(() => this.getDetailDataDelay(stock, conf), delay); + delay += this.intervals; + } + } + + private getFinancialDataQuery( + stockId: string, + code: '300' | '301' | '302' | '306' = '300', + ): StockDetailQuery { + return { + pdno: stockId, + prdt_type_cd: code, + }; + } + + private getDetailDataQuery( + stockId: string, + divCode: 'J' = 'J', + classify: '0' | '1' = '0', + ): DetailDataQuery { + return { + fid_div_cls_code: classify, + fid_cond_mrkt_div_code: divCode, + fid_input_iscd: stockId, + }; + } + + private getDate52WeeksAgo(): Date { + const today = new Date(); + const weeksAgo = 52 * 7; + const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); + date52WeeksAgo.setHours(0, 0, 0, 0); + return date52WeeksAgo; + } +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts new file mode 100644 index 00000000..712d6d2d --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts @@ -0,0 +1,104 @@ +import { Inject } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { StockData, parseStockData } from '../type/openapiLiveData.type'; +import { decryptAES256 } from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { KospiStock } from '@/stock/domain/kospiStock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +export class OpenapiLiveData { + public readonly TR_ID: string = 'H0STCNT0'; + private readonly WEBSOCKET_MAX: number = 40; + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly manager: EntityManager, + ) {} + + async getMessage(): Promise { + const kospi = await this.getKospiStockId(); + const config = openApiToken.configs; + const configLength = config.length; + const ret: string[] = []; + + for (let i = 0; i < configLength; i++) { + const stocks = kospi.splice( + i * this.WEBSOCKET_MAX, + (i + 1) * this.WEBSOCKET_MAX, + ); + for (const stock of stocks) { + ret.push(this.convertObjectToMessage(config[i], stock.id!)); + } + } + + return ret; + } + + private convertObjectToMessage( + config: typeof openApiConfig, + stockId: string, + ): string { + const message = { + header: { + approval_key: config.STOCK_WEBSOCKET_KEY!, + custtype: 'P', + tr_type: '1', + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: this.TR_ID, + tr_key: stockId, + }, + }, + }; + return JSON.stringify(message); + } + + private async getKospiStockId() { + const kospi = await this.manager.find(KospiStock, { + where: { + isKospi: true, + }, + }); + return kospi; + } + + private async saveLiveData(data: StockLiveData) { + await this.manager.save(StockLiveData, data); + } + + private convertLiveData(message: string[]): StockLiveData { + const stockData: StockData = parseStockData(message); + const stockLiveData = new StockLiveData(); + stockLiveData.currentPrice = parseFloat(stockData.STCK_PRPR); + stockLiveData.changeRate = parseFloat(stockData.PRDY_CTRT); + stockLiveData.volume = parseInt(stockData.CNTG_VOL); + stockLiveData.high = parseFloat(stockData.STCK_HGPR); + stockLiveData.low = parseFloat(stockData.STCK_LWPR); + stockLiveData.open = parseFloat(stockData.STCK_OPRC); + stockLiveData.previousClose = parseFloat(stockData.WGHN_AVRG_STCK_PRC); + stockLiveData.updatedAt = new Date(); + + return stockLiveData; + } + + public async output(message: Buffer, iv?: string, key?: string) { + const parsed = message.toString().split('|'); + if (parsed.length > 0) { + if (parsed[0] == '1' && iv && key) + parsed[3] = decryptAES256(parsed[3], iv, key); + if (parsed[1] !== this.TR_ID) return; + const stockData = parsed[3].split('^'); + const length = stockData.length / parseInt(parsed[2]); + const size = parseInt(parsed[2]); + const i = 0; + while (i < size) { + const data = stockData.splice(i * length, (i + 1) * length); + const liveData = this.convertLiveData(data); + this.saveLiveData(liveData); + } + } + } +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts new file mode 100644 index 00000000..003e7560 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts @@ -0,0 +1,156 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; + +import { + isMinuteData, + MinuteData, + UpdateStockQuery, +} from '../type/openapiMinuteData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; + +const STOCK_CUT = 4; + +@Injectable() +export class OpenapiMinuteData { + private stock: Stock[][] = []; + private readonly entity = StockMinutely; + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; + private readonly intervals: number = 130; + private flip: number = 0; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + this.getStockData(); + } + + @Cron('0 1 * * 1-5') + async getStockData() { + if (process.env.NODE_ENV !== 'production') return; + const stock = await this.datasource.manager.findBy(Stock, { + isTrading: true, + }); + const stockSize = Math.ceil(stock.length / STOCK_CUT); + let i = 0; + this.stock = []; + while (i < STOCK_CUT) { + this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; + } + } + + private convertResToMinuteData( + stockId: string, + item: MinuteData, + time: string, + ) { + const stockPeriod = new StockData(); + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(item.stck_bsop_date.slice(0, 4)), + parseInt(item.stck_bsop_date.slice(4, 6)) - 1, + parseInt(item.stck_bsop_date.slice(6, 8)), + parseInt(time.slice(0, 2)), + parseInt(time.slice(2, 4)), + ); + stockPeriod.close = parseInt(item.stck_prpr); + stockPeriod.open = parseInt(item.stck_oprc); + stockPeriod.high = parseInt(item.stck_hgpr); + stockPeriod.low = parseInt(item.stck_lwpr); + stockPeriod.volume = parseInt(item.cntg_vol); + stockPeriod.createdAt = new Date(); + return stockPeriod; + } + + private isMarketOpenTime(time: string) { + const numberTime = parseInt(time); + return numberTime >= 90000 && numberTime <= 153000; + } + + private async saveMinuteData( + stockId: string, + item: MinuteData[], + time: string, + ) { + const manager = this.datasource.manager; + if (!this.isMarketOpenTime(time)) return; + const stockPeriod = item.map((val) => + this.convertResToMinuteData(stockId, val, time), + ); + manager.save(this.entity, stockPeriod); + } + + private async getMinuteDataInterval( + stockId: string, + time: string, + config: typeof openApiConfig, + ) { + const query = this.getUpdateStockQuery(stockId, time); + try { + const response = await getOpenApi( + this.url, + config, + query, + TR_IDS.MINUTE_DATA, + ); + let output; + if (response.output2) output = response.output2; + if (output && output[0] && isMinuteData(output[0])) { + this.saveMinuteData(stockId, output, time); + } + } catch (error) { + this.logger.warn(error); + } + } + + private async getMinuteDataChunk( + chunk: Stock[], + config: typeof openApiConfig, + ) { + const time = getCurrentTime(); + let interval = 0; + for await (const stock of chunk) { + setTimeout( + () => this.getMinuteDataInterval(stock.id!, time, config), + interval, + ); + interval += this.intervals; + } + } + + @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) + getMinuteData() { + if (process.env.NODE_ENV !== 'production') return; + const configCount = openApiToken.configs.length; + const stock = this.stock[this.flip % STOCK_CUT]; + this.flip++; + const chunkSize = Math.ceil(stock.length / configCount); + for (let i = 0; i < configCount; i++) { + const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); + this.getMinuteDataChunk(chunk, openApiToken.configs[i]); + } + } + + private getUpdateStockQuery( + stockId: string, + time: string, + isPastData: boolean = true, + marketCode: 'J' | 'W' = 'J', + ): UpdateStockQuery { + return { + fid_etc_cls_code: '', + fid_cond_mrkt_div_code: marketCode, + fid_input_iscd: stockId, + fid_input_hour_1: time, + fid_pw_data_incu_yn: isPastData ? 'Y' : 'N', + }; + } +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts new file mode 100644 index 00000000..f4268088 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts @@ -0,0 +1,218 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { + ChartData, + isChartData, + ItemChartPriceQuery, + Period, +} from '../type/openapiPeriodData'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { + getOpenApi, + getPreviousDate, + getTodayDate, +} from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { + StockData, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, +} from '@/stock/domain/stockData.entity'; + +const DATE_TO_ENTITY = { + D: StockDaily, + W: StockWeekly, + M: StockMonthly, + Y: StockYearly, +}; + +const DATE_TO_MONTH = { + D: 3, + W: 6, + M: 12, + Y: 24, +}; + +const INTERVALS = 4000; + +@Injectable() +export class OpenapiPeriodData { + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + //this.getItemChartPriceCheck(); + } + + @Cron('0 1 * * 1-5') + async getItemChartPriceCheck() { + if (process.env.NODE_ENV !== 'production') return; + const stocks = await this.datasource.manager.find(Stock, { + where: { + isTrading: true, + }, + }); + const configCount = openApiToken.configs.length; + const chunkSize = Math.ceil(stocks.length / configCount); + + for (let i = 0; i < configCount; i++) { + const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); + this.getChartData(chunk, 'D'); + setTimeout(() => this.getChartData(chunk, 'W'), INTERVALS); + setTimeout(() => this.getChartData(chunk, 'M'), INTERVALS * 2); + setTimeout(() => this.getChartData(chunk, 'Y'), INTERVALS * 3); + } + } + + private async getChartData(chunk: Stock[], period: Period) { + const baseTime = INTERVALS * 4; + const entity = DATE_TO_ENTITY[period]; + + let time = 0; + for (const stock of chunk) { + time += baseTime; + setTimeout(() => this.processStockData(stock, period, entity), time); + } + } + + private async processStockData( + stock: Stock, + period: Period, + entity: typeof StockData, + ) { + const stockPeriod = new StockData(); + const manager = this.datasource.manager; + let configIdx = 0; + let end = getTodayDate(); + let start = getPreviousDate(end, 3); + let isFail = false; + + while (!isFail) { + configIdx = (configIdx + 1) % openApiToken.configs.length; + this.setStockPeriod(stockPeriod, stock.id!, end); + + // chart 데이터가 있는 지 확인 -> 리턴 + if (await this.existsChartData(stockPeriod, manager, entity)) return; + + const query = this.getItemChartPriceQuery(stock.id!, start, end, period); + + const output = await this.fetchChartData(query, configIdx); + + if (output) { + await this.saveChartData(entity, stock.id!, output); + ({ endDate: end, startDate: start } = this.updateDates(start, period)); + } else isFail = true; + } + } + + private setStockPeriod( + stockPeriod: StockData, + stockId: string, + endDate: string, + ): void { + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(endDate.slice(0, 4)), + parseInt(endDate.slice(4, 6)) - 1, + parseInt(endDate.slice(6, 8)), + ); + } + + private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { + try { + const response = await getOpenApi( + this.url, + openApiToken.configs[configIdx], + query, + TR_IDS.ITEM_CHART_PRICE, + ); + return response.output2 as ChartData[]; + } catch (error) { + this.logger.warn(error); + } + } + + private updateDates( + startDate: string, + period: Period, + ): { endDate: string; startDate: string } { + const endDate = getPreviousDate(startDate, DATE_TO_MONTH[period]); + startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); + return { endDate, startDate }; + } + + private async existsChartData( + stock: StockData, + manager: EntityManager, + entity: typeof StockData, + ) { + return await manager.findOne(entity, { + where: { + stock: { id: stock.stock.id }, + createdAt: stock.startTime, + }, + }); + } + + private async insertChartData(stock: StockData, entity: typeof StockData) { + const manager = this.datasource.manager; + if (!(await this.existsChartData(stock, manager, entity))) { + await manager.save(entity, stock); + } + } + + private convertObjectToStockData(item: ChartData, stockId: string) { + const stockPeriod = new StockData(); + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(item.stck_bsop_date.slice(0, 4)), + parseInt(item.stck_bsop_date.slice(4, 6)) - 1, + parseInt(item.stck_bsop_date.slice(6, 8)), + ); + stockPeriod.close = parseInt(item.stck_clpr); + stockPeriod.open = parseInt(item.stck_oprc); + stockPeriod.high = parseInt(item.stck_hgpr); + stockPeriod.low = parseInt(item.stck_lwpr); + stockPeriod.volume = parseInt(item.acml_vol); + stockPeriod.createdAt = new Date(); + return stockPeriod; + } + + private async saveChartData( + entity: typeof StockData, + stockId: string, + data: ChartData[], + ) { + for (const item of data) { + if (!isChartData(item)) { + continue; + } + const stockPeriod = this.convertObjectToStockData(item, stockId); + await this.insertChartData(stockPeriod, entity); + } + } + + private getItemChartPriceQuery( + stockId: string, + startDate: string, + endDate: string, + period: Period, + marketCode: 'J' | 'W' = 'J', + ): ItemChartPriceQuery { + return { + fid_cond_mrkt_div_code: marketCode, + fid_input_iscd: stockId, + fid_input_date_1: startDate, + fid_input_date_2: endDate, + fid_period_div_code: period, + fid_org_adj_prc: 0, + }; + } +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts new file mode 100644 index 00000000..14ffd2e8 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts @@ -0,0 +1,114 @@ +import { Inject, UseFilters } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; +import { OpenapiException } from '../util/openapiCustom.error'; +import { postOpenApi } from '../util/openapiUtil.api'; +import { logger } from '@/configs/logger.config'; + +class OpenapiTokenApi { + private config: (typeof openApiConfig)[] = []; + constructor(@Inject('winston') private readonly logger: Logger) { + const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); + const api_keys = openApiConfig.STOCK_API_KEY!.split(','); + const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); + if ( + accounts.length === 0 || + accounts.length !== api_keys.length || + api_passwords.length !== api_keys.length + ) { + this.logger.warn('Open API Config Error'); + } + for (let i = 0; i < accounts.length; i++) { + this.config.push({ + STOCK_URL: openApiConfig.STOCK_URL, + STOCK_ACCOUNT: accounts[i], + STOCK_API_KEY: api_keys[i], + STOCK_API_PASSWORD: api_passwords[i], + }); + } + this.initAuthenValue(); + } + + get configs() { + //TODO : 현재 구조에서 받아올 때마다 확인후 할당으로 변경 + return this.config; + } + + @UseFilters(OpenapiExceptionFilter) + private async initAuthenValue() { + const delay = 60000; + const delayMinute = delay / 1000 / 60; + + try { + await this.initAccessToken(); + await this.initWebSocketKey(); + } catch (error) { + if (error instanceof Error) { + this.logger.warn( + `Request failed: ${error.message}. Retrying in ${delayMinute} minute...`, + ); + } else { + this.logger.warn( + `Request failed. Retrying in ${delayMinute} minute...`, + ); + setTimeout(async () => { + await this.initAccessToken(); + await this.initWebSocketKey(); + }, delay); + } + } + } + + @Cron('50 0 * * 1-5') + private async initAccessToken() { + const updatedConfig = await Promise.all( + this.config.map(async (val) => { + val.STOCK_API_TOKEN = await this.getToken(val)!; + return val; + }), + ); + this.config = updatedConfig; + } + + @Cron('50 0 * * 1-5') + private async initWebSocketKey() { + const updatedConfig = await Promise.all( + this.config.map(async (val) => { + val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; + return val; + }), + ); + this.config = updatedConfig; + } + + private async getToken(config: typeof openApiConfig): Promise { + const body = { + grant_type: 'client_credentials', + appkey: config.STOCK_API_KEY, + appsecret: config.STOCK_API_PASSWORD, + }; + const tmp = await postOpenApi('/oauth2/tokenP', config, body); + if (!tmp.access_token) { + throw new OpenapiException('Access Token Failed', 403); + } + return tmp.access_token as string; + } + + private async getWebSocketKey(config: typeof openApiConfig): Promise { + const body = { + grant_type: 'client_credentials', + appkey: config.STOCK_API_KEY, + secretkey: config.STOCK_API_PASSWORD, + }; + const tmp = await postOpenApi('/oauth2/Approval', config, body); + if (!tmp.approval_key) { + throw new OpenapiException('WebSocket Key Failed', 403); + } + return tmp.approval_key as string; + } +} + +const openApiToken = new OpenapiTokenApi(logger); +export { openApiToken }; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts b/packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts new file mode 100644 index 00000000..8aa12ea3 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts @@ -0,0 +1,17 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const openApiConfig: { + STOCK_URL: string | undefined; + STOCK_ACCOUNT: string | undefined; + STOCK_API_KEY: string | undefined; + STOCK_API_PASSWORD: string | undefined; + STOCK_API_TOKEN?: string; + STOCK_WEBSOCKET_KEY?: string; +} = { + STOCK_URL: process.env.STOCK_URL, + STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, + STOCK_API_KEY: process.env.STOCK_API_KEY, + STOCK_API_PASSWORD: process.env.STOCK_API_PASSWORD, +}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts new file mode 100644 index 00000000..cb45c91c --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { OpenapiMinuteData } from './api/openapiMinuteData.api'; +import { OpenapiPeriodData } from './api/openapiPeriodData.api'; +import { OpenapiScraperService } from './openapi-scraper.service'; +import { WebsocketClient } from './websocketClient.service'; +import { Stock } from '@/stock/domain/stock.entity'; +import { + StockDaily, + StockMinutely, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Stock, + StockMinutely, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, + StockLiveData, + StockDetail, + ]), + ], + controllers: [], + providers: [ + OpenapiPeriodData, + OpenapiMinuteData, + OpenapiDetailData, + OpenapiScraperService, + OpenapiLiveData, + WebsocketClient, + ], +}) +export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts new file mode 100644 index 00000000..52c90179 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiMinuteData } from './api/openapiMinuteData.api'; +import { OpenapiPeriodData } from './api/openapiPeriodData.api'; + +@Injectable() +export class OpenapiScraperService { + public constructor( + private datasource: DataSource, + private readonly openapiPeriodData: OpenapiPeriodData, + private readonly openapiMinuteData: OpenapiMinuteData, + private readonly openapiDetailData: OpenapiDetailData, + ) {} +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts new file mode 100644 index 00000000..38015d48 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +/* eslint-disable max-lines-per-function */ + +export type DetailDataQuery = { + fid_cond_mrkt_div_code: 'J'; + fid_input_iscd: string; + fid_div_cls_code: '0' | '1'; +}; + +export type FinancialRatio = { + stac_yymm: string; // 결산 년월 + grs: string; // 매출액 증가율 + bsop_prfi_inrt: string; // 영업 이익 증가율 + ntin_inrt: string; // 순이익 증가율 + roe_val: string; // ROE 값 + eps: string; // EPS + sps: string; // 주당매출액 + bps: string; // BPS + rsrv_rate: string; // 유보 비율 + lblt_rate: string; // 부채 비율 +}; + +export function isFinancialRatioData(data: any): data is FinancialRatio { + return ( + data && + typeof data.stac_yymm === 'string' && + typeof data.grs === 'string' && + typeof data.bsop_prfi_inrt === 'string' && + typeof data.ntin_inrt === 'string' && + typeof data.roe_val === 'string' && + typeof data.eps === 'string' && + typeof data.sps === 'string' && + typeof data.bps === 'string' && + typeof data.rsrv_rate === 'string' && + typeof data.lblt_rate === 'string' + ); +} + +export type ProductDetail = { + pdno: string; // 상품번호 + prdt_type_cd: string; // 상품유형코드 + mket_id_cd: string; // 시장ID코드 + scty_grp_id_cd: string; // 증권그룹ID코드 + excg_dvsn_cd: string; // 거래소구분코드 + setl_mmdd: string; // 결산월일 + lstg_stqt: string; // 상장주수 - 이거 사용 + lstg_cptl_amt: string; // 상장자본금액 + cpta: string; // 자본금 + papr: string; // 액면가 + issu_pric: string; // 발행가격 + kospi200_item_yn: string; // 코스피200종목여부 - 이것도 사용 + scts_mket_lstg_dt: string; // 유가증권시장상장일자 + scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 + kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 + kosdaq_mket_lstg_abol_dt: string; // 코스닥시장상장폐지일자 + frbd_mket_lstg_dt: string; // 프리보드시장상장일자 + frbd_mket_lstg_abol_dt: string; // 프리보드시장상장폐지일자 + reits_kind_cd: string; // 리츠종류코드 + etf_dvsn_cd: string; // ETF구분코드 + oilf_fund_yn: string; // 유전펀드여부 + idx_bztp_lcls_cd: string; // 지수업종대분류코드 + idx_bztp_mcls_cd: string; // 지수업종중분류코드 + idx_bztp_scls_cd: string; // 지수업종소분류코드 + stck_kind_cd: string; // 주식종류코드 + mfnd_opng_dt: string; // 뮤추얼펀드개시일자 + mfnd_end_dt: string; // 뮤추얼펀드종료일자 + dpsi_erlm_cncl_dt: string; // 예탁등록취소일자 + etf_cu_qty: string; // ETFCU수량 + prdt_name: string; // 상품명 + prdt_name120: string; // 상품명120 + prdt_abrv_name: string; // 상품약어명 + std_pdno: string; // 표준상품번호 + prdt_eng_name: string; // 상품영문명 + prdt_eng_name120: string; // 상품영문명120 + prdt_eng_abrv_name: string; // 상품영문약어명 + dpsi_aptm_erlm_yn: string; // 예탁지정등록여부 + etf_txtn_type_cd: string; // ETF과세유형코드 + etf_type_cd: string; // ETF유형코드 + lstg_abol_dt: string; // 상장폐지일자 + nwst_odst_dvsn_cd: string; // 신주구주구분코드 + sbst_pric: string; // 대용가격 + thco_sbst_pric: string; // 당사대용가격 + thco_sbst_pric_chng_dt: string; // 당사대용가격변경일자 + tr_stop_yn: string; // 거래정지여부 + admn_item_yn: string; // 관리종목여부 + thdt_clpr: string; // 당일종가 + bfdy_clpr: string; // 전일종가 + clpr_chng_dt: string; // 종가변경일자 + std_idst_clsf_cd: string; // 표준산업분류코드 + std_idst_clsf_cd_name: string; // 표준산업분류코드명 + idx_bztp_lcls_cd_name: string; // 지수업종대분류코드명 + idx_bztp_mcls_cd_name: string; // 지수업종중분류코드명 + idx_bztp_scls_cd_name: string; // 지수업종소분류코드명 + ocr_no: string; // OCR번호 + crfd_item_yn: string; // 크라우드펀딩종목여부 + elec_scty_yn: string; // 전자증권여부 + issu_istt_cd: string; // 발행기관코드 + etf_chas_erng_rt_dbnb: string; // ETF추적수익율배수 + etf_etn_ivst_heed_item_yn: string; // ETFETN투자유의종목여부 + stln_int_rt_dvsn_cd: string; // 대주이자율구분코드 + frnr_psnl_lmt_rt: string; // 외국인개인한도비율 + lstg_rqsr_issu_istt_cd: string; // 상장신청인발행기관코드 + lstg_rqsr_item_cd: string; // 상장신청인종목코드 + trst_istt_issu_istt_cd: string; // 신탁기관발행기관코드 +}; + +export const isProductDetail = (data: any): data is ProductDetail => { + return ( + typeof data.pdno === 'string' && + typeof data.prdt_type_cd === 'string' && + typeof data.mket_id_cd === 'string' && + typeof data.scty_grp_id_cd === 'string' && + typeof data.excg_dvsn_cd === 'string' && + typeof data.setl_mmdd === 'string' && + typeof data.lstg_stqt === 'string' && + typeof data.lstg_cptl_amt === 'string' && + typeof data.cpta === 'string' && + typeof data.papr === 'string' && + typeof data.issu_pric === 'string' && + typeof data.kospi200_item_yn === 'string' && + typeof data.scts_mket_lstg_dt === 'string' && + typeof data.scts_mket_lstg_abol_dt === 'string' && + typeof data.kosdaq_mket_lstg_dt === 'string' && + typeof data.kosdaq_mket_lstg_abol_dt === 'string' && + typeof data.frbd_mket_lstg_dt === 'string' && + typeof data.frbd_mket_lstg_abol_dt === 'string' && + typeof data.reits_kind_cd === 'string' && + typeof data.etf_dvsn_cd === 'string' && + typeof data.oilf_fund_yn === 'string' && + typeof data.idx_bztp_lcls_cd === 'string' && + typeof data.idx_bztp_mcls_cd === 'string' && + typeof data.idx_bztp_scls_cd === 'string' && + typeof data.stck_kind_cd === 'string' && + typeof data.mfnd_opng_dt === 'string' && + typeof data.mfnd_end_dt === 'string' && + typeof data.dpsi_erlm_cncl_dt === 'string' && + typeof data.etf_cu_qty === 'string' && + typeof data.prdt_name === 'string' && + typeof data.prdt_name120 === 'string' && + typeof data.prdt_abrv_name === 'string' && + typeof data.std_pdno === 'string' && + typeof data.prdt_eng_name === 'string' && + typeof data.prdt_eng_name120 === 'string' && + typeof data.prdt_eng_abrv_name === 'string' && + typeof data.dpsi_aptm_erlm_yn === 'string' && + typeof data.etf_txtn_type_cd === 'string' && + typeof data.etf_type_cd === 'string' && + typeof data.lstg_abol_dt === 'string' && + typeof data.nwst_odst_dvsn_cd === 'string' && + typeof data.sbst_pric === 'string' && + typeof data.thco_sbst_pric === 'string' && + typeof data.thco_sbst_pric_chng_dt === 'string' && + typeof data.tr_stop_yn === 'string' && + typeof data.admn_item_yn === 'string' && + typeof data.thdt_clpr === 'string' && + typeof data.bfdy_clpr === 'string' && + typeof data.clpr_chng_dt === 'string' && + typeof data.std_idst_clsf_cd === 'string' && + typeof data.std_idst_clsf_cd_name === 'string' && + typeof data.idx_bztp_lcls_cd_name === 'string' && + typeof data.idx_bztp_mcls_cd_name === 'string' && + typeof data.idx_bztp_scls_cd_name === 'string' && + typeof data.ocr_no === 'string' && + typeof data.crfd_item_yn === 'string' && + typeof data.elec_scty_yn === 'string' && + typeof data.issu_istt_cd === 'string' && + typeof data.etf_chas_erng_rt_dbnb === 'string' && + typeof data.etf_etn_ivst_heed_item_yn === 'string' && + typeof data.stln_int_rt_dvsn_cd === 'string' && + typeof data.frnr_psnl_lmt_rt === 'string' && + typeof data.lstg_rqsr_issu_istt_cd === 'string' && + typeof data.lstg_rqsr_item_cd === 'string' && + typeof data.trst_istt_issu_istt_cd === 'string' + ); +}; + +export type StockDetailQuery = { + pdno: string; + prdt_type_cd: string; +}; + +//export type FinancialDetail = { +// stac_yymm: string; // 결산 년월 +// sale_account: string; // 매출액 +// sale_cost: string; // 매출원가 +// sale_totl_prfi: string; // 매출총이익 +// depr_cost: string; // 감가상각비 +// sell_mang: string; // 판매관리비 +// bsop_prti: string; // 영업이익 +// bsop_non_ernn: string; // 영업외수익 +// bsop_non_expn: string; // 영업외비용 +// op_prfi: string; // 영업이익 +// spec_prfi: string; // 특별이익 +// spec_loss: string; // 특별손실 +// thtr_ntin: string; // 세전순이익 +//}; + +//export const isFinancialDetail = (data: any): data is FinancialDetail => { +// return ( +// typeof data.stac_yymm === 'string' && +// typeof data.sale_account === 'string' && +// typeof data.sale_cost === 'string' && +// typeof data.sale_totl_prfi === 'string' && +// typeof data.depr_cost === 'string' && +// typeof data.sell_mang === 'string' && +// typeof data.bsop_prti === 'string' && +// typeof data.bsop_non_ernn === 'string' && +// typeof data.bsop_non_expn === 'string' && +// typeof data.op_prfi === 'string' && +// typeof data.spec_prfi === 'string' && +// typeof data.spec_loss === 'string' && +// typeof data.thtr_ntin === 'string' +// ); +//}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts new file mode 100644 index 00000000..d8041e7b --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-lines-per-function */ + +export type StockData = { + MKSC_SHRN_ISCD: string; // 유가증권 단축 종목코드 + STCK_CNTG_HOUR: string; // 주식 체결 시간 + STCK_PRPR: string; // 주식 현재가 + PRDY_VRSS_SIGN: string; // 전일 대비 부호 + PRDY_VRSS: string; // 전일 대비 + PRDY_CTRT: string; // 전일 대비율 + WGHN_AVRG_STCK_PRC: string; // 가중 평균 주식 가격 + STCK_OPRC: string; // 주식 시가 + STCK_HGPR: string; // 주식 최고가 + STCK_LWPR: string; // 주식 최저가 + ASKP1: string; // 매도호가1 + BIDP1: string; // 매수호가1 + CNTG_VOL: string; // 체결 거래량 + ACML_VOL: string; // 누적 거래량 + ACML_TR_PBMN: string; // 누적 거래 대금 + SELN_CNTG_CSNU: string; // 매도 체결 건수 + SHNU_CNTG_CSNU: string; // 매수 체결 건수 + NTBY_CNTG_CSNU: string; // 순매수 체결 건수 + CTTR: string; // 체결강도 + SELN_CNTG_SMTN: string; // 총 매도 수량 + SHNU_CNTG_SMTN: string; // 총 매수 수량 + CCLD_DVSN: string; // 체결구분 + SHNU_RATE: string; // 매수비율 + PRDY_VOL_VRSS_ACML_VOL_RATE: string; // 전일 거래량 대비 등락율 + OPRC_HOUR: string; // 시가 시간 + OPRC_VRSS_PRPR_SIGN: string; // 시가대비구분 + OPRC_VRSS_PRPR: string; // 시가대비 + HGPR_HOUR: string; // 최고가 시간 + HGPR_VRSS_PRPR_SIGN: string; // 고가대비구분 + HGPR_VRSS_PRPR: string; // 고가대비 + LWPR_HOUR: string; // 최저가 시간 + LWPR_VRSS_PRPR_SIGN: string; // 저가대비구분 + LWPR_VRSS_PRPR: string; // 저가대비 + BSOP_DATE: string; // 영업 일자 + NEW_MKOP_CLS_CODE: string; // 신 장운영 구분 코드 + TRHT_YN: string; // 거래정지 여부 + ASKP_RSQN1: string; // 매도호가 잔량1 + BIDP_RSQN1: string; // 매수호가 잔량1 + TOTAL_ASKP_RSQN: string; // 총 매도호가 잔량 + TOTAL_BIDP_RSQN: string; // 총 매수호가 잔량 + VOL_TNRT: string; // 거래량 회전율 + PRDY_SMNS_HOUR_ACML_VOL: string; // 전일 동시간 누적 거래량 + PRDY_SMNS_HOUR_ACML_VOL_RATE: string; // 전일 동시간 누적 거래량 비율 + HOUR_CLS_CODE: string; // 시간 구분 코드 + MRKT_TRTM_CLS_CODE: string; // 임의종료구분코드 + VI_STND_PRC: string; // 정적VI발동기준가 +}; + +export function parseStockData(message: string[]): StockData { + return { + MKSC_SHRN_ISCD: message[0], + STCK_CNTG_HOUR: message[1], + STCK_PRPR: message[2], + PRDY_VRSS_SIGN: message[3], + PRDY_VRSS: message[4], + PRDY_CTRT: message[5], + WGHN_AVRG_STCK_PRC: message[6], + STCK_OPRC: message[7], + STCK_HGPR: message[8], + STCK_LWPR: message[9], + ASKP1: message[10], + BIDP1: message[11], + CNTG_VOL: message[12], + ACML_VOL: message[13], + ACML_TR_PBMN: message[14], + SELN_CNTG_CSNU: message[15], + SHNU_CNTG_CSNU: message[16], + NTBY_CNTG_CSNU: message[17], + CTTR: message[18], + SELN_CNTG_SMTN: message[19], + SHNU_CNTG_SMTN: message[20], + CCLD_DVSN: message[21], + SHNU_RATE: message[22], + PRDY_VOL_VRSS_ACML_VOL_RATE: message[23], + OPRC_HOUR: message[24], + OPRC_VRSS_PRPR_SIGN: message[25], + OPRC_VRSS_PRPR: message[26], + HGPR_HOUR: message[27], + HGPR_VRSS_PRPR_SIGN: message[28], + HGPR_VRSS_PRPR: message[29], + LWPR_HOUR: message[30], + LWPR_VRSS_PRPR_SIGN: message[31], + LWPR_VRSS_PRPR: message[32], + BSOP_DATE: message[33], + NEW_MKOP_CLS_CODE: message[34], + TRHT_YN: message[35], + ASKP_RSQN1: message[36], + BIDP_RSQN1: message[37], + TOTAL_ASKP_RSQN: message[38], + TOTAL_BIDP_RSQN: message[39], + VOL_TNRT: message[40], + PRDY_SMNS_HOUR_ACML_VOL: message[41], + PRDY_SMNS_HOUR_ACML_VOL_RATE: message[42], + HOUR_CLS_CODE: message[43], + MRKT_TRTM_CLS_CODE: message[44], + VI_STND_PRC: message[45], + }; +} + +export type OpenApiMessage = { + header: { + approval_key: string; + custtype: string; + tr_type: string; + 'content-type': string; + }; + body: { + input: { + tr_id: string; + tr_key: string; + }; + }; +}; + +export type MessageResponse = { + header: { + tr_id: string; + tr_key: string; + encrypt: string; + }; + body: { + rt_cd: string; + msg_cd: string; + msg1: string; + output?: { + iv: string; + key: string; + }; + }; +}; + +export function isMessageResponse(data: any): data is MessageResponse { + return ( + typeof data === 'object' && + data !== null && + typeof data.header === 'object' && + data.header !== null && + typeof data.header.tr_id === 'object' && + typeof data.header.tr_key === 'object' && + typeof data.header.encrypt === 'object' && + typeof data.body === 'object' && + data.body !== null && + typeof data.body.rt_cd === 'object' && + typeof data.body.msg_cd === 'object' && + typeof data.body.msg1 === 'object' && + typeof data.body.output === 'object' + ); +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts new file mode 100644 index 00000000..5deb2d9e --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type MinuteData = { + stck_bsop_date: string; + stck_cntg_hour: string; + stck_prpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + cntg_vol: string; + acml_tr_pbmn: string; +}; + +export type UpdateStockQuery = { + fid_etc_cls_code: string; + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_hour_1: string; + fid_pw_data_incu_yn: 'Y' | 'N'; +}; + +export const isMinuteData = (data: any) => { + return ( + typeof data.stck_bsop_date === 'string' && + typeof data.stck_cntg_hour === 'string' && + typeof data.stck_prpr === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.cntg_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' + ); +}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts new file mode 100644 index 00000000..e4066f7c --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type Period = 'D' | 'W' | 'M' | 'Y'; +export type ChartData = { + stck_bsop_date: string; + stck_clpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + acml_vol: string; + acml_tr_pbmn: string; + flng_cls_code: string; + prtt_rate: string; + mod_yn: string; + prdy_vrss_sign: string; + prdy_vrss: string; + revl_issu_reas: string; +}; + +export type ItemChartPriceQuery = { + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_date_1: string; + fid_input_date_2: string; + fid_period_div_code: Period; + fid_org_adj_prc: number; +}; + +export const isChartData = (data?: any) => { + return ( + data && + typeof data.stck_bsop_date === 'string' && + typeof data.stck_clpr === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.acml_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.flng_cls_code === 'string' && + typeof data.prtt_rate === 'string' && + typeof data.mod_yn === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_vrss === 'string' && + typeof data.revl_issu_reas === 'string' + ); +}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts new file mode 100644 index 00000000..6df0ca19 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts @@ -0,0 +1,13 @@ +export type TR_ID = + | 'FHKST03010100' + | 'FHKST03010200' + | 'FHKST66430300' + | 'HHKDB669107C0' + | 'CTPF1002R'; + +export const TR_IDS: Record = { + ITEM_CHART_PRICE: 'FHKST03010100', + MINUTE_DATA: 'FHKST03010200', + FINANCIAL_DATA: 'FHKST66430300', + PRODUCTION_DETAIL: 'CTPF1002R', +}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts b/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts new file mode 100644 index 00000000..1e0c3913 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts @@ -0,0 +1,13 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class OpenapiException extends HttpException { + private error: unknown; + constructor(message: string, status: HttpStatus, error?: unknown) { + super(message, status); + this.error = error; + } + + public getError() { + return this.error; + } +} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts new file mode 100644 index 00000000..fa8f75b4 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +import * as crypto from 'crypto'; +import { HttpStatus } from '@nestjs/common'; +import axios from 'axios'; +import { openApiConfig } from '../config/openapi.config'; +import { TR_ID } from '../type/openapiUtil.type'; +import { OpenapiException } from './openapiCustom.error'; + +const throwOpenapiException = (error: any) => { + if (error.message && error.response && error.response.status) { + throw new OpenapiException( + `Request failed: ${error.message}`, + error.response.status, + error, + ); + } else { + throw new OpenapiException( + `Unknown error: ${error.message || 'No message'}`, + HttpStatus.INTERNAL_SERVER_ERROR, + error, + ); + } +}; + +const postOpenApi = async ( + url: string, + config: typeof openApiConfig, + body: object, +) => { + try { + const response = await axios.post(config.STOCK_URL + url, body); + return response.data; + } catch (error) { + throwOpenapiException(error); + } +}; + +const getOpenApi = async ( + url: string, + config: typeof openApiConfig, + query: object, + tr_id: TR_ID, +) => { + try { + const response = await axios.get(config.STOCK_URL + url, { + params: query, + headers: { + Authorization: `Bearer ${config.STOCK_API_TOKEN}`, + appkey: config.STOCK_API_KEY, + appsecret: config.STOCK_API_PASSWORD, + tr_id, + custtype: 'P', + }, + }); + return response.data; + } catch (error) { + throwOpenapiException(error); + } +}; + +const getTodayDate = (): string => { + const today = new Date(); + return today.toISOString().split('T')[0].replace(/-/g, ''); +}; + +const getPreviousDate = (date: string, months: number): string => { + const currentDate = new Date( + date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8), + ); + currentDate.setMonth(currentDate.getMonth() - months); + return currentDate.toISOString().split('T')[0].replace(/-/g, ''); +}; + +const getCurrentTime = () => { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${hours}${minutes}${seconds}`; +}; + +const decryptAES256 = ( + encryptedText: string, + key: string, + iv: string, +): string => { + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + Buffer.from(key, 'hex'), + Buffer.from(iv, 'hex'), + ); + let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +}; + +const bufferToObject = (buffer: Buffer): any => { + try { + const jsonString = buffer.toString('utf-8'); + return JSON.parse(jsonString); + } catch (error) { + console.error('Failed to convert buffer to object:', error); + throw error; + } +}; + +export { + postOpenApi, + getOpenApi, + getTodayDate, + getPreviousDate, + getCurrentTime, + decryptAES256, + bufferToObject, +}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts new file mode 100644 index 00000000..554c3eff --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; +import { WebSocket } from 'ws'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; + +@Injectable() +export class WebsocketClient { + private client: WebSocket; + private readonly reconnectInterval = 60000; + private readonly url = + process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly openapiLiveData: OpenapiLiveData, + ) { + if (process.env.NODE_ENV === 'production') { + this.connect(); + } + } + + // TODO : subscribe 구조로 리팩토링 + private subscribe() {} + + private message(data: any) { + this.logger.info(`Received message: ${data}`); + if (data.header && data.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(data)}`); + this.sendPong(); + return; + } + if (data.header && data.header.tr_id === 'H0STCNT0') { + return; + } + this.openapiLiveData.output(data); + } + + @Cron('0 2 * * 1-5') + private connect() { + this.client = new WebSocket(this.url); + + this.client.on('open', () => { + this.logger.info('WebSocket connection established'); + this.openapiLiveData.getMessage().then((val) => { + val.forEach((message) => this.sendMessage(message)); + }); + }); + + this.client.on('message', (data: any) => { + try { + this.message(data); + } catch (error) { + this.logger.info(error); + } + }); + + this.client.on('close', () => { + this.logger.warn( + `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, + ); + setTimeout(() => this.connect(), this.reconnectInterval); + }); + + this.client.on('error', (error: any) => { + this.logger.error(`WebSocket error: ${error.message}`); + }); + } + + private sendPong() { + const pongMessage = { + header: { tr_id: 'PINGPONG', datetime: new Date().toISOString() }, + }; + this.client.send(JSON.stringify(pongMessage)); + this.logger.info(`Sent PONG: ${JSON.stringify(pongMessage)}`); + } + + private sendMessage(message: string) { + if (this.client.readyState === WebSocket.OPEN) { + this.client.send(message); + this.logger.info(`Sent message: ${message}`); + } else { + this.logger.warn('WebSocket is not open. Message not sent.'); + } + } +} From 74875ebe2c24a66342ec545fca56048a332585bd Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 11:34:05 +0900 Subject: [PATCH 078/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20openapiToken=20-?= =?UTF-8?q?=20custom=20filter=20=EC=82=AD=EC=A0=9C,=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20private=20=EC=82=AD=EC=A0=9C,=20try-catch?= =?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 --- .../korea-stock-info/openapi/api/openapiDetailData.api.ts | 5 ++--- .../korea-stock-info/openapi/api/openapiToken.api.ts | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts index 1d1fa103..9be8e3ea 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts @@ -195,7 +195,7 @@ export class OpenapiDetailData { return output1[0]; } } catch (error) { - this.logger.error(error); + this.logger.warn(error); } } @@ -213,10 +213,9 @@ export class OpenapiDetailData { if (response.output) { const output2 = response.output; return output2; - //return bufferToObject(output2); } } catch (error) { - this.logger.error(error); + this.logger.warn(error); } } diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts index 14ffd2e8..6e6c88c5 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts @@ -1,8 +1,7 @@ -import { Inject, UseFilters } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; import { logger } from '@/configs/logger.config'; @@ -36,7 +35,6 @@ class OpenapiTokenApi { return this.config; } - @UseFilters(OpenapiExceptionFilter) private async initAuthenValue() { const delay = 60000; const delayMinute = delay / 1000 / 60; @@ -62,7 +60,7 @@ class OpenapiTokenApi { } @Cron('50 0 * * 1-5') - private async initAccessToken() { + async initAccessToken() { const updatedConfig = await Promise.all( this.config.map(async (val) => { val.STOCK_API_TOKEN = await this.getToken(val)!; @@ -73,7 +71,7 @@ class OpenapiTokenApi { } @Cron('50 0 * * 1-5') - private async initWebSocketKey() { + async initWebSocketKey() { const updatedConfig = await Promise.all( this.config.map(async (val) => { val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; From ff5e7bb6885830f2e55ed476100e7c79a705c6f3 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 16:15:50 +0900 Subject: [PATCH 079/223] =?UTF-8?q?=E2=9C=A8=20feat:=20priority=20queue=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/util/priorityQueue.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts new file mode 100644 index 00000000..a49e5822 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts @@ -0,0 +1,99 @@ +export class PriorityQueue { + private heap: { value: T; priority: number }[]; + + constructor() { + this.heap = []; + } + + private getParentIndex(index: number): number { + return Math.floor((index - 1) / 2); + } + + private getLeftChildIndex(index: number): number { + return index * 2 + 1; + } + + private getRightChildIndex(index: number): number { + return index * 2 + 2; + } + + private swap(i: number, j: number) { + [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; + } + + private heapifyUp() { + let index = this.heap.length - 1; + while ( + index > 0 && + this.heap[index].priority < this.heap[this.getParentIndex(index)].priority + ) { + this.swap(index, this.getParentIndex(index)); + index = this.getParentIndex(index); + } + } + + private heapifyDown() { + let index = 0; + while (this.getLeftChildIndex(index) < this.heap.length) { + let smallerChildIndex = this.getLeftChildIndex(index); + const rightChildIndex = this.getRightChildIndex(index); + + if ( + rightChildIndex < this.heap.length && + this.heap[rightChildIndex].priority < + this.heap[smallerChildIndex].priority + ) { + smallerChildIndex = rightChildIndex; + } + + if (this.heap[index].priority <= this.heap[smallerChildIndex].priority) { + break; + } + + this.swap(index, smallerChildIndex); + index = smallerChildIndex; + } + } + + enqueue(value: T, priority: number) { + this.heap.push({ value, priority }); + this.heapifyUp(); + } + + dequeue(): T | undefined { + if (this.isEmpty()) { + return undefined; + } + + const root = this.heap[0]; + const last = this.heap.pop(); + + if (this.heap.length > 0 && last) { + this.heap[0] = last; + this.heapifyDown(); + } + + return root.value; + } + + peek(): T | undefined { + return this.heap.length > 0 ? this.heap[0].value : undefined; + } + + isEmpty(): boolean { + return this.heap.length === 0; + } +} + +const pq = new PriorityQueue(); + +pq.enqueue('Task A', 2); +pq.enqueue('Task B', 1); +pq.enqueue('Task C', 3); + +console.log(pq.dequeue()); // Task B +console.log(pq.peek()); // Task A +console.log(pq.dequeue()); // Task A +console.log(pq.isEmpty()); // false +console.log(pq.dequeue()); // Task C +console.log(pq.isEmpty()); // true From ab4e8219e7cc919fb487f03a4d6961ee9dba5e5b Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 16:16:40 +0900 Subject: [PATCH 080/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20type=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20undefined=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/type/openapiLiveData.type.ts | 100 +++++++++--------- 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts index d8041e7b..e1687cee 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts @@ -50,57 +50,6 @@ export type StockData = { VI_STND_PRC: string; // 정적VI발동기준가 }; -export function parseStockData(message: string[]): StockData { - return { - MKSC_SHRN_ISCD: message[0], - STCK_CNTG_HOUR: message[1], - STCK_PRPR: message[2], - PRDY_VRSS_SIGN: message[3], - PRDY_VRSS: message[4], - PRDY_CTRT: message[5], - WGHN_AVRG_STCK_PRC: message[6], - STCK_OPRC: message[7], - STCK_HGPR: message[8], - STCK_LWPR: message[9], - ASKP1: message[10], - BIDP1: message[11], - CNTG_VOL: message[12], - ACML_VOL: message[13], - ACML_TR_PBMN: message[14], - SELN_CNTG_CSNU: message[15], - SHNU_CNTG_CSNU: message[16], - NTBY_CNTG_CSNU: message[17], - CTTR: message[18], - SELN_CNTG_SMTN: message[19], - SHNU_CNTG_SMTN: message[20], - CCLD_DVSN: message[21], - SHNU_RATE: message[22], - PRDY_VOL_VRSS_ACML_VOL_RATE: message[23], - OPRC_HOUR: message[24], - OPRC_VRSS_PRPR_SIGN: message[25], - OPRC_VRSS_PRPR: message[26], - HGPR_HOUR: message[27], - HGPR_VRSS_PRPR_SIGN: message[28], - HGPR_VRSS_PRPR: message[29], - LWPR_HOUR: message[30], - LWPR_VRSS_PRPR_SIGN: message[31], - LWPR_VRSS_PRPR: message[32], - BSOP_DATE: message[33], - NEW_MKOP_CLS_CODE: message[34], - TRHT_YN: message[35], - ASKP_RSQN1: message[36], - BIDP_RSQN1: message[37], - TOTAL_ASKP_RSQN: message[38], - TOTAL_BIDP_RSQN: message[39], - VOL_TNRT: message[40], - PRDY_SMNS_HOUR_ACML_VOL: message[41], - PRDY_SMNS_HOUR_ACML_VOL_RATE: message[42], - HOUR_CLS_CODE: message[43], - MRKT_TRTM_CLS_CODE: message[44], - VI_STND_PRC: message[45], - }; -} - export type OpenApiMessage = { header: { approval_key: string; @@ -150,3 +99,52 @@ export function isMessageResponse(data: any): data is MessageResponse { typeof data.body.output === 'object' ); } + +export const stockDataKeys = [ + 'MKSC_SHRN_ISCD', + 'STCK_CNTG_HOUR', + 'STCK_PRPR', + 'PRDY_VRSS_SIGN', + 'PRDY_VRSS', + 'PRDY_CTRT', + 'WGHN_AVRG_STCK_PRC', + 'STCK_OPRC', + 'STCK_HGPR', + 'STCK_LWPR', + 'ASKP1', + 'BIDP1', + 'CNTG_VOL', + 'ACML_VOL', + 'ACML_TR_PBMN', + 'SELN_CNTG_CSNU', + 'SHNU_CNTG_CSNU', + 'NTBY_CNTG_CSNU', + 'CTTR', + 'SELN_CNTG_SMTN', + 'SHNU_CNTG_SMTN', + 'CCLD_DVSN', + 'SHNU_RATE', + 'PRDY_VOL_VRSS_ACML_VOL_RATE', + 'OPRC_HOUR', + 'OPRC_VRSS_PRPR_SIGN', + 'OPRC_VRSS_PRPR', + 'HGPR_HOUR', + 'HGPR_VRSS_PRPR_SIGN', + 'HGPR_VRSS_PRPR', + 'LWPR_HOUR', + 'LWPR_VRSS_PRPR_SIGN', + 'LWPR_VRSS_PRPR', + 'BSOP_DATE', + 'NEW_MKOP_CLS_CODE', + 'TRHT_YN', + 'ASKP_RSQN1', + 'BIDP_RSQN1', + 'TOTAL_ASKP_RSQN', + 'TOTAL_BIDP_RSQN', + 'VOL_TNRT', + 'PRDY_SMNS_HOUR_ACML_VOL', + 'PRDY_SMNS_HOUR_ACML_VOL_RATE', + 'HOUR_CLS_CODE', + 'MRKT_TRTM_CLS_CODE', + 'VI_STND_PRC', +]; From b038411f98146f64929b1877e64888f5e7b3b6d3 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 16:17:31 +0900 Subject: [PATCH 081/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20websoc?= =?UTF-8?q?ket=20return=20=EA=B0=92=20parse=20=EB=B6=84=EB=A6=AC=ED=9B=84?= =?UTF-8?q?=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 --- .../openapi/parse/openapi.parser.spec.ts | 106 ++++++++++++++++++ .../openapi/parse/openapi.parser.ts | 38 +++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts create mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts b/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts new file mode 100644 index 00000000..37d3deb6 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts @@ -0,0 +1,106 @@ +/* eslint-disable max-lines-per-function */ +import { parseMessage } from './openapi.parser'; + +const answer = [ + { + STOCK_ID: '005930', + MKSC_SHRN_ISCD: 5930, + STCK_CNTG_HOUR: 93354, + STCK_PRPR: 71900, + PRDY_VRSS_SIGN: 5, + PRDY_VRSS: -100, + PRDY_CTRT: -0.14, + WGHN_AVRG_STCK_PRC: 72023.83, + STCK_OPRC: 72100, + STCK_HGPR: 72400, + STCK_LWPR: 71700, + ASKP1: 71900, + BIDP1: 71800, + CNTG_VOL: 1, + ACML_VOL: 3052507, + ACML_TR_PBMN: 219853241700, + SELN_CNTG_CSNU: 5105, + SHNU_CNTG_CSNU: 6937, + NTBY_CNTG_CSNU: 1832, + CTTR: 84.9, + SELN_CNTG_SMTN: 1366314, + SHNU_CNTG_SMTN: 1159996, + CCLD_DVSN: 1, + SHNU_RATE: 0.39, + PRDY_VOL_VRSS_ACML_VOL_RATE: 20.28, + OPRC_HOUR: 90020, + OPRC_VRSS_PRPR_SIGN: 5, + OPRC_VRSS_PRPR: -200, + HGPR_HOUR: 90820, + HGPR_VRSS_PRPR_SIGN: 5, + HGPR_VRSS_PRPR: -500, + LWPR_HOUR: 92619, + LWPR_VRSS_PRPR_SIGN: 2, + LWPR_VRSS_PRPR: 200, + BSOP_DATE: 20230612, + NEW_MKOP_CLS_CODE: 20, + TRHT_YN: 'N', + ASKP_RSQN1: 65945, + BIDP_RSQN1: 216924, + TOTAL_ASKP_RSQN: 1118750, + TOTAL_BIDP_RSQN: 2199206, + VOL_TNRT: 0.05, + PRDY_SMNS_HOUR_ACML_VOL: 2424142, + PRDY_SMNS_HOUR_ACML_VOL_RATE: 125.92, + HOUR_CLS_CODE: 0, + MRKT_TRTM_CLS_CODE: null, + VI_STND_PRC: 72100, + }, +]; + +describe('openapi parser test', () => { + test('parse json websocket data', () => { + const message = `{ + "header": { + "tr_id": "H0STCNT0", + "tr_key": "005930", + "encrypt": "N" + }, + "body": { + "rt_cd": "0", + "msg_cd": "OPSP0000", + "msg1": "SUBSCRIBE SUCCESS", + "output": { + "iv": "0123456789abcdef", + "key": "abcdefghijklmnopabcdefghijklmnop"} + } + }`; + + const result = parseMessage(message); + + expect(result).toEqual(JSON.parse(message)); + }); + + test('parse stockData', () => { + const message = + '0|H0STCNT0|001|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100'; + + const result = parseMessage(message); + + expect(result).toEqual(answer); + }); + + test('parse stockData', () => { + const message = + '0|H0STCNT0|002|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100^' + + '005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100'; + + const result = parseMessage(message); + + expect(result).toEqual([answer[0], answer[0]]); + }); +}); diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts b/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts new file mode 100644 index 00000000..fe3e7002 --- /dev/null +++ b/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts @@ -0,0 +1,38 @@ +import { stockDataKeys } from '../type/openapiLiveData.type'; + +export const parseMessage = (data: string) => { + try { + return JSON.parse(data); + //eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return parseStockData(data); + } +}; +const FIELD_LENGTH: number = stockDataKeys.length; + +const parseStockData = (input: string) => { + const dataBlocks = input.split('|'); // 데이터 구분 + const results = []; + const size = parseInt(dataBlocks[2]); // 데이터 건수 + const rawData = dataBlocks[3]; + const values = rawData.split('^'); // 필드 구분자 '^' + + for (let i = 0; i < size; i++) { + //TODO : type narrowing require + const parsedData: Record = {}; + parsedData['STOCK_ID'] = values[i * FIELD_LENGTH]; + stockDataKeys.forEach((field: string, index: number) => { + const value = values[index + FIELD_LENGTH * i]; + if (!value) return (parsedData[field] = null); + + // 숫자형 필드 처리 + if (isNaN(parseInt(value))) { + parsedData[field] = value; // 문자열 그대로 저장 + } else { + parsedData[field] = parseFloat(value); // 숫자로 변환 + } + }); + results.push(parsedData); + } + return results; +}; From 0a56bb5bbb92b4aadf891da919c80c118dbc6585 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 16:18:31 +0900 Subject: [PATCH 082/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20parse?= =?UTF-8?q?=20stock=20data=EB=A5=BC=20=EB=8B=A4=EB=A5=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiLiveData.api.ts | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts index 712d6d2d..10ce32fb 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts @@ -2,7 +2,11 @@ import { Inject } from '@nestjs/common'; import { EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { StockData, parseStockData } from '../type/openapiLiveData.type'; +import { + StockData, + parseStockData, + stockDataKeys, +} from '../type/openapiLiveData.type'; import { decryptAES256 } from '../util/openapiUtil.api'; import { openApiToken } from './openapiToken.api'; import { KospiStock } from '@/stock/domain/kospiStock.entity'; @@ -11,6 +15,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; export class OpenapiLiveData { public readonly TR_ID: string = 'H0STCNT0'; private readonly WEBSOCKET_MAX: number = 40; + private readonly FIELD_LENGTH: number = stockDataKeys.length; constructor( @Inject('winston') private readonly logger: Logger, private readonly manager: EntityManager, @@ -84,15 +89,49 @@ export class OpenapiLiveData { return stockLiveData; } - public async output(message: Buffer, iv?: string, key?: string) { - const parsed = message.toString().split('|'); - if (parsed.length > 0) { - if (parsed[0] == '1' && iv && key) - parsed[3] = decryptAES256(parsed[3], iv, key); - if (parsed[1] !== this.TR_ID) return; - const stockData = parsed[3].split('^'); - const length = stockData.length / parseInt(parsed[2]); - const size = parseInt(parsed[2]); + private parseStockData = (input: string) => { + const dataBlocks = input.split('|'); // 데이터 구분 + const results = []; + const size = parseInt(dataBlocks[2]); // 데이터 건수 + const rawData = dataBlocks[3]; + const values = rawData.split('^'); // 필드 구분자 '^' + + for (let i = 0; i < size; i++) { + //TODO : type narrowing require + const parsedData: Record = {}; + parsedData['STOCK_ID'] = values[i * this.FIELD_LENGTH]; + stockDataKeys.forEach((field: string, index: number) => { + const value = values[index + this.FIELD_LENGTH * i]; + if (!value) return (parsedData[field] = null); + + // 숫자형 필드 처리 + if (isNaN(parseInt(value))) { + parsedData[field] = value; // 문자열 그대로 저장 + } else { + parsedData[field] = parseFloat(value); // 숫자로 변환 + } + }); + results.push(parsedData); + } + return results; + }; + + public async output( + message: Record | string, + iv?: string, + key?: string, + ) { + const data = + typeof message === 'string' + ? message.split('|') + : JSON.stringify(message); + if (typeof data !== 'string') { + if (data[0] == '1' && iv && key) + data[3] = decryptAES256(data[3], iv, key); + if (data[1] !== this.TR_ID) return; + const stockData = data[3].split('^'); + const length = stockData.length / parseInt(data[2]); + const size = parseInt(data[2]); const i = 0; while (i < size) { const data = stockData.splice(i * length, (i + 1) * length); From 41d2c85b63ed3fb09cd5c9681ef481ddff3b4100 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 19:31:42 +0900 Subject: [PATCH 083/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20web=20?= =?UTF-8?q?socket=20client=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/websocketClient.service.ts | 125 +++++++++++++----- 1 file changed, 94 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts index 554c3eff..e0e35f1f 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts @@ -4,13 +4,18 @@ import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { WebSocket } from 'ws'; import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { parseMessage } from './parse/openapi.parser'; +import { openApiToken } from '@/scraper/openapi/api/openapiToken.api'; +import { openApiConfig } from '@/scraper/openapi/config/openapi.config'; +type TR_IDS = '0' | '1'; @Injectable() export class WebsocketClient { private client: WebSocket; private readonly reconnectInterval = 60000; private readonly url = process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + private readonly clientStock: Set = new Set(); constructor( @Inject('winston') private readonly logger: Logger, @@ -22,50 +27,86 @@ export class WebsocketClient { } // TODO : subscribe 구조로 리팩토링 - private subscribe() {} + subscribe(stockId: string) { + this.clientStock.add(stockId); + // TODO : 하나의 config만 사용중. + const message = this.convertObjectToMessage( + openApiToken.configs[0], + stockId, + '1', + ); + this.sendMessage(message); + } - private message(data: any) { - this.logger.info(`Received message: ${data}`); - if (data.header && data.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(data)}`); - this.sendPong(); - return; - } - if (data.header && data.header.tr_id === 'H0STCNT0') { - return; - } - this.openapiLiveData.output(data); + discribe(stockId: string) { + this.clientStock.delete(stockId); + const message = this.convertObjectToMessage( + openApiToken.configs[0], + stockId, + '0', + ); + this.sendMessage(message); } - @Cron('0 2 * * 1-5') - private connect() { - this.client = new WebSocket(this.url); + private initDisconnect() { + this.client.on('close', () => { + this.logger.warn( + `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, + ); + }); + + this.client.on('error', (error: any) => { + this.logger.error(`WebSocket error: ${error.message}`); + setTimeout(() => this.connect(), this.reconnectInterval); + }); + } + private initOpen() { this.client.on('open', () => { this.logger.info('WebSocket connection established'); - this.openapiLiveData.getMessage().then((val) => { - val.forEach((message) => this.sendMessage(message)); - }); + for (const stockId of this.clientStock.keys()) { + const message = this.convertObjectToMessage( + openApiToken.configs[0], + stockId, + '1', + ); + this.sendMessage(message); + } }); + } - this.client.on('message', (data: any) => { + private initMessage() { + this.client.on('message', async (data) => { try { - this.message(data); + let message; + if (typeof data === 'object') { + message = data; + } else { + message = parseMessage(data as string); + } + if (message.header && message.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(data)}`); + this.sendPong(); + return; + } + if (message.header && message.header.tr_id === 'H0STCNT0') { + return; + } + this.logger.info(`Recived data : ${data}`); + const liveData = this.openapiLiveData.convertLiveData(message); + this.openapiLiveData.saveLiveData(liveData); } catch (error) { - this.logger.info(error); + this.logger.warn(error); } }); + } - this.client.on('close', () => { - this.logger.warn( - `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, - ); - setTimeout(() => this.connect(), this.reconnectInterval); - }); - - this.client.on('error', (error: any) => { - this.logger.error(`WebSocket error: ${error.message}`); - }); + @Cron('0 2 * * 1-5') + connect() { + this.client = new WebSocket(this.url); + this.initOpen(); + this.initMessage(); + this.initDisconnect(); } private sendPong() { @@ -76,6 +117,28 @@ export class WebsocketClient { this.logger.info(`Sent PONG: ${JSON.stringify(pongMessage)}`); } + private convertObjectToMessage( + config: typeof openApiConfig, + stockId: string, + tr_type: TR_IDS, + ): string { + const message = { + header: { + approval_key: config.STOCK_WEBSOCKET_KEY!, + custtype: 'P', + tr_type, + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: 'H0STCNT0', + tr_key: stockId, + }, + }, + }; + return JSON.stringify(message); + } + private sendMessage(message: string) { if (this.client.readyState === WebSocket.OPEN) { this.client.send(message); From 46aaf9bfba2b55cbba2ef7eef78ec8c8c001adcc Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 19:38:59 +0900 Subject: [PATCH 084/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20websoc?= =?UTF-8?q?ket=20Client=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20initO?= =?UTF-8?q?pen,=20close=EB=93=B1=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/websocketClient.service.ts | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts index e0e35f1f..3073683e 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; -import { WebSocket } from 'ws'; +import { RawData, WebSocket } from 'ws'; import { OpenapiLiveData } from './api/openapiLiveData.api'; import { parseMessage } from './parse/openapi.parser'; import { openApiToken } from '@/scraper/openapi/api/openapiToken.api'; @@ -78,18 +78,15 @@ export class WebsocketClient { private initMessage() { this.client.on('message', async (data) => { try { - let message; - if (typeof data === 'object') { - message = data; - } else { - message = parseMessage(data as string); - } - if (message.header && message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(data)}`); - this.sendPong(); - return; - } - if (message.header && message.header.tr_id === 'H0STCNT0') { + const message = this.parseMessage(data); + if (message.header) { + if (message.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(data)}`); + this.client.pong({ + tr_id: 'PINGPONG', + datetime: new Date().toISOString(), + }); + } return; } this.logger.info(`Recived data : ${data}`); @@ -101,6 +98,14 @@ export class WebsocketClient { }); } + private parseMessage(data: RawData) { + if (typeof data === 'object') { + return data; + } else { + return parseMessage(data as string); + } + } + @Cron('0 2 * * 1-5') connect() { this.client = new WebSocket(this.url); @@ -109,14 +114,6 @@ export class WebsocketClient { this.initDisconnect(); } - private sendPong() { - const pongMessage = { - header: { tr_id: 'PINGPONG', datetime: new Date().toISOString() }, - }; - this.client.send(JSON.stringify(pongMessage)); - this.logger.info(`Sent PONG: ${JSON.stringify(pongMessage)}`); - } - private convertObjectToMessage( config: typeof openApiConfig, stockId: string, From 8562e2095eccdf9b4bbea2c643b37101a3dc2431 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 21:08:24 +0900 Subject: [PATCH 085/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20openapilive=20dat?= =?UTF-8?q?a=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiLiveData.api.ts | 155 ++-------- .../Decorator/openapiException.filter.ts | 33 -- .../openapi/api/openapiDetailData.api.ts | 282 ------------------ .../openapi/api/openapiLiveData.api.ts | 105 ------- .../openapi/api/openapiMinuteData.api.ts | 155 ---------- .../openapi/api/openapiPeriodData.api.ts | 215 ------------- .../scraper/openapi/api/openapiToken.api.ts | 114 ------- .../scraper/openapi/config/openapi.config.ts | 17 -- .../scraper/openapi/openapi-scraper.module.ts | 43 --- .../openapi/openapi-scraper.service.ts | 15 - .../openapi/parse/openapi.parser.spec.ts | 106 +++++++ .../scraper/openapi/parse/openapi.parser.ts | 38 +++ .../openapi/type/openapiDetailData.type.ts | 214 ------------- .../openapi/type/openapiLiveData.type.ts | 152 ---------- .../openapi/type/openapiMinuteData.type.ts | 33 -- .../scraper/openapi/type/openapiPeriodData.ts | 44 --- .../scraper/openapi/type/openapiUtil.type.ts | 13 - .../openapi/util/openapiCustom.error.ts | 13 - .../scraper/openapi/util/openapiUtil.api.ts | 115 ------- .../openapi/websocketClient.service.ts | 87 ------ 20 files changed, 168 insertions(+), 1781 deletions(-) delete mode 100644 packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts delete mode 100644 packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts delete mode 100644 packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts delete mode 100644 packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts delete mode 100644 packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts delete mode 100644 packages/backend/src/scraper/openapi/api/openapiToken.api.ts delete mode 100644 packages/backend/src/scraper/openapi/config/openapi.config.ts delete mode 100644 packages/backend/src/scraper/openapi/openapi-scraper.module.ts delete mode 100644 packages/backend/src/scraper/openapi/openapi-scraper.service.ts create mode 100644 packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts create mode 100644 packages/backend/src/scraper/openapi/parse/openapi.parser.ts delete mode 100644 packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts delete mode 100644 packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts delete mode 100644 packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts delete mode 100644 packages/backend/src/scraper/openapi/type/openapiPeriodData.ts delete mode 100644 packages/backend/src/scraper/openapi/type/openapiUtil.type.ts delete mode 100644 packages/backend/src/scraper/openapi/util/openapiCustom.error.ts delete mode 100644 packages/backend/src/scraper/openapi/util/openapiUtil.api.ts delete mode 100644 packages/backend/src/scraper/openapi/websocketClient.service.ts diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts index 10ce32fb..410ab4cd 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts @@ -1,143 +1,36 @@ -import { Inject } from '@nestjs/common'; -import { EntityManager } from 'typeorm'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; -import { - StockData, - parseStockData, - stockDataKeys, -} from '../type/openapiLiveData.type'; -import { decryptAES256 } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { KospiStock } from '@/stock/domain/kospiStock.entity'; +import { DataSource } from 'typeorm'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; export class OpenapiLiveData { public readonly TR_ID: string = 'H0STCNT0'; - private readonly WEBSOCKET_MAX: number = 40; - private readonly FIELD_LENGTH: number = stockDataKeys.length; constructor( - @Inject('winston') private readonly logger: Logger, - private readonly manager: EntityManager, + private readonly datasource: DataSource, ) {} - async getMessage(): Promise { - const kospi = await this.getKospiStockId(); - const config = openApiToken.configs; - const configLength = config.length; - const ret: string[] = []; - - for (let i = 0; i < configLength; i++) { - const stocks = kospi.splice( - i * this.WEBSOCKET_MAX, - (i + 1) * this.WEBSOCKET_MAX, - ); - for (const stock of stocks) { - ret.push(this.convertObjectToMessage(config[i], stock.id!)); - } - } - - return ret; - } - - private convertObjectToMessage( - config: typeof openApiConfig, - stockId: string, - ): string { - const message = { - header: { - approval_key: config.STOCK_WEBSOCKET_KEY!, - custtype: 'P', - tr_type: '1', - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: this.TR_ID, - tr_key: stockId, - }, - }, - }; - return JSON.stringify(message); + async saveLiveData(data: StockLiveData[]) { + await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .execute(); } - private async getKospiStockId() { - const kospi = await this.manager.find(KospiStock, { - where: { - isKospi: true, - }, + convertLiveData(messages: Record[]) : StockLiveData[] { + const stockData: StockLiveData[] = []; + messages.map((message) => { + const stockLiveData = new StockLiveData(); + stockLiveData.currentPrice = parseFloat(message.STCK_PRPR); + stockLiveData.changeRate = parseFloat(message.PRDY_CTRT); + stockLiveData.volume = parseInt(message.CNTG_VOL); + stockLiveData.high = parseFloat(message.STCK_HGPR); + stockLiveData.low = parseFloat(message.STCK_LWPR); + stockLiveData.open = parseFloat(message.STCK_OPRC); + stockLiveData.previousClose = parseFloat(message.WGHN_AVRG_STCK_PRC); + stockLiveData.updatedAt = new Date(); + stockData.push(stockLiveData); }); - return kospi; - } - - private async saveLiveData(data: StockLiveData) { - await this.manager.save(StockLiveData, data); - } - - private convertLiveData(message: string[]): StockLiveData { - const stockData: StockData = parseStockData(message); - const stockLiveData = new StockLiveData(); - stockLiveData.currentPrice = parseFloat(stockData.STCK_PRPR); - stockLiveData.changeRate = parseFloat(stockData.PRDY_CTRT); - stockLiveData.volume = parseInt(stockData.CNTG_VOL); - stockLiveData.high = parseFloat(stockData.STCK_HGPR); - stockLiveData.low = parseFloat(stockData.STCK_LWPR); - stockLiveData.open = parseFloat(stockData.STCK_OPRC); - stockLiveData.previousClose = parseFloat(stockData.WGHN_AVRG_STCK_PRC); - stockLiveData.updatedAt = new Date(); - - return stockLiveData; - } - - private parseStockData = (input: string) => { - const dataBlocks = input.split('|'); // 데이터 구분 - const results = []; - const size = parseInt(dataBlocks[2]); // 데이터 건수 - const rawData = dataBlocks[3]; - const values = rawData.split('^'); // 필드 구분자 '^' - - for (let i = 0; i < size; i++) { - //TODO : type narrowing require - const parsedData: Record = {}; - parsedData['STOCK_ID'] = values[i * this.FIELD_LENGTH]; - stockDataKeys.forEach((field: string, index: number) => { - const value = values[index + this.FIELD_LENGTH * i]; - if (!value) return (parsedData[field] = null); - - // 숫자형 필드 처리 - if (isNaN(parseInt(value))) { - parsedData[field] = value; // 문자열 그대로 저장 - } else { - parsedData[field] = parseFloat(value); // 숫자로 변환 - } - }); - results.push(parsedData); - } - return results; - }; - - public async output( - message: Record | string, - iv?: string, - key?: string, - ) { - const data = - typeof message === 'string' - ? message.split('|') - : JSON.stringify(message); - if (typeof data !== 'string') { - if (data[0] == '1' && iv && key) - data[3] = decryptAES256(data[3], iv, key); - if (data[1] !== this.TR_ID) return; - const stockData = data[3].split('^'); - const length = stockData.length / parseInt(data[2]); - const size = parseInt(data[2]); - const i = 0; - while (i < size) { - const data = stockData.splice(i * length, (i + 1) * length); - const liveData = this.convertLiveData(data); - this.saveLiveData(liveData); - } - } + return stockData; } } diff --git a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts b/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts deleted file mode 100644 index a6c45ae9..00000000 --- a/packages/backend/src/scraper/openapi/Decorator/openapiException.filter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - ExceptionFilter, - Catch, - HttpException, - HttpStatus, - Inject, -} from '@nestjs/common'; -import { Logger } from 'winston'; -import { OpenapiException } from '../util/openapiCustom.error'; - -@Catch() -export class OpenapiExceptionFilter implements ExceptionFilter { - constructor(@Inject('winston') private readonly logger: Logger) {} - - catch(exception: unknown) { - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - const message = - exception instanceof HttpException - ? exception.getResponse() - : 'Internal server error'; - - const error = - exception instanceof OpenapiException ? exception.getError() : ''; - - this.logger.error( - `HTTP Status: ${status} Error Message: ${JSON.stringify(message)} Error : ${error}`, - ); - } -} diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts deleted file mode 100644 index 57b007a3..00000000 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Inject, Injectable, UseFilters } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Between, DataSource } from 'typeorm'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; -import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; -import { - DetailDataQuery, - FinancialRatio, - isFinancialRatioData, - isProductDetail, - ProductDetail, - StockDetailQuery, -} from '../type/openapiDetailData.type'; -import { TR_IDS } from '../type/openapiUtil.type'; -import { getOpenApi } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { KospiStock } from '@/stock/domain/kospiStock.entity'; -import { Stock } from '@/stock/domain/stock.entity'; -import { StockDaily } from '@/stock/domain/stockData.entity'; -import { StockDetail } from '@/stock/domain/stockDetail.entity'; - -@Injectable() -export class OpenapiDetailData { - private readonly financialUrl: string = - '/uapi/domestic-stock/v1/finance/financial-ratio'; - private readonly productUrl: string = - '/uapi/domestic-stock/v1/quotations/search-stock-info'; - private readonly intervals = 1000; - constructor( - private readonly datasource: DataSource, - @Inject('winston') private readonly logger: Logger, - ) { - //setTimeout(() => this.getDetailData(), 5000); - } - - @Cron('0 8 * * 1-5') - @UseFilters(OpenapiExceptionFilter) - public async getDetailData() { - if (process.env.NODE_ENV !== 'production') return; - const entityManager = this.datasource.manager; - const stocks = await entityManager.find(Stock); - const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - this.logger.info(openApiToken.configs[i]); - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getDetailDataChunk(chunk, openApiToken.configs[i]); - } - } - - private async saveDetailData(stockDetail: StockDetail) { - const manager = this.datasource.manager; - const entity = StockDetail; - const existingStockDetail = await manager.findOne(entity, { - where: { - stock: { id: stockDetail.stock.id }, - }, - }); - if (existingStockDetail) { - manager.update( - entity, - { stock: { id: stockDetail.stock.id } }, - stockDetail, - ); - } else { - manager.save(entity, stockDetail); - } - } - - private async saveKospiData(stockDetail: KospiStock) { - const manager = this.datasource.manager; - const entity = KospiStock; - const existingStockDetail = await manager.findOne(entity, { - where: { - stock: { id: stockDetail.stock.id }, - }, - }); - - if (existingStockDetail) { - manager.update( - entity, - { stock: { id: stockDetail.stock.id } }, - stockDetail, - ); - } else { - manager.save(entity, stockDetail); - } - } - - private async calPer(eps: number): Promise { - if (eps <= 0) return NaN; - const manager = this.datasource.manager; - const latestResult = await manager.find(StockDaily, { - skip: 0, - take: 1, - order: { createdAt: 'desc' }, - }); - // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 - if (latestResult && latestResult[0] && latestResult[0].close) { - const currentPrice = latestResult[0].close; - const per = currentPrice / eps; - - if (isNaN(per)) return 0; - else return per; - } else { - return 0; - } - } - - private async calMarketCap(lstg: number) { - const manager = this.datasource.manager; - const latestResult = await manager.find(StockDaily, { - skip: 0, - take: 1, - order: { createdAt: 'desc' }, - }); - - // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 - if (latestResult && latestResult[0] && latestResult[0].close) { - const currentPrice = latestResult[0].close; - const marketCap = lstg * currentPrice; - - if (isNaN(marketCap)) return 0; - else return marketCap; - } else { - return 0; - } - } - - private async get52WeeksLowHigh() { - const manager = this.datasource.manager; - const nowDate = new Date(); - const weeksAgoDate = this.getDate52WeeksAgo(); - // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 - const output = await manager.find(StockDaily, { - select: ['low', 'high'], - where: { - startTime: Between(weeksAgoDate, nowDate), - }, - }); - const result = output.reduce((prev, cur) => { - if (prev.low > cur.low) prev.low = cur.low; - if (prev.high < cur.high) prev.high = cur.high; - return cur; - }, new StockDaily()); - let low = 0; - let high = 0; - if (result.low && !isNaN(result.low)) low = result.low; - if (result.high && !isNaN(result.high)) high = result.high; - return { low, high }; - } - - private async makeStockDetailObject( - output1: FinancialRatio, - output2: ProductDetail, - stockId: string, - ): Promise { - const result = new StockDetail(); - result.stock = { id: stockId } as Stock; - result.marketCap = - (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; - result.eps = parseInt(output1.eps); - const { low, high } = await this.get52WeeksLowHigh(); - result.low52w = low; - result.high52w = high; - const eps = parseInt(output1.eps); - if (isNaN(eps)) result.eps = 0; - else result.eps = eps; - const per = await this.calPer(eps); - if (isNaN(per)) result.per = 0; - else result.per = per; - result.updatedAt = new Date(); - return result; - } - - private async makeKospiStockObject(output: ProductDetail, stockId: string) { - const ret = new KospiStock(); - ret.isKospi = output.kospi200_item_yn === 'Y' ? true : false; - ret.stock = { id: stockId } as Stock; - return ret; - } - - private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { - const dataQuery = this.getDetailDataQuery(stock.id!); - // 여기서 가져올 건 eps -> eps와 per 계산하자. - try { - const response = await getOpenApi( - this.financialUrl, - conf, - dataQuery, - TR_IDS.FINANCIAL_DATA, - ); - if (response.output) { - const output1 = response.output; - return output1[0]; - } - } catch (error) { - this.logger.error(error); - } - } - - private async getProductData(stock: Stock, conf: typeof openApiConfig) { - const defaultQuery = this.getFinancialDataQuery(stock.id!); - - // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 - try { - const response = await getOpenApi( - this.productUrl, - conf, - defaultQuery, - TR_IDS.PRODUCTION_DETAIL, - ); - if (response.output) { - const output2 = response.output; - return output2; - //return bufferToObject(output2); - } - } catch (error) { - this.logger.error(error); - } - } - - private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { - const output1 = await this.getFinancialRatio(stock, conf); - const output2 = await this.getProductData(stock, conf); - - this.logger.info(JSON.stringify(output1)); - this.logger.info(JSON.stringify(output2)); - if (isFinancialRatioData(output1) && isProductDetail(output2)) { - const stockDetail = await this.makeStockDetailObject( - output1, - output2, - stock.id!, - ); - this.saveDetailData(stockDetail); - const kospiStock = await this.makeKospiStockObject(output2, stock.id!); - this.saveKospiData(kospiStock); - - this.logger.info(`${stock.id!} is saved`); - } - } - - private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { - let delay = 0; - for await (const stock of chunk) { - setTimeout(() => this.getDetailDataDelay(stock, conf), delay); - delay += this.intervals; - } - } - - private getFinancialDataQuery( - stockId: string, - code: '300' | '301' | '302' | '306' = '300', - ): StockDetailQuery { - return { - pdno: stockId, - prdt_type_cd: code, - }; - } - - private getDetailDataQuery( - stockId: string, - divCode: 'J' = 'J', - classify: '0' | '1' = '0', - ): DetailDataQuery { - return { - fid_div_cls_code: classify, - fid_cond_mrkt_div_code: divCode, - fid_input_iscd: stockId, - }; - } - - private getDate52WeeksAgo(): Date { - const today = new Date(); - const weeksAgo = 52 * 7; - const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); - date52WeeksAgo.setHours(0, 0, 0, 0); - return date52WeeksAgo; - } -} diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts deleted file mode 100644 index 456cff90..00000000 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { EntityManager } from 'typeorm'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; -import { StockData, parseStockData } from '../type/openapiLiveData.type'; -import { decryptAES256 } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { KospiStock } from '@/stock/domain/kospiStock.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; - -export class OpenapiLiveData { - public readonly TR_ID: string = 'H0STCNT0'; - private readonly WEBSOCKET_MAX: number = 40; - constructor( - @Inject('winston') private readonly logger: Logger, - private readonly manager: EntityManager, - ) {} - - public async getMessage(): Promise { - const kospi = await this.getKospiStockId(); - const config = openApiToken.configs; - const configLength = config.length; - const ret: string[] = []; - - for (let i = 0; i < configLength; i++) { - const stocks = kospi.splice( - i * this.WEBSOCKET_MAX, - (i + 1) * this.WEBSOCKET_MAX, - ); - for (const stock of stocks) { - ret.push(this.convertObjectToMessage(config[i], stock.id!)); - } - } - - return ret; - } - - private convertObjectToMessage( - config: typeof openApiConfig, - stockId: string, - ): string { - const message = { - header: { - approval_key: config.STOCK_WEBSOCKET_KEY!, - custtype: 'P', - tr_type: '1', - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: this.TR_ID, - tr_key: stockId, - }, - }, - }; - return JSON.stringify(message); - } - - private async getKospiStockId() { - const kospi = await this.manager.find(KospiStock, { - where: { - isKospi: true, - }, - }); - return kospi; - } - - private async saveLiveData(data: StockLiveData) { - await this.manager.save(StockLiveData, data); - } - - private convertLiveData(message: string[]): StockLiveData { - const stockData: StockData = parseStockData(message); - const stockLiveData = new StockLiveData(); - stockLiveData.currentPrice = parseFloat(stockData.STCK_PRPR); - stockLiveData.changeRate = parseFloat(stockData.PRDY_CTRT); - stockLiveData.volume = parseInt(stockData.CNTG_VOL); - stockLiveData.high = parseFloat(stockData.STCK_HGPR); - stockLiveData.low = parseFloat(stockData.STCK_LWPR); - stockLiveData.open = parseFloat(stockData.STCK_OPRC); - stockLiveData.previousClose = parseFloat(stockData.WGHN_AVRG_STCK_PRC); - stockLiveData.updatedAt = new Date(); - - return stockLiveData; - } - - public async output(message: Buffer, iv?: string, key?: string) { - const parsed = message.toString().split('|'); - console.log(message.toString()); - if (parsed.length > 0) { - if (parsed[0] == '1' && iv && key) - parsed[3] = decryptAES256(parsed[3], iv, key); - if (parsed[1] !== this.TR_ID) return; - const stockData = parsed[3].split('^'); - const length = stockData.length / parseInt(parsed[2]); - const size = parseInt(parsed[2]); - const i = 0; - while (i < size) { - const data = stockData.splice(i * length, (i + 1) * length); - const liveData = this.convertLiveData(data); - this.saveLiveData(liveData); - } - } - } -} diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts deleted file mode 100644 index e050e3cd..00000000 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Injectable, UseFilters } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; -import { openApiConfig } from '../config/openapi.config'; -import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; -import { - isMinuteData, - MinuteData, - UpdateStockQuery, -} from '../type/openapiMinuteData.type'; -import { TR_IDS } from '../type/openapiUtil.type'; -import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { Stock } from '@/stock/domain/stock.entity'; -import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; - -const STOCK_CUT = 4; - -@Injectable() -export class OpenapiMinuteData { - private stock: Stock[][] = []; - private readonly entity = StockMinutely; - private readonly url: string = - '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - private readonly intervals: number = 130; - private flip: number = 0; - public constructor(private readonly datasource: DataSource) { - this.getStockData(); - } - - @Cron('0 1 * * 1-5') - @UseFilters(OpenapiExceptionFilter) - private async getStockData() { - if (process.env.NODE_ENV !== 'production') return; - const stock = await this.datasource.manager.findBy(Stock, { - isTrading: true, - }); - const stockSize = Math.ceil(stock.length / STOCK_CUT); - let i = 0; - this.stock = []; - while (i < STOCK_CUT) { - this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); - i++; - } - } - - private convertResToMinuteData( - stockId: string, - item: MinuteData, - time: string, - ) { - const stockPeriod = new StockData(); - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(item.stck_bsop_date.slice(0, 4)), - parseInt(item.stck_bsop_date.slice(4, 6)) - 1, - parseInt(item.stck_bsop_date.slice(6, 8)), - parseInt(time.slice(0, 2)), - parseInt(time.slice(2, 4)), - ); - stockPeriod.close = parseInt(item.stck_prpr); - stockPeriod.open = parseInt(item.stck_oprc); - stockPeriod.high = parseInt(item.stck_hgpr); - stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.cntg_vol); - stockPeriod.createdAt = new Date(); - return stockPeriod; - } - - private isMarketOpenTime(time: string) { - const numberTime = parseInt(time); - return numberTime >= 90000 && numberTime <= 153000; - } - - private async saveMinuteData( - stockId: string, - item: MinuteData[], - time: string, - ) { - const manager = this.datasource.manager; - if (!this.isMarketOpenTime(time)) return; - const stockPeriod = item.map((val) => - this.convertResToMinuteData(stockId, val, time), - ); - manager.save(this.entity, stockPeriod); - } - - private async getMinuteDataInterval( - stockId: string, - time: string, - config: typeof openApiConfig, - ) { - const query = this.getUpdateStockQuery(stockId, time); - try { - const response = await getOpenApi( - this.url, - config, - query, - TR_IDS.MINUTE_DATA, - ); - let output; - if (response.output2) output = response.output2; - if (output && output[0] && isMinuteData(output[0])) { - this.saveMinuteData(stockId, output, time); - } - } catch (error) { - console.error(error); - } - } - - @UseFilters(OpenapiExceptionFilter) - private async getMinuteDataChunk( - chunk: Stock[], - config: typeof openApiConfig, - ) { - const time = getCurrentTime(); - let interval = 0; - for await (const stock of chunk) { - setTimeout( - () => this.getMinuteDataInterval(stock.id!, time, config), - interval, - ); - interval += this.intervals; - } - } - - @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) - @UseFilters(OpenapiExceptionFilter) - private getMinuteData() { - if (process.env.NODE_ENV !== 'production') return; - const configCount = openApiToken.configs.length; - const stock = this.stock[this.flip % STOCK_CUT]; - this.flip++; - const chunkSize = Math.ceil(stock.length / configCount); - for (let i = 0; i < configCount; i++) { - const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, openApiToken.configs[i]); - } - } - - private getUpdateStockQuery( - stockId: string, - time: string, - isPastData: boolean = true, - marketCode: 'J' | 'W' = 'J', - ): UpdateStockQuery { - return { - fid_etc_cls_code: '', - fid_cond_mrkt_div_code: marketCode, - fid_input_iscd: stockId, - fid_input_hour_1: time, - fid_pw_data_incu_yn: isPastData ? 'Y' : 'N', - }; - } -} diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts deleted file mode 100644 index 64db1d9b..00000000 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Inject, Injectable, UseFilters } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { DataSource, EntityManager } from 'typeorm'; -import { Logger } from 'winston'; -import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; -import { - ChartData, - isChartData, - ItemChartPriceQuery, - Period, -} from '../type/openapiPeriodData'; -import { TR_IDS } from '../type/openapiUtil.type'; -import { - getOpenApi, - getPreviousDate, - getTodayDate, -} from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { Stock } from '@/stock/domain/stock.entity'; -import { - StockData, - StockDaily, - StockWeekly, - StockMonthly, - StockYearly, -} from '@/stock/domain/stockData.entity'; - -const DATE_TO_ENTITY = { - D: StockDaily, - W: StockWeekly, - M: StockMonthly, - Y: StockYearly, -}; - -const DATE_TO_MONTH = { - D: 3, - W: 6, - M: 12, - Y: 24, -}; - -const INTERVALS = 4000; - -@Injectable() -export class OpenapiPeriodData { - private readonly url: string = - '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; - public constructor( - private readonly datasource: DataSource, - @Inject('winston') private readonly logger: Logger, - ) { - //this.getItemChartPriceCheck(); - } - - @Cron('0 1 * * 1-5') - @UseFilters(OpenapiExceptionFilter) - public async getItemChartPriceCheck() { - if (process.env.NODE_ENV !== 'production') return; - const entityManager = this.datasource.manager; - const stocks = await entityManager.find(Stock); - const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getChartData(chunk, 'D'); - setTimeout(() => this.getChartData(chunk, 'W'), INTERVALS); - setTimeout(() => this.getChartData(chunk, 'M'), INTERVALS * 2); - setTimeout(() => this.getChartData(chunk, 'Y'), INTERVALS * 3); - } - } - - private async getChartData(chunk: Stock[], period: Period) { - const baseTime = INTERVALS * 4; - const entity = DATE_TO_ENTITY[period]; - const manager = this.datasource.manager; - - let time = 0; - for (const stock of chunk) { - time += baseTime; - setTimeout( - () => this.processStockData(stock, period, entity, manager), - time, - ); - } - } - - private async processStockData( - stock: Stock, - period: Period, - entity: typeof StockData, - manager: EntityManager, - ) { - const stockPeriod = new StockData(); - let configIdx = 0; - let end = getTodayDate(); - let start = getPreviousDate(end, 3); - let isFail = false; - - while (isFail) { - configIdx = (configIdx + 1) % openApiToken.configs.length; - this.setStockPeriod(stockPeriod, stock.id!, end); - - if (await this.existsChartData(stockPeriod, manager, entity)) return; - - const query = this.getItemChartPriceQuery(stock.id!, start, end, period); - - const output = await this.fetchChartData(query, configIdx); - - if (output && isChartData(output[0])) { - await this.saveChartData(entity, stock.id!, output); - ({ endDate: end, startDate: start } = this.updateDates(start, period)); - } else isFail = true; - } - } - - private setStockPeriod( - stockPeriod: StockData, - stockId: string, - endDate: string, - ): void { - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(endDate.slice(0, 4)), - parseInt(endDate.slice(4, 6)) - 1, - parseInt(endDate.slice(6, 8)), - ); - } - - private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { - try { - const response = await getOpenApi( - this.url, - openApiToken.configs[configIdx], - query, - TR_IDS.ITEM_CHART_PRICE, - ); - return response.output2 as ChartData[]; - } catch (error) { - this.logger.error(error); - } - } - - private updateDates( - startDate: string, - period: Period, - ): { endDate: string; startDate: string } { - const endDate = getPreviousDate(startDate, DATE_TO_MONTH[period]); - startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); - return { endDate, startDate }; - } - - private async existsChartData( - stock: StockData, - manager: EntityManager, - entity: typeof StockData, - ) { - return await manager.findOne(entity, { - where: { - stock: { id: stock.stock.id }, - createdAt: stock.startTime, - }, - }); - } - - private async insertChartData(stock: StockData, entity: typeof StockData) { - const manager = this.datasource.manager; - if (!(await this.existsChartData(stock, manager, entity))) { - await manager.save(entity, stock); - } - } - - private async saveChartData( - entity: typeof StockData, - stockId: string, - data: ChartData[], - ) { - for (const item of data) { - if (!item || !item.stck_bsop_date) { - continue; - } - const stockPeriod = new StockData(); - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(item.stck_bsop_date.slice(0, 4)), - parseInt(item.stck_bsop_date.slice(4, 6)) - 1, - parseInt(item.stck_bsop_date.slice(6, 8)), - ); - stockPeriod.close = parseInt(item.stck_clpr); - stockPeriod.open = parseInt(item.stck_oprc); - stockPeriod.high = parseInt(item.stck_hgpr); - stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.acml_vol); - stockPeriod.createdAt = new Date(); - await this.insertChartData(stockPeriod, entity); - } - } - - private getItemChartPriceQuery( - stockId: string, - startDate: string, - endDate: string, - period: Period, - marketCode: 'J' | 'W' = 'J', - ): ItemChartPriceQuery { - return { - fid_cond_mrkt_div_code: marketCode, - fid_input_iscd: stockId, - fid_input_date_1: startDate, - fid_input_date_2: endDate, - fid_period_div_code: period, - fid_org_adj_prc: 0, - }; - } -} diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts deleted file mode 100644 index fa4901a8..00000000 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Inject, UseFilters } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; -import { OpenapiExceptionFilter } from '../Decorator/openapiException.filter'; -import { OpenapiException } from '../util/openapiCustom.error'; -import { postOpenApi } from '../util/openapiUtil.api'; -import { logger } from '@/configs/logger.config'; - -class OpenapiTokenApi { - private config: (typeof openApiConfig)[] = []; - public constructor(@Inject('winston') private readonly logger: Logger) { - const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); - const api_keys = openApiConfig.STOCK_API_KEY!.split(','); - const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); - if ( - accounts.length === 0 || - accounts.length !== api_keys.length || - api_passwords.length !== api_keys.length - ) { - this.logger.warn('Open API Config Error'); - } - for (let i = 0; i < accounts.length; i++) { - this.config.push({ - STOCK_URL: openApiConfig.STOCK_URL, - STOCK_ACCOUNT: accounts[i], - STOCK_API_KEY: api_keys[i], - STOCK_API_PASSWORD: api_passwords[i], - }); - } - this.initAuthenValue(); - } - - public get configs() { - //TODO : 현재 구조에서 받아올 때마다 확인후 할당으로 변경 - return this.config; - } - - @UseFilters(OpenapiExceptionFilter) - private async initAuthenValue() { - const delay = 60000; - const delayMinute = delay / 1000 / 60; - - try { - await this.initAccessToken(); - await this.initWebSocketKey(); - } catch (error) { - if (error instanceof Error) { - this.logger.warn( - `Request failed: ${error.message}. Retrying in ${delayMinute} minute...`, - ); - } else { - this.logger.warn( - `Request failed. Retrying in ${delayMinute} minute...`, - ); - setTimeout(async () => { - await this.initAccessToken(); - await this.initWebSocketKey(); - }, delay); - } - } - } - - @Cron('50 0 * * 1-5') - private async initAccessToken() { - const updatedConfig = await Promise.all( - this.config.map(async (val) => { - val.STOCK_API_TOKEN = await this.getToken(val)!; - return val; - }), - ); - this.config = updatedConfig; - } - - @Cron('50 0 * * 1-5') - private async initWebSocketKey() { - const updatedConfig = await Promise.all( - this.config.map(async (val) => { - val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; - return val; - }), - ); - this.config = updatedConfig; - } - - private async getToken(config: typeof openApiConfig): Promise { - const body = { - grant_type: 'client_credentials', - appkey: config.STOCK_API_KEY, - appsecret: config.STOCK_API_PASSWORD, - }; - const tmp = await postOpenApi('/oauth2/tokenP', config, body); - if (!tmp.access_token) { - throw new OpenapiException('Access Token Failed', 403); - } - return tmp.access_token as string; - } - - private async getWebSocketKey(config: typeof openApiConfig): Promise { - const body = { - grant_type: 'client_credentials', - appkey: config.STOCK_API_KEY, - secretkey: config.STOCK_API_PASSWORD, - }; - const tmp = await postOpenApi('/oauth2/Approval', config, body); - if (!tmp.approval_key) { - throw new OpenapiException('WebSocket Key Failed', 403); - } - return tmp.approval_key as string; - } -} - -const openApiToken = new OpenapiTokenApi(logger); -export { openApiToken }; diff --git a/packages/backend/src/scraper/openapi/config/openapi.config.ts b/packages/backend/src/scraper/openapi/config/openapi.config.ts deleted file mode 100644 index 8aa12ea3..00000000 --- a/packages/backend/src/scraper/openapi/config/openapi.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as dotenv from 'dotenv'; - -dotenv.config(); - -export const openApiConfig: { - STOCK_URL: string | undefined; - STOCK_ACCOUNT: string | undefined; - STOCK_API_KEY: string | undefined; - STOCK_API_PASSWORD: string | undefined; - STOCK_API_TOKEN?: string; - STOCK_WEBSOCKET_KEY?: string; -} = { - STOCK_URL: process.env.STOCK_URL, - STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, - STOCK_API_KEY: process.env.STOCK_API_KEY, - STOCK_API_PASSWORD: process.env.STOCK_API_PASSWORD, -}; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts deleted file mode 100644 index cb45c91c..00000000 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { OpenapiDetailData } from './api/openapiDetailData.api'; -import { OpenapiLiveData } from './api/openapiLiveData.api'; -import { OpenapiMinuteData } from './api/openapiMinuteData.api'; -import { OpenapiPeriodData } from './api/openapiPeriodData.api'; -import { OpenapiScraperService } from './openapi-scraper.service'; -import { WebsocketClient } from './websocketClient.service'; -import { Stock } from '@/stock/domain/stock.entity'; -import { - StockDaily, - StockMinutely, - StockMonthly, - StockWeekly, - StockYearly, -} from '@/stock/domain/stockData.entity'; -import { StockDetail } from '@/stock/domain/stockDetail.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([ - Stock, - StockMinutely, - StockDaily, - StockWeekly, - StockMonthly, - StockYearly, - StockLiveData, - StockDetail, - ]), - ], - controllers: [], - providers: [ - OpenapiPeriodData, - OpenapiMinuteData, - OpenapiDetailData, - OpenapiScraperService, - OpenapiLiveData, - WebsocketClient, - ], -}) -export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts deleted file mode 100644 index 52c90179..00000000 --- a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { OpenapiDetailData } from './api/openapiDetailData.api'; -import { OpenapiMinuteData } from './api/openapiMinuteData.api'; -import { OpenapiPeriodData } from './api/openapiPeriodData.api'; - -@Injectable() -export class OpenapiScraperService { - public constructor( - private datasource: DataSource, - private readonly openapiPeriodData: OpenapiPeriodData, - private readonly openapiMinuteData: OpenapiMinuteData, - private readonly openapiDetailData: OpenapiDetailData, - ) {} -} diff --git a/packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts b/packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts new file mode 100644 index 00000000..37d3deb6 --- /dev/null +++ b/packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts @@ -0,0 +1,106 @@ +/* eslint-disable max-lines-per-function */ +import { parseMessage } from './openapi.parser'; + +const answer = [ + { + STOCK_ID: '005930', + MKSC_SHRN_ISCD: 5930, + STCK_CNTG_HOUR: 93354, + STCK_PRPR: 71900, + PRDY_VRSS_SIGN: 5, + PRDY_VRSS: -100, + PRDY_CTRT: -0.14, + WGHN_AVRG_STCK_PRC: 72023.83, + STCK_OPRC: 72100, + STCK_HGPR: 72400, + STCK_LWPR: 71700, + ASKP1: 71900, + BIDP1: 71800, + CNTG_VOL: 1, + ACML_VOL: 3052507, + ACML_TR_PBMN: 219853241700, + SELN_CNTG_CSNU: 5105, + SHNU_CNTG_CSNU: 6937, + NTBY_CNTG_CSNU: 1832, + CTTR: 84.9, + SELN_CNTG_SMTN: 1366314, + SHNU_CNTG_SMTN: 1159996, + CCLD_DVSN: 1, + SHNU_RATE: 0.39, + PRDY_VOL_VRSS_ACML_VOL_RATE: 20.28, + OPRC_HOUR: 90020, + OPRC_VRSS_PRPR_SIGN: 5, + OPRC_VRSS_PRPR: -200, + HGPR_HOUR: 90820, + HGPR_VRSS_PRPR_SIGN: 5, + HGPR_VRSS_PRPR: -500, + LWPR_HOUR: 92619, + LWPR_VRSS_PRPR_SIGN: 2, + LWPR_VRSS_PRPR: 200, + BSOP_DATE: 20230612, + NEW_MKOP_CLS_CODE: 20, + TRHT_YN: 'N', + ASKP_RSQN1: 65945, + BIDP_RSQN1: 216924, + TOTAL_ASKP_RSQN: 1118750, + TOTAL_BIDP_RSQN: 2199206, + VOL_TNRT: 0.05, + PRDY_SMNS_HOUR_ACML_VOL: 2424142, + PRDY_SMNS_HOUR_ACML_VOL_RATE: 125.92, + HOUR_CLS_CODE: 0, + MRKT_TRTM_CLS_CODE: null, + VI_STND_PRC: 72100, + }, +]; + +describe('openapi parser test', () => { + test('parse json websocket data', () => { + const message = `{ + "header": { + "tr_id": "H0STCNT0", + "tr_key": "005930", + "encrypt": "N" + }, + "body": { + "rt_cd": "0", + "msg_cd": "OPSP0000", + "msg1": "SUBSCRIBE SUCCESS", + "output": { + "iv": "0123456789abcdef", + "key": "abcdefghijklmnopabcdefghijklmnop"} + } + }`; + + const result = parseMessage(message); + + expect(result).toEqual(JSON.parse(message)); + }); + + test('parse stockData', () => { + const message = + '0|H0STCNT0|001|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100'; + + const result = parseMessage(message); + + expect(result).toEqual(answer); + }); + + test('parse stockData', () => { + const message = + '0|H0STCNT0|002|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100^' + + '005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100'; + + const result = parseMessage(message); + + expect(result).toEqual([answer[0], answer[0]]); + }); +}); diff --git a/packages/backend/src/scraper/openapi/parse/openapi.parser.ts b/packages/backend/src/scraper/openapi/parse/openapi.parser.ts new file mode 100644 index 00000000..fe3e7002 --- /dev/null +++ b/packages/backend/src/scraper/openapi/parse/openapi.parser.ts @@ -0,0 +1,38 @@ +import { stockDataKeys } from '../type/openapiLiveData.type'; + +export const parseMessage = (data: string) => { + try { + return JSON.parse(data); + //eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return parseStockData(data); + } +}; +const FIELD_LENGTH: number = stockDataKeys.length; + +const parseStockData = (input: string) => { + const dataBlocks = input.split('|'); // 데이터 구분 + const results = []; + const size = parseInt(dataBlocks[2]); // 데이터 건수 + const rawData = dataBlocks[3]; + const values = rawData.split('^'); // 필드 구분자 '^' + + for (let i = 0; i < size; i++) { + //TODO : type narrowing require + const parsedData: Record = {}; + parsedData['STOCK_ID'] = values[i * FIELD_LENGTH]; + stockDataKeys.forEach((field: string, index: number) => { + const value = values[index + FIELD_LENGTH * i]; + if (!value) return (parsedData[field] = null); + + // 숫자형 필드 처리 + if (isNaN(parseInt(value))) { + parsedData[field] = value; // 문자열 그대로 저장 + } else { + parsedData[field] = parseFloat(value); // 숫자로 변환 + } + }); + results.push(parsedData); + } + return results; +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts deleted file mode 100644 index 38015d48..00000000 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any*/ -/* eslint-disable max-lines-per-function */ - -export type DetailDataQuery = { - fid_cond_mrkt_div_code: 'J'; - fid_input_iscd: string; - fid_div_cls_code: '0' | '1'; -}; - -export type FinancialRatio = { - stac_yymm: string; // 결산 년월 - grs: string; // 매출액 증가율 - bsop_prfi_inrt: string; // 영업 이익 증가율 - ntin_inrt: string; // 순이익 증가율 - roe_val: string; // ROE 값 - eps: string; // EPS - sps: string; // 주당매출액 - bps: string; // BPS - rsrv_rate: string; // 유보 비율 - lblt_rate: string; // 부채 비율 -}; - -export function isFinancialRatioData(data: any): data is FinancialRatio { - return ( - data && - typeof data.stac_yymm === 'string' && - typeof data.grs === 'string' && - typeof data.bsop_prfi_inrt === 'string' && - typeof data.ntin_inrt === 'string' && - typeof data.roe_val === 'string' && - typeof data.eps === 'string' && - typeof data.sps === 'string' && - typeof data.bps === 'string' && - typeof data.rsrv_rate === 'string' && - typeof data.lblt_rate === 'string' - ); -} - -export type ProductDetail = { - pdno: string; // 상품번호 - prdt_type_cd: string; // 상품유형코드 - mket_id_cd: string; // 시장ID코드 - scty_grp_id_cd: string; // 증권그룹ID코드 - excg_dvsn_cd: string; // 거래소구분코드 - setl_mmdd: string; // 결산월일 - lstg_stqt: string; // 상장주수 - 이거 사용 - lstg_cptl_amt: string; // 상장자본금액 - cpta: string; // 자본금 - papr: string; // 액면가 - issu_pric: string; // 발행가격 - kospi200_item_yn: string; // 코스피200종목여부 - 이것도 사용 - scts_mket_lstg_dt: string; // 유가증권시장상장일자 - scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 - kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 - kosdaq_mket_lstg_abol_dt: string; // 코스닥시장상장폐지일자 - frbd_mket_lstg_dt: string; // 프리보드시장상장일자 - frbd_mket_lstg_abol_dt: string; // 프리보드시장상장폐지일자 - reits_kind_cd: string; // 리츠종류코드 - etf_dvsn_cd: string; // ETF구분코드 - oilf_fund_yn: string; // 유전펀드여부 - idx_bztp_lcls_cd: string; // 지수업종대분류코드 - idx_bztp_mcls_cd: string; // 지수업종중분류코드 - idx_bztp_scls_cd: string; // 지수업종소분류코드 - stck_kind_cd: string; // 주식종류코드 - mfnd_opng_dt: string; // 뮤추얼펀드개시일자 - mfnd_end_dt: string; // 뮤추얼펀드종료일자 - dpsi_erlm_cncl_dt: string; // 예탁등록취소일자 - etf_cu_qty: string; // ETFCU수량 - prdt_name: string; // 상품명 - prdt_name120: string; // 상품명120 - prdt_abrv_name: string; // 상품약어명 - std_pdno: string; // 표준상품번호 - prdt_eng_name: string; // 상품영문명 - prdt_eng_name120: string; // 상품영문명120 - prdt_eng_abrv_name: string; // 상품영문약어명 - dpsi_aptm_erlm_yn: string; // 예탁지정등록여부 - etf_txtn_type_cd: string; // ETF과세유형코드 - etf_type_cd: string; // ETF유형코드 - lstg_abol_dt: string; // 상장폐지일자 - nwst_odst_dvsn_cd: string; // 신주구주구분코드 - sbst_pric: string; // 대용가격 - thco_sbst_pric: string; // 당사대용가격 - thco_sbst_pric_chng_dt: string; // 당사대용가격변경일자 - tr_stop_yn: string; // 거래정지여부 - admn_item_yn: string; // 관리종목여부 - thdt_clpr: string; // 당일종가 - bfdy_clpr: string; // 전일종가 - clpr_chng_dt: string; // 종가변경일자 - std_idst_clsf_cd: string; // 표준산업분류코드 - std_idst_clsf_cd_name: string; // 표준산업분류코드명 - idx_bztp_lcls_cd_name: string; // 지수업종대분류코드명 - idx_bztp_mcls_cd_name: string; // 지수업종중분류코드명 - idx_bztp_scls_cd_name: string; // 지수업종소분류코드명 - ocr_no: string; // OCR번호 - crfd_item_yn: string; // 크라우드펀딩종목여부 - elec_scty_yn: string; // 전자증권여부 - issu_istt_cd: string; // 발행기관코드 - etf_chas_erng_rt_dbnb: string; // ETF추적수익율배수 - etf_etn_ivst_heed_item_yn: string; // ETFETN투자유의종목여부 - stln_int_rt_dvsn_cd: string; // 대주이자율구분코드 - frnr_psnl_lmt_rt: string; // 외국인개인한도비율 - lstg_rqsr_issu_istt_cd: string; // 상장신청인발행기관코드 - lstg_rqsr_item_cd: string; // 상장신청인종목코드 - trst_istt_issu_istt_cd: string; // 신탁기관발행기관코드 -}; - -export const isProductDetail = (data: any): data is ProductDetail => { - return ( - typeof data.pdno === 'string' && - typeof data.prdt_type_cd === 'string' && - typeof data.mket_id_cd === 'string' && - typeof data.scty_grp_id_cd === 'string' && - typeof data.excg_dvsn_cd === 'string' && - typeof data.setl_mmdd === 'string' && - typeof data.lstg_stqt === 'string' && - typeof data.lstg_cptl_amt === 'string' && - typeof data.cpta === 'string' && - typeof data.papr === 'string' && - typeof data.issu_pric === 'string' && - typeof data.kospi200_item_yn === 'string' && - typeof data.scts_mket_lstg_dt === 'string' && - typeof data.scts_mket_lstg_abol_dt === 'string' && - typeof data.kosdaq_mket_lstg_dt === 'string' && - typeof data.kosdaq_mket_lstg_abol_dt === 'string' && - typeof data.frbd_mket_lstg_dt === 'string' && - typeof data.frbd_mket_lstg_abol_dt === 'string' && - typeof data.reits_kind_cd === 'string' && - typeof data.etf_dvsn_cd === 'string' && - typeof data.oilf_fund_yn === 'string' && - typeof data.idx_bztp_lcls_cd === 'string' && - typeof data.idx_bztp_mcls_cd === 'string' && - typeof data.idx_bztp_scls_cd === 'string' && - typeof data.stck_kind_cd === 'string' && - typeof data.mfnd_opng_dt === 'string' && - typeof data.mfnd_end_dt === 'string' && - typeof data.dpsi_erlm_cncl_dt === 'string' && - typeof data.etf_cu_qty === 'string' && - typeof data.prdt_name === 'string' && - typeof data.prdt_name120 === 'string' && - typeof data.prdt_abrv_name === 'string' && - typeof data.std_pdno === 'string' && - typeof data.prdt_eng_name === 'string' && - typeof data.prdt_eng_name120 === 'string' && - typeof data.prdt_eng_abrv_name === 'string' && - typeof data.dpsi_aptm_erlm_yn === 'string' && - typeof data.etf_txtn_type_cd === 'string' && - typeof data.etf_type_cd === 'string' && - typeof data.lstg_abol_dt === 'string' && - typeof data.nwst_odst_dvsn_cd === 'string' && - typeof data.sbst_pric === 'string' && - typeof data.thco_sbst_pric === 'string' && - typeof data.thco_sbst_pric_chng_dt === 'string' && - typeof data.tr_stop_yn === 'string' && - typeof data.admn_item_yn === 'string' && - typeof data.thdt_clpr === 'string' && - typeof data.bfdy_clpr === 'string' && - typeof data.clpr_chng_dt === 'string' && - typeof data.std_idst_clsf_cd === 'string' && - typeof data.std_idst_clsf_cd_name === 'string' && - typeof data.idx_bztp_lcls_cd_name === 'string' && - typeof data.idx_bztp_mcls_cd_name === 'string' && - typeof data.idx_bztp_scls_cd_name === 'string' && - typeof data.ocr_no === 'string' && - typeof data.crfd_item_yn === 'string' && - typeof data.elec_scty_yn === 'string' && - typeof data.issu_istt_cd === 'string' && - typeof data.etf_chas_erng_rt_dbnb === 'string' && - typeof data.etf_etn_ivst_heed_item_yn === 'string' && - typeof data.stln_int_rt_dvsn_cd === 'string' && - typeof data.frnr_psnl_lmt_rt === 'string' && - typeof data.lstg_rqsr_issu_istt_cd === 'string' && - typeof data.lstg_rqsr_item_cd === 'string' && - typeof data.trst_istt_issu_istt_cd === 'string' - ); -}; - -export type StockDetailQuery = { - pdno: string; - prdt_type_cd: string; -}; - -//export type FinancialDetail = { -// stac_yymm: string; // 결산 년월 -// sale_account: string; // 매출액 -// sale_cost: string; // 매출원가 -// sale_totl_prfi: string; // 매출총이익 -// depr_cost: string; // 감가상각비 -// sell_mang: string; // 판매관리비 -// bsop_prti: string; // 영업이익 -// bsop_non_ernn: string; // 영업외수익 -// bsop_non_expn: string; // 영업외비용 -// op_prfi: string; // 영업이익 -// spec_prfi: string; // 특별이익 -// spec_loss: string; // 특별손실 -// thtr_ntin: string; // 세전순이익 -//}; - -//export const isFinancialDetail = (data: any): data is FinancialDetail => { -// return ( -// typeof data.stac_yymm === 'string' && -// typeof data.sale_account === 'string' && -// typeof data.sale_cost === 'string' && -// typeof data.sale_totl_prfi === 'string' && -// typeof data.depr_cost === 'string' && -// typeof data.sell_mang === 'string' && -// typeof data.bsop_prti === 'string' && -// typeof data.bsop_non_ernn === 'string' && -// typeof data.bsop_non_expn === 'string' && -// typeof data.op_prfi === 'string' && -// typeof data.spec_prfi === 'string' && -// typeof data.spec_loss === 'string' && -// typeof data.thtr_ntin === 'string' -// ); -//}; diff --git a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts deleted file mode 100644 index d8041e7b..00000000 --- a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable max-lines-per-function */ - -export type StockData = { - MKSC_SHRN_ISCD: string; // 유가증권 단축 종목코드 - STCK_CNTG_HOUR: string; // 주식 체결 시간 - STCK_PRPR: string; // 주식 현재가 - PRDY_VRSS_SIGN: string; // 전일 대비 부호 - PRDY_VRSS: string; // 전일 대비 - PRDY_CTRT: string; // 전일 대비율 - WGHN_AVRG_STCK_PRC: string; // 가중 평균 주식 가격 - STCK_OPRC: string; // 주식 시가 - STCK_HGPR: string; // 주식 최고가 - STCK_LWPR: string; // 주식 최저가 - ASKP1: string; // 매도호가1 - BIDP1: string; // 매수호가1 - CNTG_VOL: string; // 체결 거래량 - ACML_VOL: string; // 누적 거래량 - ACML_TR_PBMN: string; // 누적 거래 대금 - SELN_CNTG_CSNU: string; // 매도 체결 건수 - SHNU_CNTG_CSNU: string; // 매수 체결 건수 - NTBY_CNTG_CSNU: string; // 순매수 체결 건수 - CTTR: string; // 체결강도 - SELN_CNTG_SMTN: string; // 총 매도 수량 - SHNU_CNTG_SMTN: string; // 총 매수 수량 - CCLD_DVSN: string; // 체결구분 - SHNU_RATE: string; // 매수비율 - PRDY_VOL_VRSS_ACML_VOL_RATE: string; // 전일 거래량 대비 등락율 - OPRC_HOUR: string; // 시가 시간 - OPRC_VRSS_PRPR_SIGN: string; // 시가대비구분 - OPRC_VRSS_PRPR: string; // 시가대비 - HGPR_HOUR: string; // 최고가 시간 - HGPR_VRSS_PRPR_SIGN: string; // 고가대비구분 - HGPR_VRSS_PRPR: string; // 고가대비 - LWPR_HOUR: string; // 최저가 시간 - LWPR_VRSS_PRPR_SIGN: string; // 저가대비구분 - LWPR_VRSS_PRPR: string; // 저가대비 - BSOP_DATE: string; // 영업 일자 - NEW_MKOP_CLS_CODE: string; // 신 장운영 구분 코드 - TRHT_YN: string; // 거래정지 여부 - ASKP_RSQN1: string; // 매도호가 잔량1 - BIDP_RSQN1: string; // 매수호가 잔량1 - TOTAL_ASKP_RSQN: string; // 총 매도호가 잔량 - TOTAL_BIDP_RSQN: string; // 총 매수호가 잔량 - VOL_TNRT: string; // 거래량 회전율 - PRDY_SMNS_HOUR_ACML_VOL: string; // 전일 동시간 누적 거래량 - PRDY_SMNS_HOUR_ACML_VOL_RATE: string; // 전일 동시간 누적 거래량 비율 - HOUR_CLS_CODE: string; // 시간 구분 코드 - MRKT_TRTM_CLS_CODE: string; // 임의종료구분코드 - VI_STND_PRC: string; // 정적VI발동기준가 -}; - -export function parseStockData(message: string[]): StockData { - return { - MKSC_SHRN_ISCD: message[0], - STCK_CNTG_HOUR: message[1], - STCK_PRPR: message[2], - PRDY_VRSS_SIGN: message[3], - PRDY_VRSS: message[4], - PRDY_CTRT: message[5], - WGHN_AVRG_STCK_PRC: message[6], - STCK_OPRC: message[7], - STCK_HGPR: message[8], - STCK_LWPR: message[9], - ASKP1: message[10], - BIDP1: message[11], - CNTG_VOL: message[12], - ACML_VOL: message[13], - ACML_TR_PBMN: message[14], - SELN_CNTG_CSNU: message[15], - SHNU_CNTG_CSNU: message[16], - NTBY_CNTG_CSNU: message[17], - CTTR: message[18], - SELN_CNTG_SMTN: message[19], - SHNU_CNTG_SMTN: message[20], - CCLD_DVSN: message[21], - SHNU_RATE: message[22], - PRDY_VOL_VRSS_ACML_VOL_RATE: message[23], - OPRC_HOUR: message[24], - OPRC_VRSS_PRPR_SIGN: message[25], - OPRC_VRSS_PRPR: message[26], - HGPR_HOUR: message[27], - HGPR_VRSS_PRPR_SIGN: message[28], - HGPR_VRSS_PRPR: message[29], - LWPR_HOUR: message[30], - LWPR_VRSS_PRPR_SIGN: message[31], - LWPR_VRSS_PRPR: message[32], - BSOP_DATE: message[33], - NEW_MKOP_CLS_CODE: message[34], - TRHT_YN: message[35], - ASKP_RSQN1: message[36], - BIDP_RSQN1: message[37], - TOTAL_ASKP_RSQN: message[38], - TOTAL_BIDP_RSQN: message[39], - VOL_TNRT: message[40], - PRDY_SMNS_HOUR_ACML_VOL: message[41], - PRDY_SMNS_HOUR_ACML_VOL_RATE: message[42], - HOUR_CLS_CODE: message[43], - MRKT_TRTM_CLS_CODE: message[44], - VI_STND_PRC: message[45], - }; -} - -export type OpenApiMessage = { - header: { - approval_key: string; - custtype: string; - tr_type: string; - 'content-type': string; - }; - body: { - input: { - tr_id: string; - tr_key: string; - }; - }; -}; - -export type MessageResponse = { - header: { - tr_id: string; - tr_key: string; - encrypt: string; - }; - body: { - rt_cd: string; - msg_cd: string; - msg1: string; - output?: { - iv: string; - key: string; - }; - }; -}; - -export function isMessageResponse(data: any): data is MessageResponse { - return ( - typeof data === 'object' && - data !== null && - typeof data.header === 'object' && - data.header !== null && - typeof data.header.tr_id === 'object' && - typeof data.header.tr_key === 'object' && - typeof data.header.encrypt === 'object' && - typeof data.body === 'object' && - data.body !== null && - typeof data.body.rt_cd === 'object' && - typeof data.body.msg_cd === 'object' && - typeof data.body.msg1 === 'object' && - typeof data.body.output === 'object' - ); -} diff --git a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts deleted file mode 100644 index 5deb2d9e..00000000 --- a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export type MinuteData = { - stck_bsop_date: string; - stck_cntg_hour: string; - stck_prpr: string; - stck_oprc: string; - stck_hgpr: string; - stck_lwpr: string; - cntg_vol: string; - acml_tr_pbmn: string; -}; - -export type UpdateStockQuery = { - fid_etc_cls_code: string; - fid_cond_mrkt_div_code: 'J' | 'W'; - fid_input_iscd: string; - fid_input_hour_1: string; - fid_pw_data_incu_yn: 'Y' | 'N'; -}; - -export const isMinuteData = (data: any) => { - return ( - typeof data.stck_bsop_date === 'string' && - typeof data.stck_cntg_hour === 'string' && - typeof data.stck_prpr === 'string' && - typeof data.stck_oprc === 'string' && - typeof data.stck_hgpr === 'string' && - typeof data.stck_lwpr === 'string' && - typeof data.cntg_vol === 'string' && - typeof data.acml_tr_pbmn === 'string' - ); -}; diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts deleted file mode 100644 index 4acc7f44..00000000 --- a/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export type Period = 'D' | 'W' | 'M' | 'Y'; -export type ChartData = { - stck_bsop_date: string; - stck_clpr: string; - stck_oprc: string; - stck_hgpr: string; - stck_lwpr: string; - acml_vol: string; - acml_tr_pbmn: string; - flng_cls_code: string; - prtt_rate: string; - mod_yn: string; - prdy_vrss_sign: string; - prdy_vrss: string; - revl_issu_reas: string; -}; - -export type ItemChartPriceQuery = { - fid_cond_mrkt_div_code: 'J' | 'W'; - fid_input_iscd: string; - fid_input_date_1: string; - fid_input_date_2: string; - fid_period_div_code: Period; - fid_org_adj_prc: number; -}; - -export const isChartData = (data: any) => { - return ( - typeof data.stck_bsop_date === 'string' && - typeof data.stck_clpr === 'string' && - typeof data.stck_oprc === 'string' && - typeof data.stck_hgpr === 'string' && - typeof data.stck_lwpr === 'string' && - typeof data.acml_vol === 'string' && - typeof data.acml_tr_pbmn === 'string' && - typeof data.flng_cls_code === 'string' && - typeof data.prtt_rate === 'string' && - typeof data.mod_yn === 'string' && - typeof data.prdy_vrss_sign === 'string' && - typeof data.prdy_vrss === 'string' && - typeof data.revl_issu_reas === 'string' - ); -}; diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts deleted file mode 100644 index 6df0ca19..00000000 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type TR_ID = - | 'FHKST03010100' - | 'FHKST03010200' - | 'FHKST66430300' - | 'HHKDB669107C0' - | 'CTPF1002R'; - -export const TR_IDS: Record = { - ITEM_CHART_PRICE: 'FHKST03010100', - MINUTE_DATA: 'FHKST03010200', - FINANCIAL_DATA: 'FHKST66430300', - PRODUCTION_DETAIL: 'CTPF1002R', -}; diff --git a/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts deleted file mode 100644 index 1e0c3913..00000000 --- a/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; - -export class OpenapiException extends HttpException { - private error: unknown; - constructor(message: string, status: HttpStatus, error?: unknown) { - super(message, status); - this.error = error; - } - - public getError() { - return this.error; - } -} diff --git a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts deleted file mode 100644 index fa8f75b4..00000000 --- a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any*/ -import * as crypto from 'crypto'; -import { HttpStatus } from '@nestjs/common'; -import axios from 'axios'; -import { openApiConfig } from '../config/openapi.config'; -import { TR_ID } from '../type/openapiUtil.type'; -import { OpenapiException } from './openapiCustom.error'; - -const throwOpenapiException = (error: any) => { - if (error.message && error.response && error.response.status) { - throw new OpenapiException( - `Request failed: ${error.message}`, - error.response.status, - error, - ); - } else { - throw new OpenapiException( - `Unknown error: ${error.message || 'No message'}`, - HttpStatus.INTERNAL_SERVER_ERROR, - error, - ); - } -}; - -const postOpenApi = async ( - url: string, - config: typeof openApiConfig, - body: object, -) => { - try { - const response = await axios.post(config.STOCK_URL + url, body); - return response.data; - } catch (error) { - throwOpenapiException(error); - } -}; - -const getOpenApi = async ( - url: string, - config: typeof openApiConfig, - query: object, - tr_id: TR_ID, -) => { - try { - const response = await axios.get(config.STOCK_URL + url, { - params: query, - headers: { - Authorization: `Bearer ${config.STOCK_API_TOKEN}`, - appkey: config.STOCK_API_KEY, - appsecret: config.STOCK_API_PASSWORD, - tr_id, - custtype: 'P', - }, - }); - return response.data; - } catch (error) { - throwOpenapiException(error); - } -}; - -const getTodayDate = (): string => { - const today = new Date(); - return today.toISOString().split('T')[0].replace(/-/g, ''); -}; - -const getPreviousDate = (date: string, months: number): string => { - const currentDate = new Date( - date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8), - ); - currentDate.setMonth(currentDate.getMonth() - months); - return currentDate.toISOString().split('T')[0].replace(/-/g, ''); -}; - -const getCurrentTime = () => { - const now = new Date(); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - return `${hours}${minutes}${seconds}`; -}; - -const decryptAES256 = ( - encryptedText: string, - key: string, - iv: string, -): string => { - const decipher = crypto.createDecipheriv( - 'aes-256-cbc', - Buffer.from(key, 'hex'), - Buffer.from(iv, 'hex'), - ); - let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; -}; - -const bufferToObject = (buffer: Buffer): any => { - try { - const jsonString = buffer.toString('utf-8'); - return JSON.parse(jsonString); - } catch (error) { - console.error('Failed to convert buffer to object:', error); - throw error; - } -}; - -export { - postOpenApi, - getOpenApi, - getTodayDate, - getPreviousDate, - getCurrentTime, - decryptAES256, - bufferToObject, -}; diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts deleted file mode 100644 index 554c3eff..00000000 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Logger } from 'winston'; -import { WebSocket } from 'ws'; -import { OpenapiLiveData } from './api/openapiLiveData.api'; - -@Injectable() -export class WebsocketClient { - private client: WebSocket; - private readonly reconnectInterval = 60000; - private readonly url = - process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; - - constructor( - @Inject('winston') private readonly logger: Logger, - private readonly openapiLiveData: OpenapiLiveData, - ) { - if (process.env.NODE_ENV === 'production') { - this.connect(); - } - } - - // TODO : subscribe 구조로 리팩토링 - private subscribe() {} - - private message(data: any) { - this.logger.info(`Received message: ${data}`); - if (data.header && data.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(data)}`); - this.sendPong(); - return; - } - if (data.header && data.header.tr_id === 'H0STCNT0') { - return; - } - this.openapiLiveData.output(data); - } - - @Cron('0 2 * * 1-5') - private connect() { - this.client = new WebSocket(this.url); - - this.client.on('open', () => { - this.logger.info('WebSocket connection established'); - this.openapiLiveData.getMessage().then((val) => { - val.forEach((message) => this.sendMessage(message)); - }); - }); - - this.client.on('message', (data: any) => { - try { - this.message(data); - } catch (error) { - this.logger.info(error); - } - }); - - this.client.on('close', () => { - this.logger.warn( - `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, - ); - setTimeout(() => this.connect(), this.reconnectInterval); - }); - - this.client.on('error', (error: any) => { - this.logger.error(`WebSocket error: ${error.message}`); - }); - } - - private sendPong() { - const pongMessage = { - header: { tr_id: 'PINGPONG', datetime: new Date().toISOString() }, - }; - this.client.send(JSON.stringify(pongMessage)); - this.logger.info(`Sent PONG: ${JSON.stringify(pongMessage)}`); - } - - private sendMessage(message: string) { - if (this.client.readyState === WebSocket.OPEN) { - this.client.send(message); - this.logger.info(`Sent message: ${message}`); - } else { - this.logger.warn('WebSocket is not open. Message not sent.'); - } - } -} From a188a032b7c293aa52be4c1a77479a9bd3819de6 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 21:11:02 +0900 Subject: [PATCH 086/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20websoc?= =?UTF-8?q?ket=20client=20service=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korea-stock-info/openapi/websocketClient.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts index 3073683e..2e36acfb 100644 --- a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts @@ -4,11 +4,12 @@ import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; import { RawData, WebSocket } from 'ws'; import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { openApiToken } from './api/openapiToken.api'; +import { openApiConfig } from './config/openapi.config'; import { parseMessage } from './parse/openapi.parser'; -import { openApiToken } from '@/scraper/openapi/api/openapiToken.api'; -import { openApiConfig } from '@/scraper/openapi/config/openapi.config'; type TR_IDS = '0' | '1'; + @Injectable() export class WebsocketClient { private client: WebSocket; From df6709c62dee28a9f3671cf5ab38d6f52550a1ff Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 21:13:50 +0900 Subject: [PATCH 087/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20api=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 279 ++++++++++++++++++ .../openapi/api/openapiLiveData.api.ts | 34 +++ .../openapi/api/openapiMinuteData.api.ts | 156 ++++++++++ .../openapi/api/openapiPeriodData.api.ts | 218 ++++++++++++++ .../scraper/openapi/api/openapiToken.api.ts | 112 +++++++ .../scraper/openapi/config/openapi.config.ts | 17 ++ .../scraper/openapi/openapi-scraper.module.ts | 43 +++ .../openapi/openapi-scraper.service.ts | 15 + .../openapi/type/openapiDetailData.type.ts | 214 ++++++++++++++ .../openapi/type/openapiLiveData.type.ts | 150 ++++++++++ .../openapi/type/openapiMinuteData.type.ts | 33 +++ .../scraper/openapi/type/openapiPeriodData.ts | 45 +++ .../scraper/openapi/type/openapiUtil.type.ts | 13 + .../openapi/util/openapiCustom.error.ts | 13 + .../scraper/openapi/util/openapiUtil.api.ts | 115 ++++++++ .../src/scraper/openapi/util/priorityQueue.ts | 99 +++++++ .../openapi/websocketClient.service.ts | 148 ++++++++++ 17 files changed, 1704 insertions(+) create mode 100644 packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiToken.api.ts create mode 100644 packages/backend/src/scraper/openapi/config/openapi.config.ts create mode 100644 packages/backend/src/scraper/openapi/openapi-scraper.module.ts create mode 100644 packages/backend/src/scraper/openapi/openapi-scraper.service.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiPeriodData.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiUtil.type.ts create mode 100644 packages/backend/src/scraper/openapi/util/openapiCustom.error.ts create mode 100644 packages/backend/src/scraper/openapi/util/openapiUtil.api.ts create mode 100644 packages/backend/src/scraper/openapi/util/priorityQueue.ts create mode 100644 packages/backend/src/scraper/openapi/websocketClient.service.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts new file mode 100644 index 00000000..9be8e3ea --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -0,0 +1,279 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Between, DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { + DetailDataQuery, + FinancialRatio, + isFinancialRatioData, + isProductDetail, + ProductDetail, + StockDetailQuery, +} from '../type/openapiDetailData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getOpenApi } from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { KospiStock } from '@/stock/domain/kospiStock.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockDaily } from '@/stock/domain/stockData.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; + +@Injectable() +export class OpenapiDetailData { + private readonly financialUrl: string = + '/uapi/domestic-stock/v1/finance/financial-ratio'; + private readonly productUrl: string = + '/uapi/domestic-stock/v1/quotations/search-stock-info'; + private readonly intervals = 1000; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + //setTimeout(() => this.getDetailData(), 5000); + } + + @Cron('0 8 * * 1-5') + async getDetailData() { + if (process.env.NODE_ENV !== 'production') return; + const entityManager = this.datasource.manager; + const stocks = await entityManager.find(Stock); + const configCount = openApiToken.configs.length; + const chunkSize = Math.ceil(stocks.length / configCount); + + for (let i = 0; i < configCount; i++) { + this.logger.info(openApiToken.configs[i]); + const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); + this.getDetailDataChunk(chunk, openApiToken.configs[i]); + } + } + + private async saveDetailData(stockDetail: StockDetail) { + const manager = this.datasource.manager; + const entity = StockDetail; + const existingStockDetail = await manager.findOne(entity, { + where: { + stock: { id: stockDetail.stock.id }, + }, + }); + if (existingStockDetail) { + manager.update( + entity, + { stock: { id: stockDetail.stock.id } }, + stockDetail, + ); + } else { + manager.save(entity, stockDetail); + } + } + + private async saveKospiData(stockDetail: KospiStock) { + const manager = this.datasource.manager; + const entity = KospiStock; + const existingStockDetail = await manager.findOne(entity, { + where: { + stock: { id: stockDetail.stock.id }, + }, + }); + + if (existingStockDetail) { + manager.update( + entity, + { stock: { id: stockDetail.stock.id } }, + stockDetail, + ); + } else { + manager.save(entity, stockDetail); + } + } + + private async calPer(eps: number): Promise { + if (eps <= 0) return NaN; + const manager = this.datasource.manager; + const latestResult = await manager.find(StockDaily, { + skip: 0, + take: 1, + order: { createdAt: 'desc' }, + }); + // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 + if (latestResult && latestResult[0] && latestResult[0].close) { + const currentPrice = latestResult[0].close; + const per = currentPrice / eps; + + if (isNaN(per)) return 0; + else return per; + } else { + return 0; + } + } + + private async calMarketCap(lstg: number) { + const manager = this.datasource.manager; + const latestResult = await manager.find(StockDaily, { + skip: 0, + take: 1, + order: { createdAt: 'desc' }, + }); + + // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 + if (latestResult && latestResult[0] && latestResult[0].close) { + const currentPrice = latestResult[0].close; + const marketCap = lstg * currentPrice; + + if (isNaN(marketCap)) return 0; + else return marketCap; + } else { + return 0; + } + } + + private async get52WeeksLowHigh() { + const manager = this.datasource.manager; + const nowDate = new Date(); + const weeksAgoDate = this.getDate52WeeksAgo(); + // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 + const output = await manager.find(StockDaily, { + select: ['low', 'high'], + where: { + startTime: Between(weeksAgoDate, nowDate), + }, + }); + const result = output.reduce((prev, cur) => { + if (prev.low > cur.low) prev.low = cur.low; + if (prev.high < cur.high) prev.high = cur.high; + return cur; + }, new StockDaily()); + let low = 0; + let high = 0; + if (result.low && !isNaN(result.low)) low = result.low; + if (result.high && !isNaN(result.high)) high = result.high; + return { low, high }; + } + + private async makeStockDetailObject( + output1: FinancialRatio, + output2: ProductDetail, + stockId: string, + ): Promise { + const result = new StockDetail(); + result.stock = { id: stockId } as Stock; + result.marketCap = + (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; + result.eps = parseInt(output1.eps); + const { low, high } = await this.get52WeeksLowHigh(); + result.low52w = low; + result.high52w = high; + const eps = parseInt(output1.eps); + if (isNaN(eps)) result.eps = 0; + else result.eps = eps; + const per = await this.calPer(eps); + if (isNaN(per)) result.per = 0; + else result.per = per; + result.updatedAt = new Date(); + return result; + } + + private async makeKospiStockObject(output: ProductDetail, stockId: string) { + const ret = new KospiStock(); + ret.isKospi = output.kospi200_item_yn === 'Y' ? true : false; + ret.stock = { id: stockId } as Stock; + return ret; + } + + private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { + const dataQuery = this.getDetailDataQuery(stock.id!); + // 여기서 가져올 건 eps -> eps와 per 계산하자. + try { + const response = await getOpenApi( + this.financialUrl, + conf, + dataQuery, + TR_IDS.FINANCIAL_DATA, + ); + if (response.output) { + const output1 = response.output; + return output1[0]; + } + } catch (error) { + this.logger.warn(error); + } + } + + private async getProductData(stock: Stock, conf: typeof openApiConfig) { + const defaultQuery = this.getFinancialDataQuery(stock.id!); + + // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 + try { + const response = await getOpenApi( + this.productUrl, + conf, + defaultQuery, + TR_IDS.PRODUCTION_DETAIL, + ); + if (response.output) { + const output2 = response.output; + return output2; + } + } catch (error) { + this.logger.warn(error); + } + } + + private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { + const output1 = await this.getFinancialRatio(stock, conf); + const output2 = await this.getProductData(stock, conf); + + this.logger.info(JSON.stringify(output1)); + this.logger.info(JSON.stringify(output2)); + if (isFinancialRatioData(output1) && isProductDetail(output2)) { + const stockDetail = await this.makeStockDetailObject( + output1, + output2, + stock.id!, + ); + this.saveDetailData(stockDetail); + const kospiStock = await this.makeKospiStockObject(output2, stock.id!); + this.saveKospiData(kospiStock); + + this.logger.info(`${stock.id!} is saved`); + } + } + + private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { + let delay = 0; + for await (const stock of chunk) { + setTimeout(() => this.getDetailDataDelay(stock, conf), delay); + delay += this.intervals; + } + } + + private getFinancialDataQuery( + stockId: string, + code: '300' | '301' | '302' | '306' = '300', + ): StockDetailQuery { + return { + pdno: stockId, + prdt_type_cd: code, + }; + } + + private getDetailDataQuery( + stockId: string, + divCode: 'J' = 'J', + classify: '0' | '1' = '0', + ): DetailDataQuery { + return { + fid_div_cls_code: classify, + fid_cond_mrkt_div_code: divCode, + fid_input_iscd: stockId, + }; + } + + private getDate52WeeksAgo(): Date { + const today = new Date(); + const weeksAgo = 52 * 7; + const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); + date52WeeksAgo.setHours(0, 0, 0, 0); + return date52WeeksAgo; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts new file mode 100644 index 00000000..9729e0fc --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -0,0 +1,34 @@ +import { DataSource } from 'typeorm'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +export class OpenapiLiveData { + public readonly TR_ID: string = 'H0STCNT0'; + constructor(private readonly datasource: DataSource) {} + + async saveLiveData(data: StockLiveData[]) { + await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .execute(); + } + + convertLiveData(messages: Record[]): StockLiveData[] { + const stockData: StockLiveData[] = []; + messages.map((message) => { + const stockLiveData = new StockLiveData(); + stockLiveData.currentPrice = parseFloat(message.STCK_PRPR); + stockLiveData.changeRate = parseFloat(message.PRDY_CTRT); + stockLiveData.volume = parseInt(message.CNTG_VOL); + stockLiveData.high = parseFloat(message.STCK_HGPR); + stockLiveData.low = parseFloat(message.STCK_LWPR); + stockLiveData.open = parseFloat(message.STCK_OPRC); + stockLiveData.previousClose = parseFloat(message.WGHN_AVRG_STCK_PRC); + stockLiveData.updatedAt = new Date(); + stockData.push(stockLiveData); + }); + return stockData; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts new file mode 100644 index 00000000..003e7560 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -0,0 +1,156 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; + +import { + isMinuteData, + MinuteData, + UpdateStockQuery, +} from '../type/openapiMinuteData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; + +const STOCK_CUT = 4; + +@Injectable() +export class OpenapiMinuteData { + private stock: Stock[][] = []; + private readonly entity = StockMinutely; + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; + private readonly intervals: number = 130; + private flip: number = 0; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + this.getStockData(); + } + + @Cron('0 1 * * 1-5') + async getStockData() { + if (process.env.NODE_ENV !== 'production') return; + const stock = await this.datasource.manager.findBy(Stock, { + isTrading: true, + }); + const stockSize = Math.ceil(stock.length / STOCK_CUT); + let i = 0; + this.stock = []; + while (i < STOCK_CUT) { + this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; + } + } + + private convertResToMinuteData( + stockId: string, + item: MinuteData, + time: string, + ) { + const stockPeriod = new StockData(); + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(item.stck_bsop_date.slice(0, 4)), + parseInt(item.stck_bsop_date.slice(4, 6)) - 1, + parseInt(item.stck_bsop_date.slice(6, 8)), + parseInt(time.slice(0, 2)), + parseInt(time.slice(2, 4)), + ); + stockPeriod.close = parseInt(item.stck_prpr); + stockPeriod.open = parseInt(item.stck_oprc); + stockPeriod.high = parseInt(item.stck_hgpr); + stockPeriod.low = parseInt(item.stck_lwpr); + stockPeriod.volume = parseInt(item.cntg_vol); + stockPeriod.createdAt = new Date(); + return stockPeriod; + } + + private isMarketOpenTime(time: string) { + const numberTime = parseInt(time); + return numberTime >= 90000 && numberTime <= 153000; + } + + private async saveMinuteData( + stockId: string, + item: MinuteData[], + time: string, + ) { + const manager = this.datasource.manager; + if (!this.isMarketOpenTime(time)) return; + const stockPeriod = item.map((val) => + this.convertResToMinuteData(stockId, val, time), + ); + manager.save(this.entity, stockPeriod); + } + + private async getMinuteDataInterval( + stockId: string, + time: string, + config: typeof openApiConfig, + ) { + const query = this.getUpdateStockQuery(stockId, time); + try { + const response = await getOpenApi( + this.url, + config, + query, + TR_IDS.MINUTE_DATA, + ); + let output; + if (response.output2) output = response.output2; + if (output && output[0] && isMinuteData(output[0])) { + this.saveMinuteData(stockId, output, time); + } + } catch (error) { + this.logger.warn(error); + } + } + + private async getMinuteDataChunk( + chunk: Stock[], + config: typeof openApiConfig, + ) { + const time = getCurrentTime(); + let interval = 0; + for await (const stock of chunk) { + setTimeout( + () => this.getMinuteDataInterval(stock.id!, time, config), + interval, + ); + interval += this.intervals; + } + } + + @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) + getMinuteData() { + if (process.env.NODE_ENV !== 'production') return; + const configCount = openApiToken.configs.length; + const stock = this.stock[this.flip % STOCK_CUT]; + this.flip++; + const chunkSize = Math.ceil(stock.length / configCount); + for (let i = 0; i < configCount; i++) { + const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); + this.getMinuteDataChunk(chunk, openApiToken.configs[i]); + } + } + + private getUpdateStockQuery( + stockId: string, + time: string, + isPastData: boolean = true, + marketCode: 'J' | 'W' = 'J', + ): UpdateStockQuery { + return { + fid_etc_cls_code: '', + fid_cond_mrkt_div_code: marketCode, + fid_input_iscd: stockId, + fid_input_hour_1: time, + fid_pw_data_incu_yn: isPastData ? 'Y' : 'N', + }; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts new file mode 100644 index 00000000..f4268088 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -0,0 +1,218 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { + ChartData, + isChartData, + ItemChartPriceQuery, + Period, +} from '../type/openapiPeriodData'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { + getOpenApi, + getPreviousDate, + getTodayDate, +} from '../util/openapiUtil.api'; +import { openApiToken } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { + StockData, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, +} from '@/stock/domain/stockData.entity'; + +const DATE_TO_ENTITY = { + D: StockDaily, + W: StockWeekly, + M: StockMonthly, + Y: StockYearly, +}; + +const DATE_TO_MONTH = { + D: 3, + W: 6, + M: 12, + Y: 24, +}; + +const INTERVALS = 4000; + +@Injectable() +export class OpenapiPeriodData { + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + //this.getItemChartPriceCheck(); + } + + @Cron('0 1 * * 1-5') + async getItemChartPriceCheck() { + if (process.env.NODE_ENV !== 'production') return; + const stocks = await this.datasource.manager.find(Stock, { + where: { + isTrading: true, + }, + }); + const configCount = openApiToken.configs.length; + const chunkSize = Math.ceil(stocks.length / configCount); + + for (let i = 0; i < configCount; i++) { + const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); + this.getChartData(chunk, 'D'); + setTimeout(() => this.getChartData(chunk, 'W'), INTERVALS); + setTimeout(() => this.getChartData(chunk, 'M'), INTERVALS * 2); + setTimeout(() => this.getChartData(chunk, 'Y'), INTERVALS * 3); + } + } + + private async getChartData(chunk: Stock[], period: Period) { + const baseTime = INTERVALS * 4; + const entity = DATE_TO_ENTITY[period]; + + let time = 0; + for (const stock of chunk) { + time += baseTime; + setTimeout(() => this.processStockData(stock, period, entity), time); + } + } + + private async processStockData( + stock: Stock, + period: Period, + entity: typeof StockData, + ) { + const stockPeriod = new StockData(); + const manager = this.datasource.manager; + let configIdx = 0; + let end = getTodayDate(); + let start = getPreviousDate(end, 3); + let isFail = false; + + while (!isFail) { + configIdx = (configIdx + 1) % openApiToken.configs.length; + this.setStockPeriod(stockPeriod, stock.id!, end); + + // chart 데이터가 있는 지 확인 -> 리턴 + if (await this.existsChartData(stockPeriod, manager, entity)) return; + + const query = this.getItemChartPriceQuery(stock.id!, start, end, period); + + const output = await this.fetchChartData(query, configIdx); + + if (output) { + await this.saveChartData(entity, stock.id!, output); + ({ endDate: end, startDate: start } = this.updateDates(start, period)); + } else isFail = true; + } + } + + private setStockPeriod( + stockPeriod: StockData, + stockId: string, + endDate: string, + ): void { + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(endDate.slice(0, 4)), + parseInt(endDate.slice(4, 6)) - 1, + parseInt(endDate.slice(6, 8)), + ); + } + + private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { + try { + const response = await getOpenApi( + this.url, + openApiToken.configs[configIdx], + query, + TR_IDS.ITEM_CHART_PRICE, + ); + return response.output2 as ChartData[]; + } catch (error) { + this.logger.warn(error); + } + } + + private updateDates( + startDate: string, + period: Period, + ): { endDate: string; startDate: string } { + const endDate = getPreviousDate(startDate, DATE_TO_MONTH[period]); + startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); + return { endDate, startDate }; + } + + private async existsChartData( + stock: StockData, + manager: EntityManager, + entity: typeof StockData, + ) { + return await manager.findOne(entity, { + where: { + stock: { id: stock.stock.id }, + createdAt: stock.startTime, + }, + }); + } + + private async insertChartData(stock: StockData, entity: typeof StockData) { + const manager = this.datasource.manager; + if (!(await this.existsChartData(stock, manager, entity))) { + await manager.save(entity, stock); + } + } + + private convertObjectToStockData(item: ChartData, stockId: string) { + const stockPeriod = new StockData(); + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(item.stck_bsop_date.slice(0, 4)), + parseInt(item.stck_bsop_date.slice(4, 6)) - 1, + parseInt(item.stck_bsop_date.slice(6, 8)), + ); + stockPeriod.close = parseInt(item.stck_clpr); + stockPeriod.open = parseInt(item.stck_oprc); + stockPeriod.high = parseInt(item.stck_hgpr); + stockPeriod.low = parseInt(item.stck_lwpr); + stockPeriod.volume = parseInt(item.acml_vol); + stockPeriod.createdAt = new Date(); + return stockPeriod; + } + + private async saveChartData( + entity: typeof StockData, + stockId: string, + data: ChartData[], + ) { + for (const item of data) { + if (!isChartData(item)) { + continue; + } + const stockPeriod = this.convertObjectToStockData(item, stockId); + await this.insertChartData(stockPeriod, entity); + } + } + + private getItemChartPriceQuery( + stockId: string, + startDate: string, + endDate: string, + period: Period, + marketCode: 'J' | 'W' = 'J', + ): ItemChartPriceQuery { + return { + fid_cond_mrkt_div_code: marketCode, + fid_input_iscd: stockId, + fid_input_date_1: startDate, + fid_input_date_2: endDate, + fid_period_div_code: period, + fid_org_adj_prc: 0, + }; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts new file mode 100644 index 00000000..6e6c88c5 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -0,0 +1,112 @@ +import { Inject } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { OpenapiException } from '../util/openapiCustom.error'; +import { postOpenApi } from '../util/openapiUtil.api'; +import { logger } from '@/configs/logger.config'; + +class OpenapiTokenApi { + private config: (typeof openApiConfig)[] = []; + constructor(@Inject('winston') private readonly logger: Logger) { + const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); + const api_keys = openApiConfig.STOCK_API_KEY!.split(','); + const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); + if ( + accounts.length === 0 || + accounts.length !== api_keys.length || + api_passwords.length !== api_keys.length + ) { + this.logger.warn('Open API Config Error'); + } + for (let i = 0; i < accounts.length; i++) { + this.config.push({ + STOCK_URL: openApiConfig.STOCK_URL, + STOCK_ACCOUNT: accounts[i], + STOCK_API_KEY: api_keys[i], + STOCK_API_PASSWORD: api_passwords[i], + }); + } + this.initAuthenValue(); + } + + get configs() { + //TODO : 현재 구조에서 받아올 때마다 확인후 할당으로 변경 + return this.config; + } + + private async initAuthenValue() { + const delay = 60000; + const delayMinute = delay / 1000 / 60; + + try { + await this.initAccessToken(); + await this.initWebSocketKey(); + } catch (error) { + if (error instanceof Error) { + this.logger.warn( + `Request failed: ${error.message}. Retrying in ${delayMinute} minute...`, + ); + } else { + this.logger.warn( + `Request failed. Retrying in ${delayMinute} minute...`, + ); + setTimeout(async () => { + await this.initAccessToken(); + await this.initWebSocketKey(); + }, delay); + } + } + } + + @Cron('50 0 * * 1-5') + async initAccessToken() { + const updatedConfig = await Promise.all( + this.config.map(async (val) => { + val.STOCK_API_TOKEN = await this.getToken(val)!; + return val; + }), + ); + this.config = updatedConfig; + } + + @Cron('50 0 * * 1-5') + async initWebSocketKey() { + const updatedConfig = await Promise.all( + this.config.map(async (val) => { + val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; + return val; + }), + ); + this.config = updatedConfig; + } + + private async getToken(config: typeof openApiConfig): Promise { + const body = { + grant_type: 'client_credentials', + appkey: config.STOCK_API_KEY, + appsecret: config.STOCK_API_PASSWORD, + }; + const tmp = await postOpenApi('/oauth2/tokenP', config, body); + if (!tmp.access_token) { + throw new OpenapiException('Access Token Failed', 403); + } + return tmp.access_token as string; + } + + private async getWebSocketKey(config: typeof openApiConfig): Promise { + const body = { + grant_type: 'client_credentials', + appkey: config.STOCK_API_KEY, + secretkey: config.STOCK_API_PASSWORD, + }; + const tmp = await postOpenApi('/oauth2/Approval', config, body); + if (!tmp.approval_key) { + throw new OpenapiException('WebSocket Key Failed', 403); + } + return tmp.approval_key as string; + } +} + +const openApiToken = new OpenapiTokenApi(logger); +export { openApiToken }; diff --git a/packages/backend/src/scraper/openapi/config/openapi.config.ts b/packages/backend/src/scraper/openapi/config/openapi.config.ts new file mode 100644 index 00000000..8aa12ea3 --- /dev/null +++ b/packages/backend/src/scraper/openapi/config/openapi.config.ts @@ -0,0 +1,17 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const openApiConfig: { + STOCK_URL: string | undefined; + STOCK_ACCOUNT: string | undefined; + STOCK_API_KEY: string | undefined; + STOCK_API_PASSWORD: string | undefined; + STOCK_API_TOKEN?: string; + STOCK_WEBSOCKET_KEY?: string; +} = { + STOCK_URL: process.env.STOCK_URL, + STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, + STOCK_API_KEY: process.env.STOCK_API_KEY, + STOCK_API_PASSWORD: process.env.STOCK_API_PASSWORD, +}; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts new file mode 100644 index 00000000..cb45c91c --- /dev/null +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { OpenapiMinuteData } from './api/openapiMinuteData.api'; +import { OpenapiPeriodData } from './api/openapiPeriodData.api'; +import { OpenapiScraperService } from './openapi-scraper.service'; +import { WebsocketClient } from './websocketClient.service'; +import { Stock } from '@/stock/domain/stock.entity'; +import { + StockDaily, + StockMinutely, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Stock, + StockMinutely, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, + StockLiveData, + StockDetail, + ]), + ], + controllers: [], + providers: [ + OpenapiPeriodData, + OpenapiMinuteData, + OpenapiDetailData, + OpenapiScraperService, + OpenapiLiveData, + WebsocketClient, + ], +}) +export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts new file mode 100644 index 00000000..52c90179 --- /dev/null +++ b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiMinuteData } from './api/openapiMinuteData.api'; +import { OpenapiPeriodData } from './api/openapiPeriodData.api'; + +@Injectable() +export class OpenapiScraperService { + public constructor( + private datasource: DataSource, + private readonly openapiPeriodData: OpenapiPeriodData, + private readonly openapiMinuteData: OpenapiMinuteData, + private readonly openapiDetailData: OpenapiDetailData, + ) {} +} diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts new file mode 100644 index 00000000..38015d48 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +/* eslint-disable max-lines-per-function */ + +export type DetailDataQuery = { + fid_cond_mrkt_div_code: 'J'; + fid_input_iscd: string; + fid_div_cls_code: '0' | '1'; +}; + +export type FinancialRatio = { + stac_yymm: string; // 결산 년월 + grs: string; // 매출액 증가율 + bsop_prfi_inrt: string; // 영업 이익 증가율 + ntin_inrt: string; // 순이익 증가율 + roe_val: string; // ROE 값 + eps: string; // EPS + sps: string; // 주당매출액 + bps: string; // BPS + rsrv_rate: string; // 유보 비율 + lblt_rate: string; // 부채 비율 +}; + +export function isFinancialRatioData(data: any): data is FinancialRatio { + return ( + data && + typeof data.stac_yymm === 'string' && + typeof data.grs === 'string' && + typeof data.bsop_prfi_inrt === 'string' && + typeof data.ntin_inrt === 'string' && + typeof data.roe_val === 'string' && + typeof data.eps === 'string' && + typeof data.sps === 'string' && + typeof data.bps === 'string' && + typeof data.rsrv_rate === 'string' && + typeof data.lblt_rate === 'string' + ); +} + +export type ProductDetail = { + pdno: string; // 상품번호 + prdt_type_cd: string; // 상품유형코드 + mket_id_cd: string; // 시장ID코드 + scty_grp_id_cd: string; // 증권그룹ID코드 + excg_dvsn_cd: string; // 거래소구분코드 + setl_mmdd: string; // 결산월일 + lstg_stqt: string; // 상장주수 - 이거 사용 + lstg_cptl_amt: string; // 상장자본금액 + cpta: string; // 자본금 + papr: string; // 액면가 + issu_pric: string; // 발행가격 + kospi200_item_yn: string; // 코스피200종목여부 - 이것도 사용 + scts_mket_lstg_dt: string; // 유가증권시장상장일자 + scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 + kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 + kosdaq_mket_lstg_abol_dt: string; // 코스닥시장상장폐지일자 + frbd_mket_lstg_dt: string; // 프리보드시장상장일자 + frbd_mket_lstg_abol_dt: string; // 프리보드시장상장폐지일자 + reits_kind_cd: string; // 리츠종류코드 + etf_dvsn_cd: string; // ETF구분코드 + oilf_fund_yn: string; // 유전펀드여부 + idx_bztp_lcls_cd: string; // 지수업종대분류코드 + idx_bztp_mcls_cd: string; // 지수업종중분류코드 + idx_bztp_scls_cd: string; // 지수업종소분류코드 + stck_kind_cd: string; // 주식종류코드 + mfnd_opng_dt: string; // 뮤추얼펀드개시일자 + mfnd_end_dt: string; // 뮤추얼펀드종료일자 + dpsi_erlm_cncl_dt: string; // 예탁등록취소일자 + etf_cu_qty: string; // ETFCU수량 + prdt_name: string; // 상품명 + prdt_name120: string; // 상품명120 + prdt_abrv_name: string; // 상품약어명 + std_pdno: string; // 표준상품번호 + prdt_eng_name: string; // 상품영문명 + prdt_eng_name120: string; // 상품영문명120 + prdt_eng_abrv_name: string; // 상품영문약어명 + dpsi_aptm_erlm_yn: string; // 예탁지정등록여부 + etf_txtn_type_cd: string; // ETF과세유형코드 + etf_type_cd: string; // ETF유형코드 + lstg_abol_dt: string; // 상장폐지일자 + nwst_odst_dvsn_cd: string; // 신주구주구분코드 + sbst_pric: string; // 대용가격 + thco_sbst_pric: string; // 당사대용가격 + thco_sbst_pric_chng_dt: string; // 당사대용가격변경일자 + tr_stop_yn: string; // 거래정지여부 + admn_item_yn: string; // 관리종목여부 + thdt_clpr: string; // 당일종가 + bfdy_clpr: string; // 전일종가 + clpr_chng_dt: string; // 종가변경일자 + std_idst_clsf_cd: string; // 표준산업분류코드 + std_idst_clsf_cd_name: string; // 표준산업분류코드명 + idx_bztp_lcls_cd_name: string; // 지수업종대분류코드명 + idx_bztp_mcls_cd_name: string; // 지수업종중분류코드명 + idx_bztp_scls_cd_name: string; // 지수업종소분류코드명 + ocr_no: string; // OCR번호 + crfd_item_yn: string; // 크라우드펀딩종목여부 + elec_scty_yn: string; // 전자증권여부 + issu_istt_cd: string; // 발행기관코드 + etf_chas_erng_rt_dbnb: string; // ETF추적수익율배수 + etf_etn_ivst_heed_item_yn: string; // ETFETN투자유의종목여부 + stln_int_rt_dvsn_cd: string; // 대주이자율구분코드 + frnr_psnl_lmt_rt: string; // 외국인개인한도비율 + lstg_rqsr_issu_istt_cd: string; // 상장신청인발행기관코드 + lstg_rqsr_item_cd: string; // 상장신청인종목코드 + trst_istt_issu_istt_cd: string; // 신탁기관발행기관코드 +}; + +export const isProductDetail = (data: any): data is ProductDetail => { + return ( + typeof data.pdno === 'string' && + typeof data.prdt_type_cd === 'string' && + typeof data.mket_id_cd === 'string' && + typeof data.scty_grp_id_cd === 'string' && + typeof data.excg_dvsn_cd === 'string' && + typeof data.setl_mmdd === 'string' && + typeof data.lstg_stqt === 'string' && + typeof data.lstg_cptl_amt === 'string' && + typeof data.cpta === 'string' && + typeof data.papr === 'string' && + typeof data.issu_pric === 'string' && + typeof data.kospi200_item_yn === 'string' && + typeof data.scts_mket_lstg_dt === 'string' && + typeof data.scts_mket_lstg_abol_dt === 'string' && + typeof data.kosdaq_mket_lstg_dt === 'string' && + typeof data.kosdaq_mket_lstg_abol_dt === 'string' && + typeof data.frbd_mket_lstg_dt === 'string' && + typeof data.frbd_mket_lstg_abol_dt === 'string' && + typeof data.reits_kind_cd === 'string' && + typeof data.etf_dvsn_cd === 'string' && + typeof data.oilf_fund_yn === 'string' && + typeof data.idx_bztp_lcls_cd === 'string' && + typeof data.idx_bztp_mcls_cd === 'string' && + typeof data.idx_bztp_scls_cd === 'string' && + typeof data.stck_kind_cd === 'string' && + typeof data.mfnd_opng_dt === 'string' && + typeof data.mfnd_end_dt === 'string' && + typeof data.dpsi_erlm_cncl_dt === 'string' && + typeof data.etf_cu_qty === 'string' && + typeof data.prdt_name === 'string' && + typeof data.prdt_name120 === 'string' && + typeof data.prdt_abrv_name === 'string' && + typeof data.std_pdno === 'string' && + typeof data.prdt_eng_name === 'string' && + typeof data.prdt_eng_name120 === 'string' && + typeof data.prdt_eng_abrv_name === 'string' && + typeof data.dpsi_aptm_erlm_yn === 'string' && + typeof data.etf_txtn_type_cd === 'string' && + typeof data.etf_type_cd === 'string' && + typeof data.lstg_abol_dt === 'string' && + typeof data.nwst_odst_dvsn_cd === 'string' && + typeof data.sbst_pric === 'string' && + typeof data.thco_sbst_pric === 'string' && + typeof data.thco_sbst_pric_chng_dt === 'string' && + typeof data.tr_stop_yn === 'string' && + typeof data.admn_item_yn === 'string' && + typeof data.thdt_clpr === 'string' && + typeof data.bfdy_clpr === 'string' && + typeof data.clpr_chng_dt === 'string' && + typeof data.std_idst_clsf_cd === 'string' && + typeof data.std_idst_clsf_cd_name === 'string' && + typeof data.idx_bztp_lcls_cd_name === 'string' && + typeof data.idx_bztp_mcls_cd_name === 'string' && + typeof data.idx_bztp_scls_cd_name === 'string' && + typeof data.ocr_no === 'string' && + typeof data.crfd_item_yn === 'string' && + typeof data.elec_scty_yn === 'string' && + typeof data.issu_istt_cd === 'string' && + typeof data.etf_chas_erng_rt_dbnb === 'string' && + typeof data.etf_etn_ivst_heed_item_yn === 'string' && + typeof data.stln_int_rt_dvsn_cd === 'string' && + typeof data.frnr_psnl_lmt_rt === 'string' && + typeof data.lstg_rqsr_issu_istt_cd === 'string' && + typeof data.lstg_rqsr_item_cd === 'string' && + typeof data.trst_istt_issu_istt_cd === 'string' + ); +}; + +export type StockDetailQuery = { + pdno: string; + prdt_type_cd: string; +}; + +//export type FinancialDetail = { +// stac_yymm: string; // 결산 년월 +// sale_account: string; // 매출액 +// sale_cost: string; // 매출원가 +// sale_totl_prfi: string; // 매출총이익 +// depr_cost: string; // 감가상각비 +// sell_mang: string; // 판매관리비 +// bsop_prti: string; // 영업이익 +// bsop_non_ernn: string; // 영업외수익 +// bsop_non_expn: string; // 영업외비용 +// op_prfi: string; // 영업이익 +// spec_prfi: string; // 특별이익 +// spec_loss: string; // 특별손실 +// thtr_ntin: string; // 세전순이익 +//}; + +//export const isFinancialDetail = (data: any): data is FinancialDetail => { +// return ( +// typeof data.stac_yymm === 'string' && +// typeof data.sale_account === 'string' && +// typeof data.sale_cost === 'string' && +// typeof data.sale_totl_prfi === 'string' && +// typeof data.depr_cost === 'string' && +// typeof data.sell_mang === 'string' && +// typeof data.bsop_prti === 'string' && +// typeof data.bsop_non_ernn === 'string' && +// typeof data.bsop_non_expn === 'string' && +// typeof data.op_prfi === 'string' && +// typeof data.spec_prfi === 'string' && +// typeof data.spec_loss === 'string' && +// typeof data.thtr_ntin === 'string' +// ); +//}; diff --git a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts new file mode 100644 index 00000000..e1687cee --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-lines-per-function */ + +export type StockData = { + MKSC_SHRN_ISCD: string; // 유가증권 단축 종목코드 + STCK_CNTG_HOUR: string; // 주식 체결 시간 + STCK_PRPR: string; // 주식 현재가 + PRDY_VRSS_SIGN: string; // 전일 대비 부호 + PRDY_VRSS: string; // 전일 대비 + PRDY_CTRT: string; // 전일 대비율 + WGHN_AVRG_STCK_PRC: string; // 가중 평균 주식 가격 + STCK_OPRC: string; // 주식 시가 + STCK_HGPR: string; // 주식 최고가 + STCK_LWPR: string; // 주식 최저가 + ASKP1: string; // 매도호가1 + BIDP1: string; // 매수호가1 + CNTG_VOL: string; // 체결 거래량 + ACML_VOL: string; // 누적 거래량 + ACML_TR_PBMN: string; // 누적 거래 대금 + SELN_CNTG_CSNU: string; // 매도 체결 건수 + SHNU_CNTG_CSNU: string; // 매수 체결 건수 + NTBY_CNTG_CSNU: string; // 순매수 체결 건수 + CTTR: string; // 체결강도 + SELN_CNTG_SMTN: string; // 총 매도 수량 + SHNU_CNTG_SMTN: string; // 총 매수 수량 + CCLD_DVSN: string; // 체결구분 + SHNU_RATE: string; // 매수비율 + PRDY_VOL_VRSS_ACML_VOL_RATE: string; // 전일 거래량 대비 등락율 + OPRC_HOUR: string; // 시가 시간 + OPRC_VRSS_PRPR_SIGN: string; // 시가대비구분 + OPRC_VRSS_PRPR: string; // 시가대비 + HGPR_HOUR: string; // 최고가 시간 + HGPR_VRSS_PRPR_SIGN: string; // 고가대비구분 + HGPR_VRSS_PRPR: string; // 고가대비 + LWPR_HOUR: string; // 최저가 시간 + LWPR_VRSS_PRPR_SIGN: string; // 저가대비구분 + LWPR_VRSS_PRPR: string; // 저가대비 + BSOP_DATE: string; // 영업 일자 + NEW_MKOP_CLS_CODE: string; // 신 장운영 구분 코드 + TRHT_YN: string; // 거래정지 여부 + ASKP_RSQN1: string; // 매도호가 잔량1 + BIDP_RSQN1: string; // 매수호가 잔량1 + TOTAL_ASKP_RSQN: string; // 총 매도호가 잔량 + TOTAL_BIDP_RSQN: string; // 총 매수호가 잔량 + VOL_TNRT: string; // 거래량 회전율 + PRDY_SMNS_HOUR_ACML_VOL: string; // 전일 동시간 누적 거래량 + PRDY_SMNS_HOUR_ACML_VOL_RATE: string; // 전일 동시간 누적 거래량 비율 + HOUR_CLS_CODE: string; // 시간 구분 코드 + MRKT_TRTM_CLS_CODE: string; // 임의종료구분코드 + VI_STND_PRC: string; // 정적VI발동기준가 +}; + +export type OpenApiMessage = { + header: { + approval_key: string; + custtype: string; + tr_type: string; + 'content-type': string; + }; + body: { + input: { + tr_id: string; + tr_key: string; + }; + }; +}; + +export type MessageResponse = { + header: { + tr_id: string; + tr_key: string; + encrypt: string; + }; + body: { + rt_cd: string; + msg_cd: string; + msg1: string; + output?: { + iv: string; + key: string; + }; + }; +}; + +export function isMessageResponse(data: any): data is MessageResponse { + return ( + typeof data === 'object' && + data !== null && + typeof data.header === 'object' && + data.header !== null && + typeof data.header.tr_id === 'object' && + typeof data.header.tr_key === 'object' && + typeof data.header.encrypt === 'object' && + typeof data.body === 'object' && + data.body !== null && + typeof data.body.rt_cd === 'object' && + typeof data.body.msg_cd === 'object' && + typeof data.body.msg1 === 'object' && + typeof data.body.output === 'object' + ); +} + +export const stockDataKeys = [ + 'MKSC_SHRN_ISCD', + 'STCK_CNTG_HOUR', + 'STCK_PRPR', + 'PRDY_VRSS_SIGN', + 'PRDY_VRSS', + 'PRDY_CTRT', + 'WGHN_AVRG_STCK_PRC', + 'STCK_OPRC', + 'STCK_HGPR', + 'STCK_LWPR', + 'ASKP1', + 'BIDP1', + 'CNTG_VOL', + 'ACML_VOL', + 'ACML_TR_PBMN', + 'SELN_CNTG_CSNU', + 'SHNU_CNTG_CSNU', + 'NTBY_CNTG_CSNU', + 'CTTR', + 'SELN_CNTG_SMTN', + 'SHNU_CNTG_SMTN', + 'CCLD_DVSN', + 'SHNU_RATE', + 'PRDY_VOL_VRSS_ACML_VOL_RATE', + 'OPRC_HOUR', + 'OPRC_VRSS_PRPR_SIGN', + 'OPRC_VRSS_PRPR', + 'HGPR_HOUR', + 'HGPR_VRSS_PRPR_SIGN', + 'HGPR_VRSS_PRPR', + 'LWPR_HOUR', + 'LWPR_VRSS_PRPR_SIGN', + 'LWPR_VRSS_PRPR', + 'BSOP_DATE', + 'NEW_MKOP_CLS_CODE', + 'TRHT_YN', + 'ASKP_RSQN1', + 'BIDP_RSQN1', + 'TOTAL_ASKP_RSQN', + 'TOTAL_BIDP_RSQN', + 'VOL_TNRT', + 'PRDY_SMNS_HOUR_ACML_VOL', + 'PRDY_SMNS_HOUR_ACML_VOL_RATE', + 'HOUR_CLS_CODE', + 'MRKT_TRTM_CLS_CODE', + 'VI_STND_PRC', +]; diff --git a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts new file mode 100644 index 00000000..5deb2d9e --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type MinuteData = { + stck_bsop_date: string; + stck_cntg_hour: string; + stck_prpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + cntg_vol: string; + acml_tr_pbmn: string; +}; + +export type UpdateStockQuery = { + fid_etc_cls_code: string; + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_hour_1: string; + fid_pw_data_incu_yn: 'Y' | 'N'; +}; + +export const isMinuteData = (data: any) => { + return ( + typeof data.stck_bsop_date === 'string' && + typeof data.stck_cntg_hour === 'string' && + typeof data.stck_prpr === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.cntg_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' + ); +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts new file mode 100644 index 00000000..e4066f7c --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type Period = 'D' | 'W' | 'M' | 'Y'; +export type ChartData = { + stck_bsop_date: string; + stck_clpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + acml_vol: string; + acml_tr_pbmn: string; + flng_cls_code: string; + prtt_rate: string; + mod_yn: string; + prdy_vrss_sign: string; + prdy_vrss: string; + revl_issu_reas: string; +}; + +export type ItemChartPriceQuery = { + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_date_1: string; + fid_input_date_2: string; + fid_period_div_code: Period; + fid_org_adj_prc: number; +}; + +export const isChartData = (data?: any) => { + return ( + data && + typeof data.stck_bsop_date === 'string' && + typeof data.stck_clpr === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.acml_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.flng_cls_code === 'string' && + typeof data.prtt_rate === 'string' && + typeof data.mod_yn === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_vrss === 'string' && + typeof data.revl_issu_reas === 'string' + ); +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts new file mode 100644 index 00000000..6df0ca19 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -0,0 +1,13 @@ +export type TR_ID = + | 'FHKST03010100' + | 'FHKST03010200' + | 'FHKST66430300' + | 'HHKDB669107C0' + | 'CTPF1002R'; + +export const TR_IDS: Record = { + ITEM_CHART_PRICE: 'FHKST03010100', + MINUTE_DATA: 'FHKST03010200', + FINANCIAL_DATA: 'FHKST66430300', + PRODUCTION_DETAIL: 'CTPF1002R', +}; diff --git a/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts new file mode 100644 index 00000000..1e0c3913 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts @@ -0,0 +1,13 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class OpenapiException extends HttpException { + private error: unknown; + constructor(message: string, status: HttpStatus, error?: unknown) { + super(message, status); + this.error = error; + } + + public getError() { + return this.error; + } +} diff --git a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts new file mode 100644 index 00000000..fa8f75b4 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +import * as crypto from 'crypto'; +import { HttpStatus } from '@nestjs/common'; +import axios from 'axios'; +import { openApiConfig } from '../config/openapi.config'; +import { TR_ID } from '../type/openapiUtil.type'; +import { OpenapiException } from './openapiCustom.error'; + +const throwOpenapiException = (error: any) => { + if (error.message && error.response && error.response.status) { + throw new OpenapiException( + `Request failed: ${error.message}`, + error.response.status, + error, + ); + } else { + throw new OpenapiException( + `Unknown error: ${error.message || 'No message'}`, + HttpStatus.INTERNAL_SERVER_ERROR, + error, + ); + } +}; + +const postOpenApi = async ( + url: string, + config: typeof openApiConfig, + body: object, +) => { + try { + const response = await axios.post(config.STOCK_URL + url, body); + return response.data; + } catch (error) { + throwOpenapiException(error); + } +}; + +const getOpenApi = async ( + url: string, + config: typeof openApiConfig, + query: object, + tr_id: TR_ID, +) => { + try { + const response = await axios.get(config.STOCK_URL + url, { + params: query, + headers: { + Authorization: `Bearer ${config.STOCK_API_TOKEN}`, + appkey: config.STOCK_API_KEY, + appsecret: config.STOCK_API_PASSWORD, + tr_id, + custtype: 'P', + }, + }); + return response.data; + } catch (error) { + throwOpenapiException(error); + } +}; + +const getTodayDate = (): string => { + const today = new Date(); + return today.toISOString().split('T')[0].replace(/-/g, ''); +}; + +const getPreviousDate = (date: string, months: number): string => { + const currentDate = new Date( + date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8), + ); + currentDate.setMonth(currentDate.getMonth() - months); + return currentDate.toISOString().split('T')[0].replace(/-/g, ''); +}; + +const getCurrentTime = () => { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${hours}${minutes}${seconds}`; +}; + +const decryptAES256 = ( + encryptedText: string, + key: string, + iv: string, +): string => { + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + Buffer.from(key, 'hex'), + Buffer.from(iv, 'hex'), + ); + let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +}; + +const bufferToObject = (buffer: Buffer): any => { + try { + const jsonString = buffer.toString('utf-8'); + return JSON.parse(jsonString); + } catch (error) { + console.error('Failed to convert buffer to object:', error); + throw error; + } +}; + +export { + postOpenApi, + getOpenApi, + getTodayDate, + getPreviousDate, + getCurrentTime, + decryptAES256, + bufferToObject, +}; diff --git a/packages/backend/src/scraper/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/openapi/util/priorityQueue.ts new file mode 100644 index 00000000..a49e5822 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/priorityQueue.ts @@ -0,0 +1,99 @@ +export class PriorityQueue { + private heap: { value: T; priority: number }[]; + + constructor() { + this.heap = []; + } + + private getParentIndex(index: number): number { + return Math.floor((index - 1) / 2); + } + + private getLeftChildIndex(index: number): number { + return index * 2 + 1; + } + + private getRightChildIndex(index: number): number { + return index * 2 + 2; + } + + private swap(i: number, j: number) { + [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; + } + + private heapifyUp() { + let index = this.heap.length - 1; + while ( + index > 0 && + this.heap[index].priority < this.heap[this.getParentIndex(index)].priority + ) { + this.swap(index, this.getParentIndex(index)); + index = this.getParentIndex(index); + } + } + + private heapifyDown() { + let index = 0; + while (this.getLeftChildIndex(index) < this.heap.length) { + let smallerChildIndex = this.getLeftChildIndex(index); + const rightChildIndex = this.getRightChildIndex(index); + + if ( + rightChildIndex < this.heap.length && + this.heap[rightChildIndex].priority < + this.heap[smallerChildIndex].priority + ) { + smallerChildIndex = rightChildIndex; + } + + if (this.heap[index].priority <= this.heap[smallerChildIndex].priority) { + break; + } + + this.swap(index, smallerChildIndex); + index = smallerChildIndex; + } + } + + enqueue(value: T, priority: number) { + this.heap.push({ value, priority }); + this.heapifyUp(); + } + + dequeue(): T | undefined { + if (this.isEmpty()) { + return undefined; + } + + const root = this.heap[0]; + const last = this.heap.pop(); + + if (this.heap.length > 0 && last) { + this.heap[0] = last; + this.heapifyDown(); + } + + return root.value; + } + + peek(): T | undefined { + return this.heap.length > 0 ? this.heap[0].value : undefined; + } + + isEmpty(): boolean { + return this.heap.length === 0; + } +} + +const pq = new PriorityQueue(); + +pq.enqueue('Task A', 2); +pq.enqueue('Task B', 1); +pq.enqueue('Task C', 3); + +console.log(pq.dequeue()); // Task B +console.log(pq.peek()); // Task A +console.log(pq.dequeue()); // Task A +console.log(pq.isEmpty()); // false +console.log(pq.dequeue()); // Task C +console.log(pq.isEmpty()); // true diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts new file mode 100644 index 00000000..2e36acfb --- /dev/null +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; +import { RawData, WebSocket } from 'ws'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { openApiToken } from './api/openapiToken.api'; +import { openApiConfig } from './config/openapi.config'; +import { parseMessage } from './parse/openapi.parser'; + +type TR_IDS = '0' | '1'; + +@Injectable() +export class WebsocketClient { + private client: WebSocket; + private readonly reconnectInterval = 60000; + private readonly url = + process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + private readonly clientStock: Set = new Set(); + + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly openapiLiveData: OpenapiLiveData, + ) { + if (process.env.NODE_ENV === 'production') { + this.connect(); + } + } + + // TODO : subscribe 구조로 리팩토링 + subscribe(stockId: string) { + this.clientStock.add(stockId); + // TODO : 하나의 config만 사용중. + const message = this.convertObjectToMessage( + openApiToken.configs[0], + stockId, + '1', + ); + this.sendMessage(message); + } + + discribe(stockId: string) { + this.clientStock.delete(stockId); + const message = this.convertObjectToMessage( + openApiToken.configs[0], + stockId, + '0', + ); + this.sendMessage(message); + } + + private initDisconnect() { + this.client.on('close', () => { + this.logger.warn( + `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, + ); + }); + + this.client.on('error', (error: any) => { + this.logger.error(`WebSocket error: ${error.message}`); + setTimeout(() => this.connect(), this.reconnectInterval); + }); + } + + private initOpen() { + this.client.on('open', () => { + this.logger.info('WebSocket connection established'); + for (const stockId of this.clientStock.keys()) { + const message = this.convertObjectToMessage( + openApiToken.configs[0], + stockId, + '1', + ); + this.sendMessage(message); + } + }); + } + + private initMessage() { + this.client.on('message', async (data) => { + try { + const message = this.parseMessage(data); + if (message.header) { + if (message.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${JSON.stringify(data)}`); + this.client.pong({ + tr_id: 'PINGPONG', + datetime: new Date().toISOString(), + }); + } + return; + } + this.logger.info(`Recived data : ${data}`); + const liveData = this.openapiLiveData.convertLiveData(message); + this.openapiLiveData.saveLiveData(liveData); + } catch (error) { + this.logger.warn(error); + } + }); + } + + private parseMessage(data: RawData) { + if (typeof data === 'object') { + return data; + } else { + return parseMessage(data as string); + } + } + + @Cron('0 2 * * 1-5') + connect() { + this.client = new WebSocket(this.url); + this.initOpen(); + this.initMessage(); + this.initDisconnect(); + } + + private convertObjectToMessage( + config: typeof openApiConfig, + stockId: string, + tr_type: TR_IDS, + ): string { + const message = { + header: { + approval_key: config.STOCK_WEBSOCKET_KEY!, + custtype: 'P', + tr_type, + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: 'H0STCNT0', + tr_key: stockId, + }, + }, + }; + return JSON.stringify(message); + } + + private sendMessage(message: string) { + if (this.client.readyState === WebSocket.OPEN) { + this.client.send(message); + this.logger.info(`Sent message: ${message}`); + } else { + this.logger.warn('WebSocket is not open. Message not sent.'); + } + } +} From f612b6ea4c41950982abb65b5d903da7c3625693 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 22:22:15 +0900 Subject: [PATCH 088/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20websocket=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0,=20import,=20DI=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 279 ------------------ .../openapi/api/openapiLiveData.api.ts | 36 --- .../openapi/api/openapiMinuteData.api.ts | 156 ---------- .../openapi/api/openapiPeriodData.api.ts | 218 -------------- .../openapi/api/openapiToken.api.ts | 112 ------- .../openapi/config/openapi.config.ts | 17 -- .../openapi/openapi-scraper.module.ts | 43 --- .../openapi/openapi-scraper.service.ts | 15 - .../openapi/parse/openapi.parser.spec.ts | 106 ------- .../openapi/parse/openapi.parser.ts | 38 --- .../openapi/type/openapiDetailData.type.ts | 214 -------------- .../openapi/type/openapiLiveData.type.ts | 150 ---------- .../openapi/type/openapiMinuteData.type.ts | 33 --- .../openapi/type/openapiPeriodData.ts | 45 --- .../openapi/type/openapiUtil.type.ts | 13 - .../openapi/util/openapiCustom.error.ts | 13 - .../openapi/util/openapiUtil.api.ts | 115 -------- .../openapi/util/priorityQueue.ts | 99 ------- .../openapi/websocketClient.service.ts | 148 ---------- .../openapi/api/openapiLiveData.api.ts | 2 + .../scraper/openapi/openapi-scraper.module.ts | 1 + .../openapi/websocketClient.service.ts | 6 +- 22 files changed, 6 insertions(+), 1853 deletions(-) delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts deleted file mode 100644 index 9be8e3ea..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiDetailData.api.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Between, DataSource } from 'typeorm'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; -import { - DetailDataQuery, - FinancialRatio, - isFinancialRatioData, - isProductDetail, - ProductDetail, - StockDetailQuery, -} from '../type/openapiDetailData.type'; -import { TR_IDS } from '../type/openapiUtil.type'; -import { getOpenApi } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { KospiStock } from '@/stock/domain/kospiStock.entity'; -import { Stock } from '@/stock/domain/stock.entity'; -import { StockDaily } from '@/stock/domain/stockData.entity'; -import { StockDetail } from '@/stock/domain/stockDetail.entity'; - -@Injectable() -export class OpenapiDetailData { - private readonly financialUrl: string = - '/uapi/domestic-stock/v1/finance/financial-ratio'; - private readonly productUrl: string = - '/uapi/domestic-stock/v1/quotations/search-stock-info'; - private readonly intervals = 1000; - constructor( - private readonly datasource: DataSource, - @Inject('winston') private readonly logger: Logger, - ) { - //setTimeout(() => this.getDetailData(), 5000); - } - - @Cron('0 8 * * 1-5') - async getDetailData() { - if (process.env.NODE_ENV !== 'production') return; - const entityManager = this.datasource.manager; - const stocks = await entityManager.find(Stock); - const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - this.logger.info(openApiToken.configs[i]); - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getDetailDataChunk(chunk, openApiToken.configs[i]); - } - } - - private async saveDetailData(stockDetail: StockDetail) { - const manager = this.datasource.manager; - const entity = StockDetail; - const existingStockDetail = await manager.findOne(entity, { - where: { - stock: { id: stockDetail.stock.id }, - }, - }); - if (existingStockDetail) { - manager.update( - entity, - { stock: { id: stockDetail.stock.id } }, - stockDetail, - ); - } else { - manager.save(entity, stockDetail); - } - } - - private async saveKospiData(stockDetail: KospiStock) { - const manager = this.datasource.manager; - const entity = KospiStock; - const existingStockDetail = await manager.findOne(entity, { - where: { - stock: { id: stockDetail.stock.id }, - }, - }); - - if (existingStockDetail) { - manager.update( - entity, - { stock: { id: stockDetail.stock.id } }, - stockDetail, - ); - } else { - manager.save(entity, stockDetail); - } - } - - private async calPer(eps: number): Promise { - if (eps <= 0) return NaN; - const manager = this.datasource.manager; - const latestResult = await manager.find(StockDaily, { - skip: 0, - take: 1, - order: { createdAt: 'desc' }, - }); - // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 - if (latestResult && latestResult[0] && latestResult[0].close) { - const currentPrice = latestResult[0].close; - const per = currentPrice / eps; - - if (isNaN(per)) return 0; - else return per; - } else { - return 0; - } - } - - private async calMarketCap(lstg: number) { - const manager = this.datasource.manager; - const latestResult = await manager.find(StockDaily, { - skip: 0, - take: 1, - order: { createdAt: 'desc' }, - }); - - // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 - if (latestResult && latestResult[0] && latestResult[0].close) { - const currentPrice = latestResult[0].close; - const marketCap = lstg * currentPrice; - - if (isNaN(marketCap)) return 0; - else return marketCap; - } else { - return 0; - } - } - - private async get52WeeksLowHigh() { - const manager = this.datasource.manager; - const nowDate = new Date(); - const weeksAgoDate = this.getDate52WeeksAgo(); - // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 - const output = await manager.find(StockDaily, { - select: ['low', 'high'], - where: { - startTime: Between(weeksAgoDate, nowDate), - }, - }); - const result = output.reduce((prev, cur) => { - if (prev.low > cur.low) prev.low = cur.low; - if (prev.high < cur.high) prev.high = cur.high; - return cur; - }, new StockDaily()); - let low = 0; - let high = 0; - if (result.low && !isNaN(result.low)) low = result.low; - if (result.high && !isNaN(result.high)) high = result.high; - return { low, high }; - } - - private async makeStockDetailObject( - output1: FinancialRatio, - output2: ProductDetail, - stockId: string, - ): Promise { - const result = new StockDetail(); - result.stock = { id: stockId } as Stock; - result.marketCap = - (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; - result.eps = parseInt(output1.eps); - const { low, high } = await this.get52WeeksLowHigh(); - result.low52w = low; - result.high52w = high; - const eps = parseInt(output1.eps); - if (isNaN(eps)) result.eps = 0; - else result.eps = eps; - const per = await this.calPer(eps); - if (isNaN(per)) result.per = 0; - else result.per = per; - result.updatedAt = new Date(); - return result; - } - - private async makeKospiStockObject(output: ProductDetail, stockId: string) { - const ret = new KospiStock(); - ret.isKospi = output.kospi200_item_yn === 'Y' ? true : false; - ret.stock = { id: stockId } as Stock; - return ret; - } - - private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { - const dataQuery = this.getDetailDataQuery(stock.id!); - // 여기서 가져올 건 eps -> eps와 per 계산하자. - try { - const response = await getOpenApi( - this.financialUrl, - conf, - dataQuery, - TR_IDS.FINANCIAL_DATA, - ); - if (response.output) { - const output1 = response.output; - return output1[0]; - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getProductData(stock: Stock, conf: typeof openApiConfig) { - const defaultQuery = this.getFinancialDataQuery(stock.id!); - - // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 - try { - const response = await getOpenApi( - this.productUrl, - conf, - defaultQuery, - TR_IDS.PRODUCTION_DETAIL, - ); - if (response.output) { - const output2 = response.output; - return output2; - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { - const output1 = await this.getFinancialRatio(stock, conf); - const output2 = await this.getProductData(stock, conf); - - this.logger.info(JSON.stringify(output1)); - this.logger.info(JSON.stringify(output2)); - if (isFinancialRatioData(output1) && isProductDetail(output2)) { - const stockDetail = await this.makeStockDetailObject( - output1, - output2, - stock.id!, - ); - this.saveDetailData(stockDetail); - const kospiStock = await this.makeKospiStockObject(output2, stock.id!); - this.saveKospiData(kospiStock); - - this.logger.info(`${stock.id!} is saved`); - } - } - - private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { - let delay = 0; - for await (const stock of chunk) { - setTimeout(() => this.getDetailDataDelay(stock, conf), delay); - delay += this.intervals; - } - } - - private getFinancialDataQuery( - stockId: string, - code: '300' | '301' | '302' | '306' = '300', - ): StockDetailQuery { - return { - pdno: stockId, - prdt_type_cd: code, - }; - } - - private getDetailDataQuery( - stockId: string, - divCode: 'J' = 'J', - classify: '0' | '1' = '0', - ): DetailDataQuery { - return { - fid_div_cls_code: classify, - fid_cond_mrkt_div_code: divCode, - fid_input_iscd: stockId, - }; - } - - private getDate52WeeksAgo(): Date { - const today = new Date(); - const weeksAgo = 52 * 7; - const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); - date52WeeksAgo.setHours(0, 0, 0, 0); - return date52WeeksAgo; - } -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts deleted file mode 100644 index 410ab4cd..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiLiveData.api.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DataSource } from 'typeorm'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; - -export class OpenapiLiveData { - public readonly TR_ID: string = 'H0STCNT0'; - constructor( - private readonly datasource: DataSource, - ) {} - - async saveLiveData(data: StockLiveData[]) { - await this.datasource.manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .execute(); - } - - convertLiveData(messages: Record[]) : StockLiveData[] { - const stockData: StockLiveData[] = []; - messages.map((message) => { - const stockLiveData = new StockLiveData(); - stockLiveData.currentPrice = parseFloat(message.STCK_PRPR); - stockLiveData.changeRate = parseFloat(message.PRDY_CTRT); - stockLiveData.volume = parseInt(message.CNTG_VOL); - stockLiveData.high = parseFloat(message.STCK_HGPR); - stockLiveData.low = parseFloat(message.STCK_LWPR); - stockLiveData.open = parseFloat(message.STCK_OPRC); - stockLiveData.previousClose = parseFloat(message.WGHN_AVRG_STCK_PRC); - stockLiveData.updatedAt = new Date(); - stockData.push(stockLiveData); - }); - return stockData; - } -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts deleted file mode 100644 index 003e7560..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiMinuteData.api.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; - -import { - isMinuteData, - MinuteData, - UpdateStockQuery, -} from '../type/openapiMinuteData.type'; -import { TR_IDS } from '../type/openapiUtil.type'; -import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { Stock } from '@/stock/domain/stock.entity'; -import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; - -const STOCK_CUT = 4; - -@Injectable() -export class OpenapiMinuteData { - private stock: Stock[][] = []; - private readonly entity = StockMinutely; - private readonly url: string = - '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - private readonly intervals: number = 130; - private flip: number = 0; - constructor( - private readonly datasource: DataSource, - @Inject('winston') private readonly logger: Logger, - ) { - this.getStockData(); - } - - @Cron('0 1 * * 1-5') - async getStockData() { - if (process.env.NODE_ENV !== 'production') return; - const stock = await this.datasource.manager.findBy(Stock, { - isTrading: true, - }); - const stockSize = Math.ceil(stock.length / STOCK_CUT); - let i = 0; - this.stock = []; - while (i < STOCK_CUT) { - this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); - i++; - } - } - - private convertResToMinuteData( - stockId: string, - item: MinuteData, - time: string, - ) { - const stockPeriod = new StockData(); - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(item.stck_bsop_date.slice(0, 4)), - parseInt(item.stck_bsop_date.slice(4, 6)) - 1, - parseInt(item.stck_bsop_date.slice(6, 8)), - parseInt(time.slice(0, 2)), - parseInt(time.slice(2, 4)), - ); - stockPeriod.close = parseInt(item.stck_prpr); - stockPeriod.open = parseInt(item.stck_oprc); - stockPeriod.high = parseInt(item.stck_hgpr); - stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.cntg_vol); - stockPeriod.createdAt = new Date(); - return stockPeriod; - } - - private isMarketOpenTime(time: string) { - const numberTime = parseInt(time); - return numberTime >= 90000 && numberTime <= 153000; - } - - private async saveMinuteData( - stockId: string, - item: MinuteData[], - time: string, - ) { - const manager = this.datasource.manager; - if (!this.isMarketOpenTime(time)) return; - const stockPeriod = item.map((val) => - this.convertResToMinuteData(stockId, val, time), - ); - manager.save(this.entity, stockPeriod); - } - - private async getMinuteDataInterval( - stockId: string, - time: string, - config: typeof openApiConfig, - ) { - const query = this.getUpdateStockQuery(stockId, time); - try { - const response = await getOpenApi( - this.url, - config, - query, - TR_IDS.MINUTE_DATA, - ); - let output; - if (response.output2) output = response.output2; - if (output && output[0] && isMinuteData(output[0])) { - this.saveMinuteData(stockId, output, time); - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getMinuteDataChunk( - chunk: Stock[], - config: typeof openApiConfig, - ) { - const time = getCurrentTime(); - let interval = 0; - for await (const stock of chunk) { - setTimeout( - () => this.getMinuteDataInterval(stock.id!, time, config), - interval, - ); - interval += this.intervals; - } - } - - @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) - getMinuteData() { - if (process.env.NODE_ENV !== 'production') return; - const configCount = openApiToken.configs.length; - const stock = this.stock[this.flip % STOCK_CUT]; - this.flip++; - const chunkSize = Math.ceil(stock.length / configCount); - for (let i = 0; i < configCount; i++) { - const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, openApiToken.configs[i]); - } - } - - private getUpdateStockQuery( - stockId: string, - time: string, - isPastData: boolean = true, - marketCode: 'J' | 'W' = 'J', - ): UpdateStockQuery { - return { - fid_etc_cls_code: '', - fid_cond_mrkt_div_code: marketCode, - fid_input_iscd: stockId, - fid_input_hour_1: time, - fid_pw_data_incu_yn: isPastData ? 'Y' : 'N', - }; - } -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts deleted file mode 100644 index f4268088..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiPeriodData.api.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { DataSource, EntityManager } from 'typeorm'; -import { Logger } from 'winston'; -import { - ChartData, - isChartData, - ItemChartPriceQuery, - Period, -} from '../type/openapiPeriodData'; -import { TR_IDS } from '../type/openapiUtil.type'; -import { - getOpenApi, - getPreviousDate, - getTodayDate, -} from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; -import { Stock } from '@/stock/domain/stock.entity'; -import { - StockData, - StockDaily, - StockWeekly, - StockMonthly, - StockYearly, -} from '@/stock/domain/stockData.entity'; - -const DATE_TO_ENTITY = { - D: StockDaily, - W: StockWeekly, - M: StockMonthly, - Y: StockYearly, -}; - -const DATE_TO_MONTH = { - D: 3, - W: 6, - M: 12, - Y: 24, -}; - -const INTERVALS = 4000; - -@Injectable() -export class OpenapiPeriodData { - private readonly url: string = - '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; - constructor( - private readonly datasource: DataSource, - @Inject('winston') private readonly logger: Logger, - ) { - //this.getItemChartPriceCheck(); - } - - @Cron('0 1 * * 1-5') - async getItemChartPriceCheck() { - if (process.env.NODE_ENV !== 'production') return; - const stocks = await this.datasource.manager.find(Stock, { - where: { - isTrading: true, - }, - }); - const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getChartData(chunk, 'D'); - setTimeout(() => this.getChartData(chunk, 'W'), INTERVALS); - setTimeout(() => this.getChartData(chunk, 'M'), INTERVALS * 2); - setTimeout(() => this.getChartData(chunk, 'Y'), INTERVALS * 3); - } - } - - private async getChartData(chunk: Stock[], period: Period) { - const baseTime = INTERVALS * 4; - const entity = DATE_TO_ENTITY[period]; - - let time = 0; - for (const stock of chunk) { - time += baseTime; - setTimeout(() => this.processStockData(stock, period, entity), time); - } - } - - private async processStockData( - stock: Stock, - period: Period, - entity: typeof StockData, - ) { - const stockPeriod = new StockData(); - const manager = this.datasource.manager; - let configIdx = 0; - let end = getTodayDate(); - let start = getPreviousDate(end, 3); - let isFail = false; - - while (!isFail) { - configIdx = (configIdx + 1) % openApiToken.configs.length; - this.setStockPeriod(stockPeriod, stock.id!, end); - - // chart 데이터가 있는 지 확인 -> 리턴 - if (await this.existsChartData(stockPeriod, manager, entity)) return; - - const query = this.getItemChartPriceQuery(stock.id!, start, end, period); - - const output = await this.fetchChartData(query, configIdx); - - if (output) { - await this.saveChartData(entity, stock.id!, output); - ({ endDate: end, startDate: start } = this.updateDates(start, period)); - } else isFail = true; - } - } - - private setStockPeriod( - stockPeriod: StockData, - stockId: string, - endDate: string, - ): void { - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(endDate.slice(0, 4)), - parseInt(endDate.slice(4, 6)) - 1, - parseInt(endDate.slice(6, 8)), - ); - } - - private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { - try { - const response = await getOpenApi( - this.url, - openApiToken.configs[configIdx], - query, - TR_IDS.ITEM_CHART_PRICE, - ); - return response.output2 as ChartData[]; - } catch (error) { - this.logger.warn(error); - } - } - - private updateDates( - startDate: string, - period: Period, - ): { endDate: string; startDate: string } { - const endDate = getPreviousDate(startDate, DATE_TO_MONTH[period]); - startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); - return { endDate, startDate }; - } - - private async existsChartData( - stock: StockData, - manager: EntityManager, - entity: typeof StockData, - ) { - return await manager.findOne(entity, { - where: { - stock: { id: stock.stock.id }, - createdAt: stock.startTime, - }, - }); - } - - private async insertChartData(stock: StockData, entity: typeof StockData) { - const manager = this.datasource.manager; - if (!(await this.existsChartData(stock, manager, entity))) { - await manager.save(entity, stock); - } - } - - private convertObjectToStockData(item: ChartData, stockId: string) { - const stockPeriod = new StockData(); - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(item.stck_bsop_date.slice(0, 4)), - parseInt(item.stck_bsop_date.slice(4, 6)) - 1, - parseInt(item.stck_bsop_date.slice(6, 8)), - ); - stockPeriod.close = parseInt(item.stck_clpr); - stockPeriod.open = parseInt(item.stck_oprc); - stockPeriod.high = parseInt(item.stck_hgpr); - stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.acml_vol); - stockPeriod.createdAt = new Date(); - return stockPeriod; - } - - private async saveChartData( - entity: typeof StockData, - stockId: string, - data: ChartData[], - ) { - for (const item of data) { - if (!isChartData(item)) { - continue; - } - const stockPeriod = this.convertObjectToStockData(item, stockId); - await this.insertChartData(stockPeriod, entity); - } - } - - private getItemChartPriceQuery( - stockId: string, - startDate: string, - endDate: string, - period: Period, - marketCode: 'J' | 'W' = 'J', - ): ItemChartPriceQuery { - return { - fid_cond_mrkt_div_code: marketCode, - fid_input_iscd: stockId, - fid_input_date_1: startDate, - fid_input_date_2: endDate, - fid_period_div_code: period, - fid_org_adj_prc: 0, - }; - } -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts deleted file mode 100644 index 6e6c88c5..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/api/openapiToken.api.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; -import { OpenapiException } from '../util/openapiCustom.error'; -import { postOpenApi } from '../util/openapiUtil.api'; -import { logger } from '@/configs/logger.config'; - -class OpenapiTokenApi { - private config: (typeof openApiConfig)[] = []; - constructor(@Inject('winston') private readonly logger: Logger) { - const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); - const api_keys = openApiConfig.STOCK_API_KEY!.split(','); - const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); - if ( - accounts.length === 0 || - accounts.length !== api_keys.length || - api_passwords.length !== api_keys.length - ) { - this.logger.warn('Open API Config Error'); - } - for (let i = 0; i < accounts.length; i++) { - this.config.push({ - STOCK_URL: openApiConfig.STOCK_URL, - STOCK_ACCOUNT: accounts[i], - STOCK_API_KEY: api_keys[i], - STOCK_API_PASSWORD: api_passwords[i], - }); - } - this.initAuthenValue(); - } - - get configs() { - //TODO : 현재 구조에서 받아올 때마다 확인후 할당으로 변경 - return this.config; - } - - private async initAuthenValue() { - const delay = 60000; - const delayMinute = delay / 1000 / 60; - - try { - await this.initAccessToken(); - await this.initWebSocketKey(); - } catch (error) { - if (error instanceof Error) { - this.logger.warn( - `Request failed: ${error.message}. Retrying in ${delayMinute} minute...`, - ); - } else { - this.logger.warn( - `Request failed. Retrying in ${delayMinute} minute...`, - ); - setTimeout(async () => { - await this.initAccessToken(); - await this.initWebSocketKey(); - }, delay); - } - } - } - - @Cron('50 0 * * 1-5') - async initAccessToken() { - const updatedConfig = await Promise.all( - this.config.map(async (val) => { - val.STOCK_API_TOKEN = await this.getToken(val)!; - return val; - }), - ); - this.config = updatedConfig; - } - - @Cron('50 0 * * 1-5') - async initWebSocketKey() { - const updatedConfig = await Promise.all( - this.config.map(async (val) => { - val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; - return val; - }), - ); - this.config = updatedConfig; - } - - private async getToken(config: typeof openApiConfig): Promise { - const body = { - grant_type: 'client_credentials', - appkey: config.STOCK_API_KEY, - appsecret: config.STOCK_API_PASSWORD, - }; - const tmp = await postOpenApi('/oauth2/tokenP', config, body); - if (!tmp.access_token) { - throw new OpenapiException('Access Token Failed', 403); - } - return tmp.access_token as string; - } - - private async getWebSocketKey(config: typeof openApiConfig): Promise { - const body = { - grant_type: 'client_credentials', - appkey: config.STOCK_API_KEY, - secretkey: config.STOCK_API_PASSWORD, - }; - const tmp = await postOpenApi('/oauth2/Approval', config, body); - if (!tmp.approval_key) { - throw new OpenapiException('WebSocket Key Failed', 403); - } - return tmp.approval_key as string; - } -} - -const openApiToken = new OpenapiTokenApi(logger); -export { openApiToken }; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts b/packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts deleted file mode 100644 index 8aa12ea3..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/config/openapi.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as dotenv from 'dotenv'; - -dotenv.config(); - -export const openApiConfig: { - STOCK_URL: string | undefined; - STOCK_ACCOUNT: string | undefined; - STOCK_API_KEY: string | undefined; - STOCK_API_PASSWORD: string | undefined; - STOCK_API_TOKEN?: string; - STOCK_WEBSOCKET_KEY?: string; -} = { - STOCK_URL: process.env.STOCK_URL, - STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, - STOCK_API_KEY: process.env.STOCK_API_KEY, - STOCK_API_PASSWORD: process.env.STOCK_API_PASSWORD, -}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts deleted file mode 100644 index cb45c91c..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.module.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { OpenapiDetailData } from './api/openapiDetailData.api'; -import { OpenapiLiveData } from './api/openapiLiveData.api'; -import { OpenapiMinuteData } from './api/openapiMinuteData.api'; -import { OpenapiPeriodData } from './api/openapiPeriodData.api'; -import { OpenapiScraperService } from './openapi-scraper.service'; -import { WebsocketClient } from './websocketClient.service'; -import { Stock } from '@/stock/domain/stock.entity'; -import { - StockDaily, - StockMinutely, - StockMonthly, - StockWeekly, - StockYearly, -} from '@/stock/domain/stockData.entity'; -import { StockDetail } from '@/stock/domain/stockDetail.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([ - Stock, - StockMinutely, - StockDaily, - StockWeekly, - StockMonthly, - StockYearly, - StockLiveData, - StockDetail, - ]), - ], - controllers: [], - providers: [ - OpenapiPeriodData, - OpenapiMinuteData, - OpenapiDetailData, - OpenapiScraperService, - OpenapiLiveData, - WebsocketClient, - ], -}) -export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts deleted file mode 100644 index 52c90179..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/openapi-scraper.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { OpenapiDetailData } from './api/openapiDetailData.api'; -import { OpenapiMinuteData } from './api/openapiMinuteData.api'; -import { OpenapiPeriodData } from './api/openapiPeriodData.api'; - -@Injectable() -export class OpenapiScraperService { - public constructor( - private datasource: DataSource, - private readonly openapiPeriodData: OpenapiPeriodData, - private readonly openapiMinuteData: OpenapiMinuteData, - private readonly openapiDetailData: OpenapiDetailData, - ) {} -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts b/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts deleted file mode 100644 index 37d3deb6..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint-disable max-lines-per-function */ -import { parseMessage } from './openapi.parser'; - -const answer = [ - { - STOCK_ID: '005930', - MKSC_SHRN_ISCD: 5930, - STCK_CNTG_HOUR: 93354, - STCK_PRPR: 71900, - PRDY_VRSS_SIGN: 5, - PRDY_VRSS: -100, - PRDY_CTRT: -0.14, - WGHN_AVRG_STCK_PRC: 72023.83, - STCK_OPRC: 72100, - STCK_HGPR: 72400, - STCK_LWPR: 71700, - ASKP1: 71900, - BIDP1: 71800, - CNTG_VOL: 1, - ACML_VOL: 3052507, - ACML_TR_PBMN: 219853241700, - SELN_CNTG_CSNU: 5105, - SHNU_CNTG_CSNU: 6937, - NTBY_CNTG_CSNU: 1832, - CTTR: 84.9, - SELN_CNTG_SMTN: 1366314, - SHNU_CNTG_SMTN: 1159996, - CCLD_DVSN: 1, - SHNU_RATE: 0.39, - PRDY_VOL_VRSS_ACML_VOL_RATE: 20.28, - OPRC_HOUR: 90020, - OPRC_VRSS_PRPR_SIGN: 5, - OPRC_VRSS_PRPR: -200, - HGPR_HOUR: 90820, - HGPR_VRSS_PRPR_SIGN: 5, - HGPR_VRSS_PRPR: -500, - LWPR_HOUR: 92619, - LWPR_VRSS_PRPR_SIGN: 2, - LWPR_VRSS_PRPR: 200, - BSOP_DATE: 20230612, - NEW_MKOP_CLS_CODE: 20, - TRHT_YN: 'N', - ASKP_RSQN1: 65945, - BIDP_RSQN1: 216924, - TOTAL_ASKP_RSQN: 1118750, - TOTAL_BIDP_RSQN: 2199206, - VOL_TNRT: 0.05, - PRDY_SMNS_HOUR_ACML_VOL: 2424142, - PRDY_SMNS_HOUR_ACML_VOL_RATE: 125.92, - HOUR_CLS_CODE: 0, - MRKT_TRTM_CLS_CODE: null, - VI_STND_PRC: 72100, - }, -]; - -describe('openapi parser test', () => { - test('parse json websocket data', () => { - const message = `{ - "header": { - "tr_id": "H0STCNT0", - "tr_key": "005930", - "encrypt": "N" - }, - "body": { - "rt_cd": "0", - "msg_cd": "OPSP0000", - "msg1": "SUBSCRIBE SUCCESS", - "output": { - "iv": "0123456789abcdef", - "key": "abcdefghijklmnopabcdefghijklmnop"} - } - }`; - - const result = parseMessage(message); - - expect(result).toEqual(JSON.parse(message)); - }); - - test('parse stockData', () => { - const message = - '0|H0STCNT0|001|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + - '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + - '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + - '2424142^125.92^0^^72100'; - - const result = parseMessage(message); - - expect(result).toEqual(answer); - }); - - test('parse stockData', () => { - const message = - '0|H0STCNT0|002|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + - '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + - '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + - '2424142^125.92^0^^72100^' + - '005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + - '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + - '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + - '2424142^125.92^0^^72100'; - - const result = parseMessage(message); - - expect(result).toEqual([answer[0], answer[0]]); - }); -}); diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts b/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts deleted file mode 100644 index fe3e7002..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/parse/openapi.parser.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { stockDataKeys } from '../type/openapiLiveData.type'; - -export const parseMessage = (data: string) => { - try { - return JSON.parse(data); - //eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - return parseStockData(data); - } -}; -const FIELD_LENGTH: number = stockDataKeys.length; - -const parseStockData = (input: string) => { - const dataBlocks = input.split('|'); // 데이터 구분 - const results = []; - const size = parseInt(dataBlocks[2]); // 데이터 건수 - const rawData = dataBlocks[3]; - const values = rawData.split('^'); // 필드 구분자 '^' - - for (let i = 0; i < size; i++) { - //TODO : type narrowing require - const parsedData: Record = {}; - parsedData['STOCK_ID'] = values[i * FIELD_LENGTH]; - stockDataKeys.forEach((field: string, index: number) => { - const value = values[index + FIELD_LENGTH * i]; - if (!value) return (parsedData[field] = null); - - // 숫자형 필드 처리 - if (isNaN(parseInt(value))) { - parsedData[field] = value; // 문자열 그대로 저장 - } else { - parsedData[field] = parseFloat(value); // 숫자로 변환 - } - }); - results.push(parsedData); - } - return results; -}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts deleted file mode 100644 index 38015d48..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiDetailData.type.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any*/ -/* eslint-disable max-lines-per-function */ - -export type DetailDataQuery = { - fid_cond_mrkt_div_code: 'J'; - fid_input_iscd: string; - fid_div_cls_code: '0' | '1'; -}; - -export type FinancialRatio = { - stac_yymm: string; // 결산 년월 - grs: string; // 매출액 증가율 - bsop_prfi_inrt: string; // 영업 이익 증가율 - ntin_inrt: string; // 순이익 증가율 - roe_val: string; // ROE 값 - eps: string; // EPS - sps: string; // 주당매출액 - bps: string; // BPS - rsrv_rate: string; // 유보 비율 - lblt_rate: string; // 부채 비율 -}; - -export function isFinancialRatioData(data: any): data is FinancialRatio { - return ( - data && - typeof data.stac_yymm === 'string' && - typeof data.grs === 'string' && - typeof data.bsop_prfi_inrt === 'string' && - typeof data.ntin_inrt === 'string' && - typeof data.roe_val === 'string' && - typeof data.eps === 'string' && - typeof data.sps === 'string' && - typeof data.bps === 'string' && - typeof data.rsrv_rate === 'string' && - typeof data.lblt_rate === 'string' - ); -} - -export type ProductDetail = { - pdno: string; // 상품번호 - prdt_type_cd: string; // 상품유형코드 - mket_id_cd: string; // 시장ID코드 - scty_grp_id_cd: string; // 증권그룹ID코드 - excg_dvsn_cd: string; // 거래소구분코드 - setl_mmdd: string; // 결산월일 - lstg_stqt: string; // 상장주수 - 이거 사용 - lstg_cptl_amt: string; // 상장자본금액 - cpta: string; // 자본금 - papr: string; // 액면가 - issu_pric: string; // 발행가격 - kospi200_item_yn: string; // 코스피200종목여부 - 이것도 사용 - scts_mket_lstg_dt: string; // 유가증권시장상장일자 - scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 - kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 - kosdaq_mket_lstg_abol_dt: string; // 코스닥시장상장폐지일자 - frbd_mket_lstg_dt: string; // 프리보드시장상장일자 - frbd_mket_lstg_abol_dt: string; // 프리보드시장상장폐지일자 - reits_kind_cd: string; // 리츠종류코드 - etf_dvsn_cd: string; // ETF구분코드 - oilf_fund_yn: string; // 유전펀드여부 - idx_bztp_lcls_cd: string; // 지수업종대분류코드 - idx_bztp_mcls_cd: string; // 지수업종중분류코드 - idx_bztp_scls_cd: string; // 지수업종소분류코드 - stck_kind_cd: string; // 주식종류코드 - mfnd_opng_dt: string; // 뮤추얼펀드개시일자 - mfnd_end_dt: string; // 뮤추얼펀드종료일자 - dpsi_erlm_cncl_dt: string; // 예탁등록취소일자 - etf_cu_qty: string; // ETFCU수량 - prdt_name: string; // 상품명 - prdt_name120: string; // 상품명120 - prdt_abrv_name: string; // 상품약어명 - std_pdno: string; // 표준상품번호 - prdt_eng_name: string; // 상품영문명 - prdt_eng_name120: string; // 상품영문명120 - prdt_eng_abrv_name: string; // 상품영문약어명 - dpsi_aptm_erlm_yn: string; // 예탁지정등록여부 - etf_txtn_type_cd: string; // ETF과세유형코드 - etf_type_cd: string; // ETF유형코드 - lstg_abol_dt: string; // 상장폐지일자 - nwst_odst_dvsn_cd: string; // 신주구주구분코드 - sbst_pric: string; // 대용가격 - thco_sbst_pric: string; // 당사대용가격 - thco_sbst_pric_chng_dt: string; // 당사대용가격변경일자 - tr_stop_yn: string; // 거래정지여부 - admn_item_yn: string; // 관리종목여부 - thdt_clpr: string; // 당일종가 - bfdy_clpr: string; // 전일종가 - clpr_chng_dt: string; // 종가변경일자 - std_idst_clsf_cd: string; // 표준산업분류코드 - std_idst_clsf_cd_name: string; // 표준산업분류코드명 - idx_bztp_lcls_cd_name: string; // 지수업종대분류코드명 - idx_bztp_mcls_cd_name: string; // 지수업종중분류코드명 - idx_bztp_scls_cd_name: string; // 지수업종소분류코드명 - ocr_no: string; // OCR번호 - crfd_item_yn: string; // 크라우드펀딩종목여부 - elec_scty_yn: string; // 전자증권여부 - issu_istt_cd: string; // 발행기관코드 - etf_chas_erng_rt_dbnb: string; // ETF추적수익율배수 - etf_etn_ivst_heed_item_yn: string; // ETFETN투자유의종목여부 - stln_int_rt_dvsn_cd: string; // 대주이자율구분코드 - frnr_psnl_lmt_rt: string; // 외국인개인한도비율 - lstg_rqsr_issu_istt_cd: string; // 상장신청인발행기관코드 - lstg_rqsr_item_cd: string; // 상장신청인종목코드 - trst_istt_issu_istt_cd: string; // 신탁기관발행기관코드 -}; - -export const isProductDetail = (data: any): data is ProductDetail => { - return ( - typeof data.pdno === 'string' && - typeof data.prdt_type_cd === 'string' && - typeof data.mket_id_cd === 'string' && - typeof data.scty_grp_id_cd === 'string' && - typeof data.excg_dvsn_cd === 'string' && - typeof data.setl_mmdd === 'string' && - typeof data.lstg_stqt === 'string' && - typeof data.lstg_cptl_amt === 'string' && - typeof data.cpta === 'string' && - typeof data.papr === 'string' && - typeof data.issu_pric === 'string' && - typeof data.kospi200_item_yn === 'string' && - typeof data.scts_mket_lstg_dt === 'string' && - typeof data.scts_mket_lstg_abol_dt === 'string' && - typeof data.kosdaq_mket_lstg_dt === 'string' && - typeof data.kosdaq_mket_lstg_abol_dt === 'string' && - typeof data.frbd_mket_lstg_dt === 'string' && - typeof data.frbd_mket_lstg_abol_dt === 'string' && - typeof data.reits_kind_cd === 'string' && - typeof data.etf_dvsn_cd === 'string' && - typeof data.oilf_fund_yn === 'string' && - typeof data.idx_bztp_lcls_cd === 'string' && - typeof data.idx_bztp_mcls_cd === 'string' && - typeof data.idx_bztp_scls_cd === 'string' && - typeof data.stck_kind_cd === 'string' && - typeof data.mfnd_opng_dt === 'string' && - typeof data.mfnd_end_dt === 'string' && - typeof data.dpsi_erlm_cncl_dt === 'string' && - typeof data.etf_cu_qty === 'string' && - typeof data.prdt_name === 'string' && - typeof data.prdt_name120 === 'string' && - typeof data.prdt_abrv_name === 'string' && - typeof data.std_pdno === 'string' && - typeof data.prdt_eng_name === 'string' && - typeof data.prdt_eng_name120 === 'string' && - typeof data.prdt_eng_abrv_name === 'string' && - typeof data.dpsi_aptm_erlm_yn === 'string' && - typeof data.etf_txtn_type_cd === 'string' && - typeof data.etf_type_cd === 'string' && - typeof data.lstg_abol_dt === 'string' && - typeof data.nwst_odst_dvsn_cd === 'string' && - typeof data.sbst_pric === 'string' && - typeof data.thco_sbst_pric === 'string' && - typeof data.thco_sbst_pric_chng_dt === 'string' && - typeof data.tr_stop_yn === 'string' && - typeof data.admn_item_yn === 'string' && - typeof data.thdt_clpr === 'string' && - typeof data.bfdy_clpr === 'string' && - typeof data.clpr_chng_dt === 'string' && - typeof data.std_idst_clsf_cd === 'string' && - typeof data.std_idst_clsf_cd_name === 'string' && - typeof data.idx_bztp_lcls_cd_name === 'string' && - typeof data.idx_bztp_mcls_cd_name === 'string' && - typeof data.idx_bztp_scls_cd_name === 'string' && - typeof data.ocr_no === 'string' && - typeof data.crfd_item_yn === 'string' && - typeof data.elec_scty_yn === 'string' && - typeof data.issu_istt_cd === 'string' && - typeof data.etf_chas_erng_rt_dbnb === 'string' && - typeof data.etf_etn_ivst_heed_item_yn === 'string' && - typeof data.stln_int_rt_dvsn_cd === 'string' && - typeof data.frnr_psnl_lmt_rt === 'string' && - typeof data.lstg_rqsr_issu_istt_cd === 'string' && - typeof data.lstg_rqsr_item_cd === 'string' && - typeof data.trst_istt_issu_istt_cd === 'string' - ); -}; - -export type StockDetailQuery = { - pdno: string; - prdt_type_cd: string; -}; - -//export type FinancialDetail = { -// stac_yymm: string; // 결산 년월 -// sale_account: string; // 매출액 -// sale_cost: string; // 매출원가 -// sale_totl_prfi: string; // 매출총이익 -// depr_cost: string; // 감가상각비 -// sell_mang: string; // 판매관리비 -// bsop_prti: string; // 영업이익 -// bsop_non_ernn: string; // 영업외수익 -// bsop_non_expn: string; // 영업외비용 -// op_prfi: string; // 영업이익 -// spec_prfi: string; // 특별이익 -// spec_loss: string; // 특별손실 -// thtr_ntin: string; // 세전순이익 -//}; - -//export const isFinancialDetail = (data: any): data is FinancialDetail => { -// return ( -// typeof data.stac_yymm === 'string' && -// typeof data.sale_account === 'string' && -// typeof data.sale_cost === 'string' && -// typeof data.sale_totl_prfi === 'string' && -// typeof data.depr_cost === 'string' && -// typeof data.sell_mang === 'string' && -// typeof data.bsop_prti === 'string' && -// typeof data.bsop_non_ernn === 'string' && -// typeof data.bsop_non_expn === 'string' && -// typeof data.op_prfi === 'string' && -// typeof data.spec_prfi === 'string' && -// typeof data.spec_loss === 'string' && -// typeof data.thtr_ntin === 'string' -// ); -//}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts deleted file mode 100644 index e1687cee..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiLiveData.type.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable max-lines-per-function */ - -export type StockData = { - MKSC_SHRN_ISCD: string; // 유가증권 단축 종목코드 - STCK_CNTG_HOUR: string; // 주식 체결 시간 - STCK_PRPR: string; // 주식 현재가 - PRDY_VRSS_SIGN: string; // 전일 대비 부호 - PRDY_VRSS: string; // 전일 대비 - PRDY_CTRT: string; // 전일 대비율 - WGHN_AVRG_STCK_PRC: string; // 가중 평균 주식 가격 - STCK_OPRC: string; // 주식 시가 - STCK_HGPR: string; // 주식 최고가 - STCK_LWPR: string; // 주식 최저가 - ASKP1: string; // 매도호가1 - BIDP1: string; // 매수호가1 - CNTG_VOL: string; // 체결 거래량 - ACML_VOL: string; // 누적 거래량 - ACML_TR_PBMN: string; // 누적 거래 대금 - SELN_CNTG_CSNU: string; // 매도 체결 건수 - SHNU_CNTG_CSNU: string; // 매수 체결 건수 - NTBY_CNTG_CSNU: string; // 순매수 체결 건수 - CTTR: string; // 체결강도 - SELN_CNTG_SMTN: string; // 총 매도 수량 - SHNU_CNTG_SMTN: string; // 총 매수 수량 - CCLD_DVSN: string; // 체결구분 - SHNU_RATE: string; // 매수비율 - PRDY_VOL_VRSS_ACML_VOL_RATE: string; // 전일 거래량 대비 등락율 - OPRC_HOUR: string; // 시가 시간 - OPRC_VRSS_PRPR_SIGN: string; // 시가대비구분 - OPRC_VRSS_PRPR: string; // 시가대비 - HGPR_HOUR: string; // 최고가 시간 - HGPR_VRSS_PRPR_SIGN: string; // 고가대비구분 - HGPR_VRSS_PRPR: string; // 고가대비 - LWPR_HOUR: string; // 최저가 시간 - LWPR_VRSS_PRPR_SIGN: string; // 저가대비구분 - LWPR_VRSS_PRPR: string; // 저가대비 - BSOP_DATE: string; // 영업 일자 - NEW_MKOP_CLS_CODE: string; // 신 장운영 구분 코드 - TRHT_YN: string; // 거래정지 여부 - ASKP_RSQN1: string; // 매도호가 잔량1 - BIDP_RSQN1: string; // 매수호가 잔량1 - TOTAL_ASKP_RSQN: string; // 총 매도호가 잔량 - TOTAL_BIDP_RSQN: string; // 총 매수호가 잔량 - VOL_TNRT: string; // 거래량 회전율 - PRDY_SMNS_HOUR_ACML_VOL: string; // 전일 동시간 누적 거래량 - PRDY_SMNS_HOUR_ACML_VOL_RATE: string; // 전일 동시간 누적 거래량 비율 - HOUR_CLS_CODE: string; // 시간 구분 코드 - MRKT_TRTM_CLS_CODE: string; // 임의종료구분코드 - VI_STND_PRC: string; // 정적VI발동기준가 -}; - -export type OpenApiMessage = { - header: { - approval_key: string; - custtype: string; - tr_type: string; - 'content-type': string; - }; - body: { - input: { - tr_id: string; - tr_key: string; - }; - }; -}; - -export type MessageResponse = { - header: { - tr_id: string; - tr_key: string; - encrypt: string; - }; - body: { - rt_cd: string; - msg_cd: string; - msg1: string; - output?: { - iv: string; - key: string; - }; - }; -}; - -export function isMessageResponse(data: any): data is MessageResponse { - return ( - typeof data === 'object' && - data !== null && - typeof data.header === 'object' && - data.header !== null && - typeof data.header.tr_id === 'object' && - typeof data.header.tr_key === 'object' && - typeof data.header.encrypt === 'object' && - typeof data.body === 'object' && - data.body !== null && - typeof data.body.rt_cd === 'object' && - typeof data.body.msg_cd === 'object' && - typeof data.body.msg1 === 'object' && - typeof data.body.output === 'object' - ); -} - -export const stockDataKeys = [ - 'MKSC_SHRN_ISCD', - 'STCK_CNTG_HOUR', - 'STCK_PRPR', - 'PRDY_VRSS_SIGN', - 'PRDY_VRSS', - 'PRDY_CTRT', - 'WGHN_AVRG_STCK_PRC', - 'STCK_OPRC', - 'STCK_HGPR', - 'STCK_LWPR', - 'ASKP1', - 'BIDP1', - 'CNTG_VOL', - 'ACML_VOL', - 'ACML_TR_PBMN', - 'SELN_CNTG_CSNU', - 'SHNU_CNTG_CSNU', - 'NTBY_CNTG_CSNU', - 'CTTR', - 'SELN_CNTG_SMTN', - 'SHNU_CNTG_SMTN', - 'CCLD_DVSN', - 'SHNU_RATE', - 'PRDY_VOL_VRSS_ACML_VOL_RATE', - 'OPRC_HOUR', - 'OPRC_VRSS_PRPR_SIGN', - 'OPRC_VRSS_PRPR', - 'HGPR_HOUR', - 'HGPR_VRSS_PRPR_SIGN', - 'HGPR_VRSS_PRPR', - 'LWPR_HOUR', - 'LWPR_VRSS_PRPR_SIGN', - 'LWPR_VRSS_PRPR', - 'BSOP_DATE', - 'NEW_MKOP_CLS_CODE', - 'TRHT_YN', - 'ASKP_RSQN1', - 'BIDP_RSQN1', - 'TOTAL_ASKP_RSQN', - 'TOTAL_BIDP_RSQN', - 'VOL_TNRT', - 'PRDY_SMNS_HOUR_ACML_VOL', - 'PRDY_SMNS_HOUR_ACML_VOL_RATE', - 'HOUR_CLS_CODE', - 'MRKT_TRTM_CLS_CODE', - 'VI_STND_PRC', -]; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts deleted file mode 100644 index 5deb2d9e..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiMinuteData.type.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export type MinuteData = { - stck_bsop_date: string; - stck_cntg_hour: string; - stck_prpr: string; - stck_oprc: string; - stck_hgpr: string; - stck_lwpr: string; - cntg_vol: string; - acml_tr_pbmn: string; -}; - -export type UpdateStockQuery = { - fid_etc_cls_code: string; - fid_cond_mrkt_div_code: 'J' | 'W'; - fid_input_iscd: string; - fid_input_hour_1: string; - fid_pw_data_incu_yn: 'Y' | 'N'; -}; - -export const isMinuteData = (data: any) => { - return ( - typeof data.stck_bsop_date === 'string' && - typeof data.stck_cntg_hour === 'string' && - typeof data.stck_prpr === 'string' && - typeof data.stck_oprc === 'string' && - typeof data.stck_hgpr === 'string' && - typeof data.stck_lwpr === 'string' && - typeof data.cntg_vol === 'string' && - typeof data.acml_tr_pbmn === 'string' - ); -}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts deleted file mode 100644 index e4066f7c..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiPeriodData.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export type Period = 'D' | 'W' | 'M' | 'Y'; -export type ChartData = { - stck_bsop_date: string; - stck_clpr: string; - stck_oprc: string; - stck_hgpr: string; - stck_lwpr: string; - acml_vol: string; - acml_tr_pbmn: string; - flng_cls_code: string; - prtt_rate: string; - mod_yn: string; - prdy_vrss_sign: string; - prdy_vrss: string; - revl_issu_reas: string; -}; - -export type ItemChartPriceQuery = { - fid_cond_mrkt_div_code: 'J' | 'W'; - fid_input_iscd: string; - fid_input_date_1: string; - fid_input_date_2: string; - fid_period_div_code: Period; - fid_org_adj_prc: number; -}; - -export const isChartData = (data?: any) => { - return ( - data && - typeof data.stck_bsop_date === 'string' && - typeof data.stck_clpr === 'string' && - typeof data.stck_oprc === 'string' && - typeof data.stck_hgpr === 'string' && - typeof data.stck_lwpr === 'string' && - typeof data.acml_vol === 'string' && - typeof data.acml_tr_pbmn === 'string' && - typeof data.flng_cls_code === 'string' && - typeof data.prtt_rate === 'string' && - typeof data.mod_yn === 'string' && - typeof data.prdy_vrss_sign === 'string' && - typeof data.prdy_vrss === 'string' && - typeof data.revl_issu_reas === 'string' - ); -}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts deleted file mode 100644 index 6df0ca19..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/type/openapiUtil.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type TR_ID = - | 'FHKST03010100' - | 'FHKST03010200' - | 'FHKST66430300' - | 'HHKDB669107C0' - | 'CTPF1002R'; - -export const TR_IDS: Record = { - ITEM_CHART_PRICE: 'FHKST03010100', - MINUTE_DATA: 'FHKST03010200', - FINANCIAL_DATA: 'FHKST66430300', - PRODUCTION_DETAIL: 'CTPF1002R', -}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts b/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts deleted file mode 100644 index 1e0c3913..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiCustom.error.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; - -export class OpenapiException extends HttpException { - private error: unknown; - constructor(message: string, status: HttpStatus, error?: unknown) { - super(message, status); - this.error = error; - } - - public getError() { - return this.error; - } -} diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts deleted file mode 100644 index fa8f75b4..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/util/openapiUtil.api.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any*/ -import * as crypto from 'crypto'; -import { HttpStatus } from '@nestjs/common'; -import axios from 'axios'; -import { openApiConfig } from '../config/openapi.config'; -import { TR_ID } from '../type/openapiUtil.type'; -import { OpenapiException } from './openapiCustom.error'; - -const throwOpenapiException = (error: any) => { - if (error.message && error.response && error.response.status) { - throw new OpenapiException( - `Request failed: ${error.message}`, - error.response.status, - error, - ); - } else { - throw new OpenapiException( - `Unknown error: ${error.message || 'No message'}`, - HttpStatus.INTERNAL_SERVER_ERROR, - error, - ); - } -}; - -const postOpenApi = async ( - url: string, - config: typeof openApiConfig, - body: object, -) => { - try { - const response = await axios.post(config.STOCK_URL + url, body); - return response.data; - } catch (error) { - throwOpenapiException(error); - } -}; - -const getOpenApi = async ( - url: string, - config: typeof openApiConfig, - query: object, - tr_id: TR_ID, -) => { - try { - const response = await axios.get(config.STOCK_URL + url, { - params: query, - headers: { - Authorization: `Bearer ${config.STOCK_API_TOKEN}`, - appkey: config.STOCK_API_KEY, - appsecret: config.STOCK_API_PASSWORD, - tr_id, - custtype: 'P', - }, - }); - return response.data; - } catch (error) { - throwOpenapiException(error); - } -}; - -const getTodayDate = (): string => { - const today = new Date(); - return today.toISOString().split('T')[0].replace(/-/g, ''); -}; - -const getPreviousDate = (date: string, months: number): string => { - const currentDate = new Date( - date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8), - ); - currentDate.setMonth(currentDate.getMonth() - months); - return currentDate.toISOString().split('T')[0].replace(/-/g, ''); -}; - -const getCurrentTime = () => { - const now = new Date(); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - return `${hours}${minutes}${seconds}`; -}; - -const decryptAES256 = ( - encryptedText: string, - key: string, - iv: string, -): string => { - const decipher = crypto.createDecipheriv( - 'aes-256-cbc', - Buffer.from(key, 'hex'), - Buffer.from(iv, 'hex'), - ); - let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; -}; - -const bufferToObject = (buffer: Buffer): any => { - try { - const jsonString = buffer.toString('utf-8'); - return JSON.parse(jsonString); - } catch (error) { - console.error('Failed to convert buffer to object:', error); - throw error; - } -}; - -export { - postOpenApi, - getOpenApi, - getTodayDate, - getPreviousDate, - getCurrentTime, - decryptAES256, - bufferToObject, -}; diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts deleted file mode 100644 index a49e5822..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/util/priorityQueue.ts +++ /dev/null @@ -1,99 +0,0 @@ -export class PriorityQueue { - private heap: { value: T; priority: number }[]; - - constructor() { - this.heap = []; - } - - private getParentIndex(index: number): number { - return Math.floor((index - 1) / 2); - } - - private getLeftChildIndex(index: number): number { - return index * 2 + 1; - } - - private getRightChildIndex(index: number): number { - return index * 2 + 2; - } - - private swap(i: number, j: number) { - [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; - } - - private heapifyUp() { - let index = this.heap.length - 1; - while ( - index > 0 && - this.heap[index].priority < this.heap[this.getParentIndex(index)].priority - ) { - this.swap(index, this.getParentIndex(index)); - index = this.getParentIndex(index); - } - } - - private heapifyDown() { - let index = 0; - while (this.getLeftChildIndex(index) < this.heap.length) { - let smallerChildIndex = this.getLeftChildIndex(index); - const rightChildIndex = this.getRightChildIndex(index); - - if ( - rightChildIndex < this.heap.length && - this.heap[rightChildIndex].priority < - this.heap[smallerChildIndex].priority - ) { - smallerChildIndex = rightChildIndex; - } - - if (this.heap[index].priority <= this.heap[smallerChildIndex].priority) { - break; - } - - this.swap(index, smallerChildIndex); - index = smallerChildIndex; - } - } - - enqueue(value: T, priority: number) { - this.heap.push({ value, priority }); - this.heapifyUp(); - } - - dequeue(): T | undefined { - if (this.isEmpty()) { - return undefined; - } - - const root = this.heap[0]; - const last = this.heap.pop(); - - if (this.heap.length > 0 && last) { - this.heap[0] = last; - this.heapifyDown(); - } - - return root.value; - } - - peek(): T | undefined { - return this.heap.length > 0 ? this.heap[0].value : undefined; - } - - isEmpty(): boolean { - return this.heap.length === 0; - } -} - -const pq = new PriorityQueue(); - -pq.enqueue('Task A', 2); -pq.enqueue('Task B', 1); -pq.enqueue('Task C', 3); - -console.log(pq.dequeue()); // Task B -console.log(pq.peek()); // Task A -console.log(pq.dequeue()); // Task A -console.log(pq.isEmpty()); // false -console.log(pq.dequeue()); // Task C -console.log(pq.isEmpty()); // true diff --git a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts b/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts deleted file mode 100644 index 2e36acfb..00000000 --- a/packages/backend/src/scraper/korea-stock-info/openapi/websocketClient.service.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Logger } from 'winston'; -import { RawData, WebSocket } from 'ws'; -import { OpenapiLiveData } from './api/openapiLiveData.api'; -import { openApiToken } from './api/openapiToken.api'; -import { openApiConfig } from './config/openapi.config'; -import { parseMessage } from './parse/openapi.parser'; - -type TR_IDS = '0' | '1'; - -@Injectable() -export class WebsocketClient { - private client: WebSocket; - private readonly reconnectInterval = 60000; - private readonly url = - process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; - private readonly clientStock: Set = new Set(); - - constructor( - @Inject('winston') private readonly logger: Logger, - private readonly openapiLiveData: OpenapiLiveData, - ) { - if (process.env.NODE_ENV === 'production') { - this.connect(); - } - } - - // TODO : subscribe 구조로 리팩토링 - subscribe(stockId: string) { - this.clientStock.add(stockId); - // TODO : 하나의 config만 사용중. - const message = this.convertObjectToMessage( - openApiToken.configs[0], - stockId, - '1', - ); - this.sendMessage(message); - } - - discribe(stockId: string) { - this.clientStock.delete(stockId); - const message = this.convertObjectToMessage( - openApiToken.configs[0], - stockId, - '0', - ); - this.sendMessage(message); - } - - private initDisconnect() { - this.client.on('close', () => { - this.logger.warn( - `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, - ); - }); - - this.client.on('error', (error: any) => { - this.logger.error(`WebSocket error: ${error.message}`); - setTimeout(() => this.connect(), this.reconnectInterval); - }); - } - - private initOpen() { - this.client.on('open', () => { - this.logger.info('WebSocket connection established'); - for (const stockId of this.clientStock.keys()) { - const message = this.convertObjectToMessage( - openApiToken.configs[0], - stockId, - '1', - ); - this.sendMessage(message); - } - }); - } - - private initMessage() { - this.client.on('message', async (data) => { - try { - const message = this.parseMessage(data); - if (message.header) { - if (message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(data)}`); - this.client.pong({ - tr_id: 'PINGPONG', - datetime: new Date().toISOString(), - }); - } - return; - } - this.logger.info(`Recived data : ${data}`); - const liveData = this.openapiLiveData.convertLiveData(message); - this.openapiLiveData.saveLiveData(liveData); - } catch (error) { - this.logger.warn(error); - } - }); - } - - private parseMessage(data: RawData) { - if (typeof data === 'object') { - return data; - } else { - return parseMessage(data as string); - } - } - - @Cron('0 2 * * 1-5') - connect() { - this.client = new WebSocket(this.url); - this.initOpen(); - this.initMessage(); - this.initDisconnect(); - } - - private convertObjectToMessage( - config: typeof openApiConfig, - stockId: string, - tr_type: TR_IDS, - ): string { - const message = { - header: { - approval_key: config.STOCK_WEBSOCKET_KEY!, - custtype: 'P', - tr_type, - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: 'H0STCNT0', - tr_key: stockId, - }, - }, - }; - return JSON.stringify(message); - } - - private sendMessage(message: string) { - if (this.client.readyState === WebSocket.OPEN) { - this.client.send(message); - this.logger.info(`Sent message: ${message}`); - } else { - this.logger.warn('WebSocket is not open. Message not sent.'); - } - } -} diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 9729e0fc..16976657 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -1,6 +1,8 @@ +import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +@Injectable() export class OpenapiLiveData { public readonly TR_ID: string = 'H0STCNT0'; constructor(private readonly datasource: DataSource) {} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index cb45c91c..ad425412 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -39,5 +39,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiLiveData, WebsocketClient, ], + exports: [WebsocketClient], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index 2e36acfb..40501a25 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; -import { RawData, WebSocket } from 'ws'; +import { WebSocket } from 'ws'; import { OpenapiLiveData } from './api/openapiLiveData.api'; import { openApiToken } from './api/openapiToken.api'; import { openApiConfig } from './config/openapi.config'; @@ -79,7 +79,7 @@ export class WebsocketClient { private initMessage() { this.client.on('message', async (data) => { try { - const message = this.parseMessage(data); + const message = this.parseMessage(data.toString()); if (message.header) { if (message.header.tr_id === 'PINGPONG') { this.logger.info(`Received PING: ${JSON.stringify(data)}`); @@ -99,7 +99,7 @@ export class WebsocketClient { }); } - private parseMessage(data: RawData) { + private parseMessage(data: string) { if (typeof data === 'object') { return data; } else { From 441926fc41b31d480820b331ae84dad070713653 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 22:23:43 +0900 Subject: [PATCH 089/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stock=20gateway?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20websocket=20-=20client=20=EC=84=9C?= =?UTF-8?q?=EB=B9=99=20=EA=B5=AC=EC=A1=B0=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/app.module.ts | 8 ++++---- packages/backend/src/stock/stock.gateway.ts | 10 ++++++++-- packages/backend/src/stock/stock.module.ts | 2 ++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index f4735ad2..439337b1 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -7,11 +7,11 @@ import { ScraperModule } from './scraper/scraper.module'; import { AuthModule } from '@/auth/auth.module'; import { SessionModule } from '@/auth/session.module'; import { ChatModule } from '@/chat/chat.module'; +import { logger } from '@/configs/logger.config'; import { typeormDevelopConfig, typeormProductConfig, } from '@/configs/typeormConfig'; -import { logger } from '@/configs/logger.config'; import { StockModule } from '@/stock/stock.module'; import { UserModule } from '@/user/user.module'; @@ -19,14 +19,14 @@ import { UserModule } from '@/user/user.module'; imports: [ ConfigModule.forRoot({ cache: true, isGlobal: true }), ScheduleModule.forRoot(), - ScraperModule, - StockModule, - UserModule, TypeOrmModule.forRoot( process.env.NODE_ENV === 'production' ? typeormProductConfig : typeormDevelopConfig, ), + ScraperModule, + StockModule, + UserModule, WinstonModule.forRoot(logger), AuthModule, ChatModule, diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 1f4ab32d..c6268e82 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -6,23 +6,29 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { WebsocketClient } from '@/scraper/openapi/websocketClient.service'; @WebSocketGateway({ namespace: '/api/stock/realtime', + cors: true, + transports: ['websocket'], }) export class StockGateway { @WebSocketServer() server: Server; - constructor() {} + constructor(private readonly websocketClient: WebsocketClient) {} @SubscribeMessage('connectStock') - handleConnectStock( + async handleConnectStock( @MessageBody() stockId: string, @ConnectedSocket() client: Socket, ) { client.join(stockId); + if ((await this.server.in(stockId).fetchSockets()).length === 0) { + this.websocketClient.subscribe(stockId); + } client.emit('connectionSuccess', { message: `Successfully connected to stock room: ${stockId}`, stockId, diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 13df81b0..69d925f4 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -23,6 +23,7 @@ import { } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; +import { OpenapiScraperModule } from '@/scraper/openapi/openapi-scraper.module'; @Module({ imports: [ @@ -36,6 +37,7 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; StockLiveData, StockDetail, ]), + OpenapiScraperModule, ], controllers: [StockController], providers: [ From 973733b4d0a4efe1e221632a4706461cf4962a22 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 23:03:02 +0900 Subject: [PATCH 090/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A3=BC?= =?UTF-8?q?=EC=8B=9D=20=EC=83=81=EC=84=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?name=20=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/dto/stockDetail.response.ts | 6 ++++++ packages/backend/src/stock/stockDetail.service.ts | 9 ++++++++- packages/backend/src/user/user.service.spec.ts | 10 +++++----- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/stock/dto/stockDetail.response.ts b/packages/backend/src/stock/dto/stockDetail.response.ts index cf692e58..002876c5 100644 --- a/packages/backend/src/stock/dto/stockDetail.response.ts +++ b/packages/backend/src/stock/dto/stockDetail.response.ts @@ -7,6 +7,12 @@ export class StockDetailResponse { }) marketCap: number; + @ApiProperty({ + description: '주식의 이름', + example: '삼성전자', + }) + name: string; + @ApiProperty({ description: '주식의 EPS', example: 4091, diff --git a/packages/backend/src/stock/stockDetail.service.ts b/packages/backend/src/stock/stockDetail.service.ts index 412a4745..65d5a249 100644 --- a/packages/backend/src/stock/stockDetail.service.ts +++ b/packages/backend/src/stock/stockDetail.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; +import { Stock } from './domain/stock.entity'; import { StockDetail } from './domain/stockDetail.entity'; import { StockDetailResponse } from './dto/stockDetail.response'; @@ -29,7 +30,13 @@ export class StockDetailService { stock: { id: stockId }, }); - return plainToInstance(StockDetailResponse, stockDetail[0]); + const stockName = await manager.findBy(Stock, { + id: stockId, + }); + + const result = { name: stockName[0].name, ...stockDetail[0] }; + + return plainToInstance(StockDetailResponse, result); }); } } diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index df80a82c..6af944a1 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -1,9 +1,9 @@ /* eslint-disable max-lines-per-function */ -import { BadRequestException, NotFoundException } from "@nestjs/common"; -import { DataSource, EntityManager } from "typeorm"; -import { User } from "./domain/user.entity"; -import { OauthType } from "@/user/domain/ouathType"; -import { UserService } from "@/user/user.service"; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { User } from './domain/user.entity'; +import { OauthType } from '@/user/domain/ouathType'; +import { UserService } from '@/user/user.service'; export function createDataSourceMock( managerMock?: Partial, From cdfd1f86c774d929703f53c37f600779b83c5427 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 23:05:37 +0900 Subject: [PATCH 091/223] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20fix:=20stock?= =?UTF-8?q?=20gateway=20=EC=88=98=EC=A0=95,=20websocket=20-=20client=20?= =?UTF-8?q?=EC=84=9C=EB=B9=99=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 441926fc41b31d480820b331ae84dad070713653. --- packages/backend/src/app.module.ts | 8 ++++---- packages/backend/src/stock/stock.gateway.ts | 10 ++-------- packages/backend/src/stock/stock.module.ts | 2 -- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 439337b1..f4735ad2 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -7,11 +7,11 @@ import { ScraperModule } from './scraper/scraper.module'; import { AuthModule } from '@/auth/auth.module'; import { SessionModule } from '@/auth/session.module'; import { ChatModule } from '@/chat/chat.module'; -import { logger } from '@/configs/logger.config'; import { typeormDevelopConfig, typeormProductConfig, } from '@/configs/typeormConfig'; +import { logger } from '@/configs/logger.config'; import { StockModule } from '@/stock/stock.module'; import { UserModule } from '@/user/user.module'; @@ -19,14 +19,14 @@ import { UserModule } from '@/user/user.module'; imports: [ ConfigModule.forRoot({ cache: true, isGlobal: true }), ScheduleModule.forRoot(), + ScraperModule, + StockModule, + UserModule, TypeOrmModule.forRoot( process.env.NODE_ENV === 'production' ? typeormProductConfig : typeormDevelopConfig, ), - ScraperModule, - StockModule, - UserModule, WinstonModule.forRoot(logger), AuthModule, ChatModule, diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index c6268e82..1f4ab32d 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -6,29 +6,23 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { WebsocketClient } from '@/scraper/openapi/websocketClient.service'; @WebSocketGateway({ namespace: '/api/stock/realtime', - cors: true, - transports: ['websocket'], }) export class StockGateway { @WebSocketServer() server: Server; - constructor(private readonly websocketClient: WebsocketClient) {} + constructor() {} @SubscribeMessage('connectStock') - async handleConnectStock( + handleConnectStock( @MessageBody() stockId: string, @ConnectedSocket() client: Socket, ) { client.join(stockId); - if ((await this.server.in(stockId).fetchSockets()).length === 0) { - this.websocketClient.subscribe(stockId); - } client.emit('connectionSuccess', { message: `Successfully connected to stock room: ${stockId}`, stockId, diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 69d925f4..13df81b0 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -23,7 +23,6 @@ import { } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; -import { OpenapiScraperModule } from '@/scraper/openapi/openapi-scraper.module'; @Module({ imports: [ @@ -37,7 +36,6 @@ import { OpenapiScraperModule } from '@/scraper/openapi/openapi-scraper.module'; StockLiveData, StockDetail, ]), - OpenapiScraperModule, ], controllers: [StockController], providers: [ From 9b395d3792b6e6f8bddcb989233c133b5c6e73ec Mon Sep 17 00:00:00 2001 From: sunghwki Date: Sun, 24 Nov 2024 23:31:44 +0900 Subject: [PATCH 092/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20stock?= =?UTF-8?q?=20detail=20=EA=B8=B0=EB=B3=B8=EC=BF=BC=EB=A6=AC=EA=B0=80=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C=20left=20join=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/dto/stockDetail.response.ts | 10 ++++++++ .../backend/src/stock/stockDetail.service.ts | 23 ++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/stock/dto/stockDetail.response.ts b/packages/backend/src/stock/dto/stockDetail.response.ts index 002876c5..248163fd 100644 --- a/packages/backend/src/stock/dto/stockDetail.response.ts +++ b/packages/backend/src/stock/dto/stockDetail.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { StockDetail } from '../domain/stockDetail.entity'; export class StockDetailResponse { @ApiProperty({ @@ -36,4 +37,13 @@ export class StockDetailResponse { example: 53000, }) low52w: number; + + constructor(stockDetail: StockDetail) { + this.eps = stockDetail.eps; + this.per = stockDetail.per; + this.high52w = stockDetail.high52w; + this.low52w = stockDetail.low52w; + this.marketCap = Number(stockDetail.marketCap); + this.name = stockDetail.stock.name; + } } diff --git a/packages/backend/src/stock/stockDetail.service.ts b/packages/backend/src/stock/stockDetail.service.ts index 65d5a249..828a40ee 100644 --- a/packages/backend/src/stock/stockDetail.service.ts +++ b/packages/backend/src/stock/stockDetail.service.ts @@ -1,8 +1,6 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { plainToInstance } from 'class-transformer'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; -import { Stock } from './domain/stock.entity'; import { StockDetail } from './domain/stockDetail.entity'; import { StockDetailResponse } from './dto/stockDetail.response'; @@ -26,17 +24,20 @@ export class StockDetailService { ); } - const stockDetail = await manager.findBy(StockDetail, { - stock: { id: stockId }, - }); - - const stockName = await manager.findBy(Stock, { - id: stockId, - }); + const result = await this.datasource.manager + .getRepository(StockDetail) + .createQueryBuilder('stockDetail') + .leftJoinAndSelect('stockDetail.stock', 'stock') + .where('stockDetail.stock_id = :stockId', { stockId }) + .getOne(); - const result = { name: stockName[0].name, ...stockDetail[0] }; + if (!result) { + throw new NotFoundException( + `stock detail not found (stockId: ${stockId}`, + ); + } - return plainToInstance(StockDetailResponse, result); + return new StockDetailResponse(result); }); } } From b71839a219595cee9e6116d28d9dd36d69ab0517 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 11:37:06 +0900 Subject: [PATCH 093/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EB=9E=9C=EB=8D=A4=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/user/user.service.ts | 32 ++++------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 8d2a5d23..dbef92dd 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -6,6 +6,7 @@ import { import { DataSource, EntityManager } from 'typeorm'; import { OauthType } from './domain/ouathType'; import { User } from './domain/user.entity'; +import { status, subject } from '@/user/constants/randomNickname'; type RegisterRequest = Required< Pick @@ -76,33 +77,10 @@ export class UserService { return user.isLight; } - private generateRandomNickname(): string { - const adjectives = [ - '강력한', - '지혜로운', - '소중한', - '빛나는', - '고요한', - '용감한', - '행운의', - '신비로운', - ]; - const animals = [ - '호랑이', - '독수리', - '용', - '사슴', - '백호', - '하늘새', - '백두산 호랑이', - '붉은 여우', - ]; - - const randomAdjective = - adjectives[Math.floor(Math.random() * adjectives.length)]; - const randomAnimal = animals[Math.floor(Math.random() * animals.length)]; - - return `${randomAdjective} ${randomAnimal}`; + private generateRandomNickname() { + const statusName = status[Math.floor(Math.random() * status.length)]; + const subjectName = subject[Math.floor(Math.random() * subject.length)]; + return `${statusName}${subjectName}`; } private async getMaxOauthId(oauthType: OauthType, manager: EntityManager) { From 22d60c65f57beda606df335a92c84e8bb220b9b0 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 11:40:48 +0900 Subject: [PATCH 094/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20swagger=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/auth/tester/testerAuth.controller.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/auth/tester/testerAuth.controller.ts b/packages/backend/src/auth/tester/testerAuth.controller.ts index d4381db9..4ee02553 100644 --- a/packages/backend/src/auth/tester/testerAuth.controller.ts +++ b/packages/backend/src/auth/tester/testerAuth.controller.ts @@ -1,5 +1,10 @@ import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import { Request, Response } from 'express'; import { TestAuthGuard } from '@/auth/tester/guard/tester.guard'; @@ -12,6 +17,16 @@ export class TesterAuthController { summary: '테스터 로그인 api', description: '테스터로 로그인합니다.', }) + @ApiQuery({ + name: 'username', + required: true, + description: '테스터 아이디(값만 넣으면 됨)', + }) + @ApiQuery({ + name: 'password', + required: true, + description: '테스터 비밀번호(값만 넣으면 됨)', + }) @Get('/login') @UseGuards(TestAuthGuard) async handleLogin(@Res() response: Response) { From 996b64a52890fc4ee1e7cd6ee1ee3be582e4407c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 17:02:23 +0900 Subject: [PATCH 095/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=9E=9C=EB=8D=A4?= =?UTF-8?q?=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=83=81=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/user/constants/randomNickname.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/backend/src/user/constants/randomNickname.ts diff --git a/packages/backend/src/user/constants/randomNickname.ts b/packages/backend/src/user/constants/randomNickname.ts new file mode 100644 index 00000000..fb55e86e --- /dev/null +++ b/packages/backend/src/user/constants/randomNickname.ts @@ -0,0 +1,16 @@ +export const status = [ + '신중한', + '과감한', + '공부하는', + '성장하는', + '주춤거리는', +]; +export const subject = [ + '병아리', + '햄스터', + '다람쥐', + '거북이', + '판다', + '주린이', + '투자자', +]; From e82a6dd61e60d917ed8ac859bd335226a3f51dc7 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Mon, 25 Nov 2024 19:35:21 +0900 Subject: [PATCH 096/223] =?UTF-8?q?Bug/#235=20websocket=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=ED=95=B4=EA=B2=B0,=20token=20db=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20(#240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 --- packages/backend/src/configs/typeormConfig.ts | 2 +- .../src/scraper/domain/openapiToken.entity.ts | 28 ++++ .../openapi/api/openapiDetailData.api.ts | 9 +- .../openapi/api/openapiLiveData.api.ts | 39 ++++-- .../openapi/api/openapiMinuteData.api.ts | 11 +- .../openapi/api/openapiPeriodData.api.ts | 9 +- .../scraper/openapi/api/openapiToken.api.ts | 125 ++++++++++++++++-- .../scraper/openapi/openapi-scraper.module.ts | 2 + .../src/scraper/openapi/util/priorityQueue.ts | 13 -- .../openapi/websocketClient.service.ts | 32 ++--- .../backend/src/stock/domain/stock.entity.ts | 4 + 11 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 packages/backend/src/scraper/domain/openapiToken.entity.ts diff --git a/packages/backend/src/configs/typeormConfig.ts b/packages/backend/src/configs/typeormConfig.ts index a8c70a18..f8ff59ac 100644 --- a/packages/backend/src/configs/typeormConfig.ts +++ b/packages/backend/src/configs/typeormConfig.ts @@ -21,6 +21,6 @@ export const typeormDevelopConfig: TypeOrmModuleOptions = { password: process.env.DB_PASS, database: process.env.DB_NAME, entities: [__dirname + '/../**/*.entity.{js,ts}'], - //logging: true, + logging: true, synchronize: true, }; diff --git a/packages/backend/src/scraper/domain/openapiToken.entity.ts b/packages/backend/src/scraper/domain/openapiToken.entity.ts new file mode 100644 index 00000000..613abcb5 --- /dev/null +++ b/packages/backend/src/scraper/domain/openapiToken.entity.ts @@ -0,0 +1,28 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'openapi_token' }) +export class OpenapiToken { + @PrimaryColumn({ name: 'account' }) + account: string; + + @Column({ name: 'apiUrl' }) + api_url: string; + + @Column({ name: 'key' }) + api_key: string; + + @Column({ name: 'password' }) + api_password: string; + + @Column({ name: 'token', length: 512 }) + api_token?: string; + + @Column({ name: 'tokenExpire' }) + api_token_expire?: Date; + + @Column({ name: 'websocketKey' }) + websocket_key?: string; + + @Column({ name: 'websocketKeyExpire' }) + websocket_key_expire?: Date; +} diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 9be8e3ea..6a32435f 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -13,7 +13,7 @@ import { } from '../type/openapiDetailData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { getOpenApi } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; +import { OpenapiTokenApi } from './openapiToken.api'; import { KospiStock } from '@/stock/domain/kospiStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily } from '@/stock/domain/stockData.entity'; @@ -27,6 +27,7 @@ export class OpenapiDetailData { '/uapi/domestic-stock/v1/quotations/search-stock-info'; private readonly intervals = 1000; constructor( + private readonly openApiToken: OpenapiTokenApi, private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { @@ -38,13 +39,13 @@ export class OpenapiDetailData { if (process.env.NODE_ENV !== 'production') return; const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); - const configCount = openApiToken.configs.length; + const configCount = this.openApiToken.configs.length; const chunkSize = Math.ceil(stocks.length / configCount); for (let i = 0; i < configCount; i++) { - this.logger.info(openApiToken.configs[i]); + this.logger.info(this.openApiToken.configs[i]); const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getDetailDataChunk(chunk, openApiToken.configs[i]); + this.getDetailDataChunk(chunk, this.openApiToken.configs[i]); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 16976657..6fa15505 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -1,26 +1,47 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @Injectable() export class OpenapiLiveData { public readonly TR_ID: string = 'H0STCNT0'; - constructor(private readonly datasource: DataSource) {} + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) {} async saveLiveData(data: StockLiveData[]) { - await this.datasource.manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .execute(); + const exists = await this.datasource.manager.exists(StockLiveData, { + where: { + stock: { id: data[0].stock.id }, + }, + }); + if (exists) { + await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .update() + .set(data[0]) + .where('stock.id = :stockId', { stockId: data[0].stock.id }) + .execute(); + } else { + await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .execute(); + } } convertLiveData(messages: Record[]): StockLiveData[] { const stockData: StockLiveData[] = []; messages.map((message) => { const stockLiveData = new StockLiveData(); + stockLiveData.stock = { id: message.STOCK_ID } as Stock; stockLiveData.currentPrice = parseFloat(message.STCK_PRPR); stockLiveData.changeRate = parseFloat(message.PRDY_CTRT); stockLiveData.volume = parseInt(message.CNTG_VOL); diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 003e7560..e64e26c7 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -11,7 +11,7 @@ import { } from '../type/openapiMinuteData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; +import { OpenapiTokenApi } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; @@ -27,9 +27,10 @@ export class OpenapiMinuteData { private flip: number = 0; constructor( private readonly datasource: DataSource, + private readonly openApiToken: OpenapiTokenApi, @Inject('winston') private readonly logger: Logger, ) { - this.getStockData(); + //this.getStockData(); } @Cron('0 1 * * 1-5') @@ -126,16 +127,16 @@ export class OpenapiMinuteData { } } - @Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) + //@Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) getMinuteData() { if (process.env.NODE_ENV !== 'production') return; - const configCount = openApiToken.configs.length; + const configCount = this.openApiToken.configs.length; const stock = this.stock[this.flip % STOCK_CUT]; this.flip++; const chunkSize = Math.ceil(stock.length / configCount); for (let i = 0; i < configCount; i++) { const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, openApiToken.configs[i]); + this.getMinuteDataChunk(chunk, this.openApiToken.configs[i]); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index f4268088..4b976be8 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -14,7 +14,7 @@ import { getPreviousDate, getTodayDate, } from '../util/openapiUtil.api'; -import { openApiToken } from './openapiToken.api'; +import { OpenapiTokenApi } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, @@ -46,6 +46,7 @@ export class OpenapiPeriodData { '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; constructor( private readonly datasource: DataSource, + private readonly openApiToken: OpenapiTokenApi, @Inject('winston') private readonly logger: Logger, ) { //this.getItemChartPriceCheck(); @@ -59,7 +60,7 @@ export class OpenapiPeriodData { isTrading: true, }, }); - const configCount = openApiToken.configs.length; + const configCount = this.openApiToken.configs.length; const chunkSize = Math.ceil(stocks.length / configCount); for (let i = 0; i < configCount; i++) { @@ -95,7 +96,7 @@ export class OpenapiPeriodData { let isFail = false; while (!isFail) { - configIdx = (configIdx + 1) % openApiToken.configs.length; + configIdx = (configIdx + 1) % this.openApiToken.configs.length; this.setStockPeriod(stockPeriod, stock.id!, end); // chart 데이터가 있는 지 확인 -> 리턴 @@ -129,7 +130,7 @@ export class OpenapiPeriodData { try { const response = await getOpenApi( this.url, - openApiToken.configs[configIdx], + this.openApiToken.configs[configIdx], query, TR_IDS.ITEM_CHART_PRICE, ); diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 6e6c88c5..829ca9a3 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,14 +1,21 @@ -import { Inject } from '@nestjs/common'; +import { Global, Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; import { Logger } from 'winston'; +import { OpenapiToken } from '../../domain/openapiToken.entity'; import { openApiConfig } from '../config/openapi.config'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; -import { logger } from '@/configs/logger.config'; -class OpenapiTokenApi { +@Global() +@Injectable() +export class OpenapiTokenApi { private config: (typeof openApiConfig)[] = []; - constructor(@Inject('winston') private readonly logger: Logger) { + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly datasource: DataSource, + ) { + if (process.env.NODE_ENV !== 'production') return; const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); const api_keys = openApiConfig.STOCK_API_KEY!.split(','); const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); @@ -27,14 +34,111 @@ class OpenapiTokenApi { STOCK_API_PASSWORD: api_passwords[i], }); } - this.initAuthenValue(); + this.init(); } get configs() { - //TODO : 현재 구조에서 받아올 때마다 확인후 할당으로 변경 + this.init(); return this.config; } + @Cron('30 0 * * 1-5') + async init() { + const tokens = await this.convertConfigToTokenEntity(this.config); + const config = await this.getPropertyFromDB(tokens); + const expired = config.filter( + (val) => + this.isTokenExpired(val.api_token_expire) && + this.isTokenExpired(val.websocket_key_expire), + ); + + if (expired.length || !config.length) { + await this.initAuthenValue(); + const newTokens = await this.convertConfigToTokenEntity(this.config); + this.savePropertyToDB(newTokens); + } else { + this.config = await this.convertTokenEntityToConfig(config); + } + } + + private isTokenExpired(startDate?: Date) { + if (!startDate) return true; + const now = new Date(); + //실제 만료 시간은 24시간이지만, 문제의 소지가 발생하는 것을 방지하기 위해 20시간으로 설정함. + const baseTimeToMilliSec = 20 * 60 * 60 * 1000; + const timeDiff = now.getTime() - startDate.getTime(); + + return timeDiff >= baseTimeToMilliSec; + } + + private async convertTokenEntityToConfig(tokens: OpenapiToken[]) { + const result: (typeof openApiConfig)[] = []; + tokens.forEach((val) => { + const config: typeof openApiConfig = { + STOCK_ACCOUNT: val.account, + STOCK_API_KEY: val.api_key, + STOCK_API_PASSWORD: val.api_password, + STOCK_API_TOKEN: val.api_token, + STOCK_URL: val.api_url, + STOCK_WEBSOCKET_KEY: val.websocket_key, + }; + result.push(config); + }); + return result; + } + + private async convertConfigToTokenEntity(config: (typeof openApiConfig)[]) { + const result: OpenapiToken[] = []; + config.forEach((val) => { + const token = new OpenapiToken(); + if ( + val.STOCK_URL && + val.STOCK_ACCOUNT && + val.STOCK_API_KEY && + val.STOCK_API_PASSWORD + ) { + token.api_url = val.STOCK_URL; + token.account = val.STOCK_ACCOUNT; + token.api_key = val.STOCK_API_KEY; + token.api_password = val.STOCK_API_PASSWORD; + } + token.api_token = val.STOCK_API_TOKEN; + token.websocket_key = val.STOCK_WEBSOCKET_KEY; + token.api_token_expire = new Date(); + token.websocket_key_expire = new Date(); + result.push(token); + }); + return result; + } + + private async savePropertyToDB(tokens: OpenapiToken[]) { + tokens.forEach(async (val) => { + this.datasource.manager.save(OpenapiToken, val); + }); + } + + private async getPropertyFromDB(tokens: OpenapiToken[]) { + const result: OpenapiToken[] = []; + await Promise.all( + tokens.map(async (val) => { + const findByToken = await this.datasource.manager.findOne( + OpenapiToken, + { + where: { + account: val.account, + api_key: val.api_key, + api_password: val.api_password, + }, + }, + ); + if (findByToken) { + result.push(findByToken); + } + }), + ); + return result; + } + private async initAuthenValue() { const delay = 60000; const delayMinute = delay / 1000 / 60; @@ -59,8 +163,7 @@ class OpenapiTokenApi { } } - @Cron('50 0 * * 1-5') - async initAccessToken() { + private async initAccessToken() { const updatedConfig = await Promise.all( this.config.map(async (val) => { val.STOCK_API_TOKEN = await this.getToken(val)!; @@ -70,8 +173,7 @@ class OpenapiTokenApi { this.config = updatedConfig; } - @Cron('50 0 * * 1-5') - async initWebSocketKey() { + private async initWebSocketKey() { const updatedConfig = await Promise.all( this.config.map(async (val) => { val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; @@ -107,6 +209,3 @@ class OpenapiTokenApi { return tmp.approval_key as string; } } - -const openApiToken = new OpenapiTokenApi(logger); -export { openApiToken }; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index ad425412..73f33f23 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -4,6 +4,7 @@ import { OpenapiDetailData } from './api/openapiDetailData.api'; import { OpenapiLiveData } from './api/openapiLiveData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; +import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; import { WebsocketClient } from './websocketClient.service'; import { Stock } from '@/stock/domain/stock.entity'; @@ -32,6 +33,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; ], controllers: [], providers: [ + OpenapiTokenApi, OpenapiPeriodData, OpenapiMinuteData, OpenapiDetailData, diff --git a/packages/backend/src/scraper/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/openapi/util/priorityQueue.ts index a49e5822..190718a5 100644 --- a/packages/backend/src/scraper/openapi/util/priorityQueue.ts +++ b/packages/backend/src/scraper/openapi/util/priorityQueue.ts @@ -84,16 +84,3 @@ export class PriorityQueue { return this.heap.length === 0; } } - -const pq = new PriorityQueue(); - -pq.enqueue('Task A', 2); -pq.enqueue('Task B', 1); -pq.enqueue('Task C', 3); - -console.log(pq.dequeue()); // Task B -console.log(pq.peek()); // Task A -console.log(pq.dequeue()); // Task A -console.log(pq.isEmpty()); // false -console.log(pq.dequeue()); // Task C -console.log(pq.isEmpty()); // true diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts index 40501a25..e784afe5 100644 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ b/packages/backend/src/scraper/openapi/websocketClient.service.ts @@ -2,9 +2,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Logger } from 'winston'; -import { WebSocket } from 'ws'; +import { RawData, WebSocket } from 'ws'; import { OpenapiLiveData } from './api/openapiLiveData.api'; -import { openApiToken } from './api/openapiToken.api'; +import { OpenapiTokenApi } from './api/openapiToken.api'; import { openApiConfig } from './config/openapi.config'; import { parseMessage } from './parse/openapi.parser'; @@ -20,6 +20,7 @@ export class WebsocketClient { constructor( @Inject('winston') private readonly logger: Logger, + private readonly openApiToken: OpenapiTokenApi, private readonly openapiLiveData: OpenapiLiveData, ) { if (process.env.NODE_ENV === 'production') { @@ -32,7 +33,7 @@ export class WebsocketClient { this.clientStock.add(stockId); // TODO : 하나의 config만 사용중. const message = this.convertObjectToMessage( - openApiToken.configs[0], + this.openApiToken.configs[0], stockId, '1', ); @@ -42,7 +43,7 @@ export class WebsocketClient { discribe(stockId: string) { this.clientStock.delete(stockId); const message = this.convertObjectToMessage( - openApiToken.configs[0], + this.openApiToken.configs[0], stockId, '0', ); @@ -67,7 +68,7 @@ export class WebsocketClient { this.logger.info('WebSocket connection established'); for (const stockId of this.clientStock.keys()) { const message = this.convertObjectToMessage( - openApiToken.configs[0], + this.openApiToken.configs[0], stockId, '1', ); @@ -77,31 +78,32 @@ export class WebsocketClient { } private initMessage() { - this.client.on('message', async (data) => { + this.client.on('message', async (data: RawData) => { try { - const message = this.parseMessage(data.toString()); + console.log(data); + const message = this.parseMessage(data); if (message.header) { if (message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${JSON.stringify(data)}`); - this.client.pong({ - tr_id: 'PINGPONG', - datetime: new Date().toISOString(), - }); + this.logger.info(`Received PING: ${data}`); + this.client.pong(data); } return; } this.logger.info(`Recived data : ${data}`); + this.logger.info(`Stock id : ${message[0]['STOCK_ID']}`); const liveData = this.openapiLiveData.convertLiveData(message); - this.openapiLiveData.saveLiveData(liveData); + await this.openapiLiveData.saveLiveData(liveData); } catch (error) { this.logger.warn(error); } }); } - private parseMessage(data: string) { - if (typeof data === 'object') { + private parseMessage(data: RawData) { + if (typeof data === 'object' && !(data instanceof Buffer)) { return data; + } else if (typeof data === 'object') { + return parseMessage(data.toString()); } else { return parseMessage(data as string); } diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index be321f55..994a6de1 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -7,6 +7,7 @@ import { StockWeekly, StockYearly, } from './stockData.entity'; +import { StockLiveData } from './stockLiveData.entity'; import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; @@ -52,6 +53,9 @@ export class Stock { @OneToMany(() => StockYearly, (stockYearly) => stockYearly.stock) stockYearly?: StockYearly[]; + @OneToOne(() => StockLiveData, (stockLiveData) => stockLiveData.stock) + stockLive?: StockLiveData; + @OneToOne(() => KospiStock, (kospiStock) => kospiStock.stock) kospiStock?: KospiStock; } From 44bb8eceb6d2423492ad738abcb4542932fb136f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 19:39:40 +0900 Subject: [PATCH 097/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B1=B0=EB=9E=98?= =?UTF-8?q?=EC=A4=91=EC=9D=B8=20=EC=A2=85=EB=AA=A9=EB=A7=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.service.ts | 61 ++++++++++----------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 154eb9d6..c0e50a9c 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -69,11 +69,10 @@ export class StockService { } async searchStock(stockName: string) { - const queryBuilder = this.datasource + const result = await this.datasource .getRepository(Stock) - .createQueryBuilder(); - const result = await queryBuilder - .where('stock.stock_name LIKE :name', { + .createQueryBuilder('stock') + .where('stock.is_trading = :isTrading and stock.stock_name LIKE :name', { isTrading: true, name: `%${stockName}%`, }) @@ -94,6 +93,33 @@ export class StockService { } } + async getTopStocksByViews(limit: number) { + const rawData = await this.StocksQuery() + .orderBy('stock.views', 'DESC') + .limit(limit) + .getRawMany(); + + return plainToInstance(StocksResponse, rawData); + } + + async getTopStocksByGainers(limit: number) { + const rawData = await this.StocksQuery() + .orderBy('stockLiveData.changeRate', 'DESC') + .limit(limit) + .getRawMany(); + + return plainToInstance(StocksResponse, rawData); + } + + async getTopStocksByLosers(limit: number) { + const rawData = await this.StocksQuery() + .orderBy('stockLiveData.changeRate', 'ASC') + .limit(limit) + .getRawMany(); + + return plainToInstance(StocksResponse, rawData); + } + private async validateStockExists(stockId: string, manager: EntityManager) { if (!(await this.existsStock(stockId, manager))) { throw new BadRequestException('not exists stock'); @@ -150,31 +176,4 @@ export class StockService { 'stockDetail.marketCap AS marketCap', ]); } - - async getTopStocksByViews(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stock.views', 'DESC') - .limit(limit) - .getRawMany(); - - return plainToInstance(StocksResponse, rawData); - } - - async getTopStocksByGainers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'DESC') - .limit(limit) - .getRawMany(); - - return plainToInstance(StocksResponse, rawData); - } - - async getTopStocksByLosers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'ASC') - .limit(limit) - .getRawMany(); - - return plainToInstance(StocksResponse, rawData); - } } From a2c49b055558c5bd2740589631c17c6741311b21 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 21:39:59 +0900 Subject: [PATCH 098/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=90=9C=20=EB=8B=89=EB=84=A4=EC=9E=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/user/domain/user.entity.ts | 2 +- packages/backend/src/user/user.service.ts | 21 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 04f964cf..288c6fa0 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -20,7 +20,7 @@ export class User { @Column({ length: 50 }) nickname: string; - @Column({ length: 10 }) + @Column({ length: 10, default: '0001' }) subName: string; @Column({ length: 50 }) diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 46d1bbf7..2cfe63f8 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -32,6 +32,7 @@ export class UserService { async createSubName(nickname: string) { return this.dataSource.transaction(async (manager) => { + console.log(await this.existsUserByNickname(nickname, manager)); if (!(await this.existsUserByNickname(nickname, manager))) { return '0001'; } @@ -41,7 +42,7 @@ export class UserService { .select('MAX(user.subName)', 'max') .where('user.nickname = :nickname', { nickname }) .getRawOne(); - + console.log(maxSubName); return (parseInt(maxSubName.max, 10) + 1).toString().padStart(4, '0'); }); } @@ -51,15 +52,11 @@ export class UserService { } async registerTester() { - return await this.dataSource.transaction(async (manager) => { - return await manager.save(User, { - nickname: this.generateRandomNickname(), - email: 'tester@nav', - type: OauthType.LOCAL, - oauthId: String( - (await this.getMaxOauthId(OauthType.LOCAL, manager)) + 1, - ), - }); + return this.register({ + nickname: this.generateRandomNickname(), + email: 'tester@nav', + type: OauthType.LOCAL, + oauthId: String((await this.getMaxOauthId(OauthType.LOCAL)) + 1), }); } @@ -105,8 +102,8 @@ export class UserService { return `${statusName}${subjectName}`; } - private async getMaxOauthId(oauthType: OauthType, manager: EntityManager) { - const result = await manager + private async getMaxOauthId(oauthType: OauthType) { + const result = await this.dataSource.manager .createQueryBuilder(User, 'user') .select('MAX(user.oauthId)', 'max') .where('user.type = :oauthType', { oauthType }) From d0fadf7d5ebf5e832d3a866a342213612d4da95d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 22:28:41 +0900 Subject: [PATCH 099/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A9=98=EC=85=98=EC=9D=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.gateway.ts | 61 +++++++++++++------ packages/backend/src/chat/dto/chat.request.ts | 9 ++- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 0fd6a1f0..121804f9 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -18,20 +18,22 @@ import { WebsocketSessionService } from '@/auth/session/websocketSession.service import { MEMORY_STORE } from '@/auth/session.module'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; -import { ChatScrollQuery, isChatScrollQuery } from '@/chat/dto/chat.request'; +import { + ChatMessage, + ChatScrollQuery, + isChatScrollQuery, +} from '@/chat/dto/chat.request'; import { LikeResponse } from '@/chat/dto/like.response'; +import { MentionService } from '@/chat/mention.service'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; - -interface chatMessage { - room: string; - content: string; -} +import { User } from '@/user/domain/user.entity'; interface chatResponse { likeCount: number; message: string; type: string; + mentioned: boolean; createdAt: Date; } @@ -39,13 +41,15 @@ interface chatResponse { @UseFilters(WebSocketExceptionFilter) export class ChatGateway implements OnGatewayConnection { @WebSocketServer() - server: Server; - websocketSessionService: WebsocketSessionService; + private server: Server; + private websocketSessionService: WebsocketSessionService; + private users = new Map(); constructor( @Inject('winston') private readonly logger: Logger, private readonly stockService: StockService, private readonly chatService: ChatService, + private readonly mentionService: MentionService, @Inject(MEMORY_STORE) sessionStore: MemoryStore, ) { this.websocketSessionService = new WebsocketSessionService(sessionStore); @@ -54,10 +58,10 @@ export class ChatGateway implements OnGatewayConnection { @UseGuards(WebSocketSessionGuard) @SubscribeMessage('chat') async handleConnectStock( - @MessageBody() message: chatMessage, + @MessageBody() message: ChatMessage, @ConnectedSocket() client: SessionSocket, ) { - const { room, content } = message; + const { room, content, mention } = message; if (!client.rooms.has(room)) { client.emit('error', 'You are not in the room'); this.logger.warn(`client is not in the room ${room}`); @@ -72,6 +76,17 @@ export class ChatGateway implements OnGatewayConnection { stockId: room, message: content, }); + if (mention) { + await this.mentionService.createMention(savedChat.id, mention); + const mentionedSocket = this.users.get(Number(mention)); + if (mentionedSocket) { + const chatResponse = this.toResponse(savedChat); + this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); + chatResponse.mentioned = true; + this.server.to(mentionedSocket).emit('chat', chatResponse); + return; + } + } this.server.to(room).emit('chat', this.toResponse(savedChat)); } @@ -86,15 +101,12 @@ export class ChatGateway implements OnGatewayConnection { const { stockId, pageSize } = await this.getChatScrollQuery(client); await this.validateExistStock(stockId); client.join(stockId); - const messages = await this.chatService.scrollChat( - { - stockId, - pageSize, - }, - user?.id, - ); + const messages = await this.scrollChat(stockId, user, pageSize); this.logger.info(`client joined room ${stockId}`); client.emit('chat', messages); + if (user) { + this.users.set(user.id, client.id); + } } catch (e) { const error = e as Error; this.logger.warn(error.message); @@ -103,6 +115,20 @@ export class ChatGateway implements OnGatewayConnection { } } + private async scrollChat( + stockId: string, + user: User | null, + pageSize?: number, + ) { + return await this.chatService.scrollChat( + { + stockId, + pageSize, + }, + user?.id, + ); + } + private async validateExistStock(stockId: string): Promise { if (!(await this.stockService.checkStockExist(stockId))) { throw new Error(`Stock does not exist: ${stockId}`); @@ -126,6 +152,7 @@ export class ChatGateway implements OnGatewayConnection { likeCount: chat.likeCount, message: chat.message, type: chat.type, + mentioned: false, createdAt: chat.date?.createdAt || new Date(), }; } diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index 0c68943b..3d970fdd 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -33,17 +33,20 @@ export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { if (typeof object !== 'object' || object === null) { return false; } - if (!('stockId' in object) || typeof object.stockId !== 'string') { return false; } - if ( 'latestChatId' in object && !Number.isInteger(Number(object.latestChatId)) ) { return false; } - return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); } + +export interface ChatMessage { + room: string; + content: string; + mention?: number; +} From ba47d8bde3e1b8a7cdab5f005f33050ca8f8f07a Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 25 Nov 2024 23:16:41 +0900 Subject: [PATCH 100/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A9=98=EC=85=98?= =?UTF-8?q?=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=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/chat/domain/chat.entity.ts | 4 ++++ packages/backend/src/user/domain/user.entity.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index 2a5ab380..ccaec4c8 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -12,6 +12,7 @@ import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; +import { Mention } from '@/chat/domain/mention.entity'; @Entity() export class Chat { @@ -41,4 +42,7 @@ export class Chat { @Column(() => DateEmbedded, { prefix: '' }) date: DateEmbedded; + + @OneToMany(() => Mention, (mention) => mention.chat) + mentions: Mention[]; } diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 288c6fa0..9f93e968 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -9,6 +9,7 @@ import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; import { OauthType } from '@/user/domain/ouathType'; import { Role } from '@/user/domain/role'; +import { Mention } from '@/chat/domain/mention.entity'; @Index('nickname_sub_name', ['nickname', 'subName'], { unique: true }) @Index('type_oauth_id', ['type', 'oauthId'], { unique: true }) @@ -43,4 +44,7 @@ export class User { @OneToMany(() => UserStock, (userStock) => userStock.user) userStocks: UserStock[]; + + @OneToMany(() => Mention, (mention) => mention.user) + mentions: Mention[]; } From 2bc7699cc452f424c160665853ededcd329af681 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 11:05:29 +0900 Subject: [PATCH 101/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=EA=B2=80=EC=A6=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/mention.service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/chat/mention.service.ts b/packages/backend/src/chat/mention.service.ts index 3b5a36a2..f5e49133 100644 --- a/packages/backend/src/chat/mention.service.ts +++ b/packages/backend/src/chat/mention.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; import { Mention } from '@/chat/domain/mention.entity'; import { User } from '@/user/domain/user.entity'; @@ -12,7 +13,7 @@ export class MentionService { if (!(await this.existsChatAndUser(chatId, userId, manager))) { return null; } - return await this.dataSource.manager.save(Mention, { + return await manager.save(Mention, { chat: { id: chatId }, user: { id: userId }, }); @@ -27,8 +28,8 @@ export class MentionService { if (!(await manager.exists(User, { where: { id: userId } }))) { return false; } - return await manager.exists(Mention, { - where: { chat: { id: chatId }, user: { id: userId } }, + return await manager.exists(Chat, { + where: { id: chatId }, }); } } From 299d3b7b3c60d7cb1756110c1431ad9c33ea47a5 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 11:06:28 +0900 Subject: [PATCH 102/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=97=90=EC=84=9C=20=EB=A9=98?= =?UTF-8?q?=EC=85=98=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.ts | 49 ++++++++++++++----- .../backend/src/chat/dto/chat.response.ts | 2 + 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 80417656..03bdaa17 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -102,27 +102,41 @@ export class ChatService { userId?: number, order: Order = ORDER.LATEST, ) { - const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); const { stockId, latestChatId, pageSize } = chatScrollQuery; const size = pageSize ? pageSize : DEFAULT_PAGE_SIZE; + const queryBuilder = await this.buildInitialChatScrollQuery( + stockId, + size, + userId, + ); + if (order === ORDER.LIKE) { + return this.buildLikeCountQuery(queryBuilder, latestChatId); + } + return this.buildLatestChatIdQuery(queryBuilder, latestChatId); + } - queryBuilder + private async buildInitialChatScrollQuery( + stockId: string, + size: number, + userId?: number, + ) { + console.log('stockId', stockId); + return this.dataSource + .createQueryBuilder(Chat, 'chat') .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { userId, }) + .leftJoinAndSelect( + 'chat.mentions', + 'mention', + 'mention.user_id = :userId', + { + userId, + }, + ) .leftJoinAndSelect('chat.user', 'user') .where('chat.stock_id = :stockId', { stockId }) .take(size + 1); - - if (order === ORDER.LIKE) { - return this.buildLikeCountQuery(queryBuilder, latestChatId); - } - queryBuilder.orderBy('chat.id', 'DESC'); - if (latestChatId) { - queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); - } - - return queryBuilder; } private async buildLikeCountQuery( @@ -150,4 +164,15 @@ export class ChatService { } return queryBuilder; } + + private async buildLatestChatIdQuery( + queryBuilder: SelectQueryBuilder, + latestChatId?: number, + ) { + queryBuilder.orderBy('chat.id', 'DESC'); + if (latestChatId) { + queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); + } + return queryBuilder; + } } diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index bf42c903..68a81b4d 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -9,6 +9,7 @@ interface ChatResponse { type: string; liked: boolean; nickname: string; + mentioned: boolean; createdAt: Date; } @@ -43,6 +44,7 @@ export class ChatScrollResponse { type: chat.type, createdAt: chat.date!.createdAt, liked: !!(chat.likes && chat.likes.length > 0), + mentioned: chat.mentions && chat.mentions.length > 0, nickname: chat.user.nickname, })); this.hasMore = hasMore; From 52f830fca4dbf106de726f844f75be9de7357990 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 11:43:38 +0900 Subject: [PATCH 103/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=EA=B3=BC=20=EC=84=9C=EB=B8=8C=EB=84=A4=EC=9E=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9C=A0=EC=A0=80=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/user/dto/User.response.ts | 33 +++++++++++++++++++ packages/backend/src/user/user.controller.ts | 26 ++++++++++++--- packages/backend/src/user/user.service.ts | 11 ++++++- 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/user/dto/User.response.ts diff --git a/packages/backend/src/user/dto/User.response.ts b/packages/backend/src/user/dto/User.response.ts new file mode 100644 index 00000000..98f37930 --- /dev/null +++ b/packages/backend/src/user/dto/User.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '@/user/domain/user.entity'; + +interface UserResponse { + id: number; + nickname: string; + subName: string; + createdAt: Date; +} + +export class UserSearchResult { + @ApiProperty({ + description: '유저 검색 결과', + example: [ + { + id: 1, + nickname: 'nickname', + subName: 'subName', + createdAt: new Date(), + }, + ], + }) + result: UserResponse[]; + + constructor(users: User[]) { + this.result = users.map((user) => ({ + id: user.id, + nickname: user.nickname, + subName: user.subName, + createdAt: user.date.createdAt, + })); + } +} diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 748ae2b7..17d88491 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -1,11 +1,12 @@ import { - Controller, - Patch, - Param, Body, + Controller, + Get, HttpCode, HttpStatus, - Get, + Param, + Patch, + Query, } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; @@ -15,6 +16,23 @@ import { UserService } from './user.service'; export class UserController { constructor(private readonly userService: UserService) {} + @Get() + @ApiOperation({ + summary: '유저 닉네임과 서브 닉네임으로 유저 조회 API', + description: '유저 닉네임과 서브 닉네임으로 유저를 조회합니다.', + }) + @ApiParam({ name: 'nickname', type: 'string', description: '유저 닉네임' }) + @ApiParam({ name: 'subName', type: 'string', description: '유저 서브네임' }) + async searchUser( + @Query('nickname') nickname: string, + @Query('subName') subName: string, + ) { + return await this.userService.searchUserByNicknameAndSubName( + nickname, + subName, + ); + } + @Patch(':id/theme') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 2cfe63f8..f059c5f9 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -3,10 +3,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource, EntityManager, Like } from 'typeorm'; import { OauthType } from './domain/ouathType'; import { User } from './domain/user.entity'; import { status, subject } from '@/user/constants/randomNickname'; +import { UserSearchResult } from '@/user/dto/User.response'; type RegisterRequest = Required< Pick @@ -30,6 +31,14 @@ export class UserService { }); } + async searchUserByNicknameAndSubName(nickname: string, subName: string) { + const users = await this.dataSource.manager.find(User, { + where: { nickname: Like(`%${nickname}%`), subName }, + take: 10, + }); + return new UserSearchResult(users); + } + async createSubName(nickname: string) { return this.dataSource.transaction(async (manager) => { console.log(await this.existsUserByNickname(nickname, manager)); From 7621b73363271bbb4f6701ab420507167d1d333b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 11:49:25 +0900 Subject: [PATCH 104/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20like=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/user/user.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index f059c5f9..a132262d 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -31,9 +31,9 @@ export class UserService { }); } - async searchUserByNicknameAndSubName(nickname: string, subName: string) { + async searchUserByNicknameAndSubName(nickname: string, subName?: string) { const users = await this.dataSource.manager.find(User, { - where: { nickname: Like(`%${nickname}%`), subName }, + where: { nickname: Like(`%${nickname}%`), subName: Like(`${subName}%`) }, take: 10, }); return new UserSearchResult(users); From eafa2b329f8230be4079198b75821ec16bdcc5c0 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:51:53 +0900 Subject: [PATCH 105/223] =?UTF-8?q?livedata=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20openapi=EB=A1=9C=20=EC=9E=A5=EC=8B=9C?= =?UTF-8?q?=EA=B0=84,=EB=A7=88=EA=B0=90=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81,=20token=EC=9D=84=20=EC=A3=BC=EC=9E=85=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C=20=EB=B3=80=EA=B2=BD=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 --- packages/backend/src/configs/typeormConfig.ts | 2 +- .../openapi/api/openapiDetailData.api.ts | 6 +- .../openapi/api/openapiLiveData.api.ts | 52 +++++- .../openapi/api/openapiMinuteData.api.ts | 6 +- .../openapi/api/openapiPeriodData.api.ts | 4 +- .../scraper/openapi/api/openapiToken.api.ts | 22 +-- .../src/scraper/openapi/liveData.service.ts | 172 ++++++++++++++++++ .../scraper/openapi/openapi-scraper.module.ts | 6 +- .../openapi/type/openapiLiveData.type.ts | 105 +++++++++++ .../scraper/openapi/type/openapiUtil.type.ts | 2 + .../websocket/websocketClient.websocket.ts | 61 +++++++ .../openapi/websocketClient.service.ts | 150 --------------- .../src/stock/domain/stockLiveData.entity.ts | 3 - packages/backend/src/stock/stock.gateway.ts | 8 +- packages/backend/src/stock/stock.module.ts | 8 + 15 files changed, 425 insertions(+), 182 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/liveData.service.ts create mode 100644 packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts delete mode 100644 packages/backend/src/scraper/openapi/websocketClient.service.ts diff --git a/packages/backend/src/configs/typeormConfig.ts b/packages/backend/src/configs/typeormConfig.ts index f8ff59ac..a8c70a18 100644 --- a/packages/backend/src/configs/typeormConfig.ts +++ b/packages/backend/src/configs/typeormConfig.ts @@ -21,6 +21,6 @@ export const typeormDevelopConfig: TypeOrmModuleOptions = { password: process.env.DB_PASS, database: process.env.DB_NAME, entities: [__dirname + '/../**/*.entity.{js,ts}'], - logging: true, + //logging: true, synchronize: true, }; diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 6a32435f..982ebaf6 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -39,13 +39,13 @@ export class OpenapiDetailData { if (process.env.NODE_ENV !== 'production') return; const entityManager = this.datasource.manager; const stocks = await entityManager.find(Stock); - const configCount = this.openApiToken.configs.length; + const configCount = (await this.openApiToken.configs()).length; const chunkSize = Math.ceil(stocks.length / configCount); for (let i = 0; i < configCount; i++) { - this.logger.info(this.openApiToken.configs[i]); + this.logger.info((await this.openApiToken.configs())[i]); const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getDetailDataChunk(chunk, this.openApiToken.configs[i]); + this.getDetailDataChunk(chunk, (await this.openApiToken.configs())[i]); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 6fa15505..7db12b2c 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -1,12 +1,17 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { isOpenapiLiveData } from '../type/openapiLiveData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getOpenApi } from '../util/openapiUtil.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @Injectable() export class OpenapiLiveData { - public readonly TR_ID: string = 'H0STCNT0'; + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-ccnl'; constructor( private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, @@ -37,6 +42,26 @@ export class OpenapiLiveData { } } + // 현재가 체결로는 데이터가 부족해 현재가 시세를 사용함. + convertResponseToStockLiveData = ( + data: OpenapiLiveData, + stockId: string, + ): StockLiveData | undefined => { + const stockLiveData = new StockLiveData(); + if (isOpenapiLiveData(data)) { + stockLiveData.stock = { id: stockId } as Stock; + stockLiveData.currentPrice = parseFloat(data.stck_prpr); + stockLiveData.changeRate = parseFloat(data.prdy_ctrt); + stockLiveData.volume = parseInt(data.acml_vol); + stockLiveData.high = parseFloat(data.stck_hgpr); + stockLiveData.low = parseFloat(data.stck_lwpr); + stockLiveData.open = parseFloat(data.stck_oprc); + stockLiveData.updatedAt = new Date(); + + return stockLiveData; + } + }; + convertLiveData(messages: Record[]): StockLiveData[] { const stockData: StockLiveData[] = []; messages.map((message) => { @@ -48,10 +73,33 @@ export class OpenapiLiveData { stockLiveData.high = parseFloat(message.STCK_HGPR); stockLiveData.low = parseFloat(message.STCK_LWPR); stockLiveData.open = parseFloat(message.STCK_OPRC); - stockLiveData.previousClose = parseFloat(message.WGHN_AVRG_STCK_PRC); stockLiveData.updatedAt = new Date(); + stockData.push(stockLiveData); }); return stockData; } + + async connectLiveData(stockId: string, config: typeof openApiConfig) { + const query = this.makeLiveDataQuery(stockId); + + try { + const result = await getOpenApi( + this.url, + config, + query, + TR_IDS.LIVE_DATA, + ); + return result; + } catch (error) { + this.logger.warn(`Connect live data error : ${error}`); + } + } + + private makeLiveDataQuery(stockId: string, code: 'J' = 'J') { + return { + fid_cond_mrkt_div_code: code, + fid_input_iscd: stockId, + }; + } } diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index e64e26c7..4e193b74 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -128,15 +128,15 @@ export class OpenapiMinuteData { } //@Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) - getMinuteData() { + async getMinuteData() { if (process.env.NODE_ENV !== 'production') return; - const configCount = this.openApiToken.configs.length; + const configCount = (await this.openApiToken.configs()).length; const stock = this.stock[this.flip % STOCK_CUT]; this.flip++; const chunkSize = Math.ceil(stock.length / configCount); for (let i = 0; i < configCount; i++) { const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, this.openApiToken.configs[i]); + this.getMinuteDataChunk(chunk, (await this.openApiToken.configs())[i]); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 4b976be8..5471cf5e 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -96,7 +96,7 @@ export class OpenapiPeriodData { let isFail = false; while (!isFail) { - configIdx = (configIdx + 1) % this.openApiToken.configs.length; + configIdx = (configIdx + 1) % (await this.openApiToken.configs()).length; this.setStockPeriod(stockPeriod, stock.id!, end); // chart 데이터가 있는 지 확인 -> 리턴 @@ -130,7 +130,7 @@ export class OpenapiPeriodData { try { const response = await getOpenApi( this.url, - this.openApiToken.configs[configIdx], + (await this.openApiToken.configs())[configIdx], query, TR_IDS.ITEM_CHART_PRICE, ); diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 829ca9a3..fee72534 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -15,7 +15,6 @@ export class OpenapiTokenApi { @Inject('winston') private readonly logger: Logger, private readonly datasource: DataSource, ) { - if (process.env.NODE_ENV !== 'production') return; const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); const api_keys = openApiConfig.STOCK_API_KEY!.split(','); const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); @@ -34,17 +33,16 @@ export class OpenapiTokenApi { STOCK_API_PASSWORD: api_passwords[i], }); } - this.init(); } - get configs() { - this.init(); + async configs() { + await this.init(); return this.config; } @Cron('30 0 * * 1-5') async init() { - const tokens = await this.convertConfigToTokenEntity(this.config); + const tokens = this.convertConfigToTokenEntity(this.config); const config = await this.getPropertyFromDB(tokens); const expired = config.filter( (val) => @@ -54,24 +52,24 @@ export class OpenapiTokenApi { if (expired.length || !config.length) { await this.initAuthenValue(); - const newTokens = await this.convertConfigToTokenEntity(this.config); - this.savePropertyToDB(newTokens); + const newTokens = this.convertConfigToTokenEntity(this.config); + await this.savePropertyToDB(newTokens); } else { - this.config = await this.convertTokenEntityToConfig(config); + this.config = this.convertTokenEntityToConfig(config); } } private isTokenExpired(startDate?: Date) { if (!startDate) return true; const now = new Date(); - //실제 만료 시간은 24시간이지만, 문제의 소지가 발생하는 것을 방지하기 위해 20시간으로 설정함. + //실제 만료 시간은 24시간이지만, 문제가 발생할 여지를 줄이기 위해 20시간으로 설정 const baseTimeToMilliSec = 20 * 60 * 60 * 1000; const timeDiff = now.getTime() - startDate.getTime(); return timeDiff >= baseTimeToMilliSec; } - private async convertTokenEntityToConfig(tokens: OpenapiToken[]) { + private convertTokenEntityToConfig(tokens: OpenapiToken[]) { const result: (typeof openApiConfig)[] = []; tokens.forEach((val) => { const config: typeof openApiConfig = { @@ -87,7 +85,7 @@ export class OpenapiTokenApi { return result; } - private async convertConfigToTokenEntity(config: (typeof openApiConfig)[]) { + private convertConfigToTokenEntity(config: (typeof openApiConfig)[]) { const result: OpenapiToken[] = []; config.forEach((val) => { const token = new OpenapiToken(); @@ -171,6 +169,7 @@ export class OpenapiTokenApi { }), ); this.config = updatedConfig; + this.logger.info(`Init access token : ${this.config}`); } private async initWebSocketKey() { @@ -181,6 +180,7 @@ export class OpenapiTokenApi { }), ); this.config = updatedConfig; + this.logger.info(`Init websocket token : ${this.config}`); } private async getToken(config: typeof openApiConfig): Promise { diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts new file mode 100644 index 00000000..68ed8c0f --- /dev/null +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -0,0 +1,172 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; +import { RawData, WebSocket } from 'ws'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { OpenapiTokenApi } from './api/openapiToken.api'; +import { openApiConfig } from './config/openapi.config'; +import { parseMessage } from './parse/openapi.parser'; +import { WebsocketClient } from './websocket/websocketClient.websocket'; + +type TR_IDS = '0' | '1'; + +@Injectable() +export class LiveData { + private readonly clientStock: Set = new Set(); + private readonly reconnectInterval = 60 * 1000 * 1000; + + private readonly startTime: Date = new Date(2024, 0, 1, 9, 0, 0, 0); + private readonly endTime: Date = new Date(2024, 0, 1, 15, 30, 0, 0); + constructor( + private readonly openApiToken: OpenapiTokenApi, + private readonly webSocketClient: WebsocketClient, + private readonly openapiLiveData: OpenapiLiveData, + @Inject('winston') private readonly logger: Logger, + ) { + this.connect(); + this.subscribe('000020'); + } + + private async openapiSubscribe(stockId: string) { + const config = (await this.openApiToken.configs())[0]; + const result = await this.openapiLiveData.connectLiveData(stockId, config); + this.logger.info(JSON.stringify(result)); + try { + const stockLiveData = this.openapiLiveData.convertResponseToStockLiveData( + result.output, + stockId, + ); + if (stockLiveData) { + this.openapiLiveData.saveLiveData([stockLiveData]); + } + } catch (error) { + this.logger.warn(`Subscribe error in open api : ${error}`); + } + } + + async subscribe(stockId: string) { + if (this.isCloseTime(new Date(), this.startTime, this.endTime)) { + await this.openapiSubscribe(stockId); + } else { + // TODO : 하나의 config만 사용중. + this.clientStock.add(stockId); + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[0], + stockId, + '1', + ); + this.webSocketClient.subscribe(message); + } + } + + async discribe(stockId: string) { + if (this.clientStock.has(stockId)) { + this.clientStock.delete(stockId); + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[0], + stockId, + '0', + ); + this.webSocketClient.discribe(message); + } + } + + private initOpenCallback = + (sendMessage: (message: string) => void) => async () => { + this.logger.info('WebSocket connection established'); + for (const stockId of this.clientStock.keys()) { + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[0], + stockId, + '1', + ); + sendMessage(message); + } + }; + + private initMessageCallback = + (client: WebSocket) => async (data: RawData) => { + try { + const message = this.parseMessage(data); + if (message.header) { + if (message.header.tr_id === 'PINGPONG') { + this.logger.info(`Received PING: ${data}`); + client.pong(data); + } + return; + } + this.logger.info(`Recived data : ${data}`); + this.logger.info(`Stock id : ${message[0]['STOCK_ID']}`); + const liveData = this.openapiLiveData.convertLiveData(message); + await this.openapiLiveData.saveLiveData(liveData); + } catch (error) { + this.logger.warn(error); + } + }; + + private initCloseCallback = () => { + this.logger.warn( + `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, + ); + }; + + private initErrorCallback = (error: unknown) => { + if (error instanceof Error) { + this.logger.error(`WebSocket error: ${error.message}`); + } else { + this.logger.error('WebSocket error: callback function'); + } + setTimeout(() => this.connect(), this.reconnectInterval); + }; + + private isCloseTime(date: Date, start: Date, end: Date): boolean { + const dateMinutes = date.getHours() * 60 + date.getMinutes(); + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + + return dateMinutes <= startMinutes || dateMinutes >= endMinutes; + } + + @Cron('0 2 * * 1-5') + connect() { + this.webSocketClient.connectPacade( + this.initOpenCallback, + this.initMessageCallback, + this.initCloseCallback, + this.initErrorCallback, + ); + } + + private convertObjectToMessage( + config: typeof openApiConfig, + stockId: string, + tr_type: TR_IDS, + ): string { + this.logger.info(JSON.stringify(config)); + const message = { + header: { + approval_key: config.STOCK_WEBSOCKET_KEY!, + custtype: 'P', + tr_type, + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: 'H0STCNT0', + tr_key: stockId, + }, + }, + }; + return JSON.stringify(message); + } + + private parseMessage(data: RawData) { + if (typeof data === 'object' && !(data instanceof Buffer)) { + return data; + } else if (typeof data === 'object') { + return parseMessage(data.toString()); + } else { + return parseMessage(data as string); + } + } +} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 73f33f23..4afbf154 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,12 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenapiDetailData } from './api/openapiDetailData.api'; -import { OpenapiLiveData } from './api/openapiLiveData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; -import { WebsocketClient } from './websocketClient.service'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily, @@ -38,9 +36,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiMinuteData, OpenapiDetailData, OpenapiScraperService, - OpenapiLiveData, - WebsocketClient, + OpenapiTokenApi, ], - exports: [WebsocketClient], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts index e1687cee..18bead3e 100644 --- a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts @@ -148,3 +148,108 @@ export const stockDataKeys = [ 'MRKT_TRTM_CLS_CODE', 'VI_STND_PRC', ]; + +export type OpenapiLiveData = { + iscd_stat_cls_code: string; + marg_rate: string; + rprs_mrkt_kor_name: string; + bstp_kor_isnm: string; + temp_stop_yn: string; + oprc_rang_cont_yn: string; + clpr_rang_cont_yn: string; + crdt_able_yn: string; + grmn_rate_cls_code: string; + elw_pblc_yn: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + acml_tr_pbmn: string; + acml_vol: string; + prdy_vrss_vol_rate: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_mxpr: string; + stck_llam: string; + stck_sdpr: string; + wghn_avrg_stck_prc: string; + hts_frgn_ehrt: string; + frgn_ntby_qty: string; + pgtr_ntby_qty: string; + pvt_scnd_dmrs_prc: string; + pvt_frst_dmrs_prc: string; + pvt_pont_val: string; + pvt_frst_dmsp_prc: string; + pvt_scnd_dmsp_prc: string; + dmrs_val: string; + dmsp_val: string; + cpfn: string; + rstc_wdth_prc: string; + stck_fcam: string; + stck_sspr: string; + aspr_unit: string; + hts_deal_qty_unit_val: string; + lstn_stcn: string; + hts_avls: string; + per: string; + pbr: string; + stac_month: string; + vol_tnrt: string; + eps: string; + bps: string; + d250_hgpr: string; + d250_hgpr_date: string; + d250_hgpr_vrss_prpr_rate: string; + d250_lwpr: string; + d250_lwpr_date: string; + d250_lwpr_vrss_prpr_rate: string; + stck_dryy_hgpr: string; + dryy_hgpr_vrss_prpr_rate: string; + dryy_hgpr_date: string; + stck_dryy_lwpr: string; + dryy_lwpr_vrss_prpr_rate: string; + dryy_lwpr_date: string; + w52_hgpr: string; + w52_hgpr_vrss_prpr_ctrt: string; + w52_hgpr_date: string; + w52_lwpr: string; + w52_lwpr_vrss_prpr_ctrt: string; + w52_lwpr_date: string; + whol_loan_rmnd_rate: string; + ssts_yn: string; + stck_shrn_iscd: string; + fcam_cnnm: string; + cpfn_cnnm: string; + frgn_hldn_qty: string; + vi_cls_code: string; + ovtm_vi_cls_code: string; + last_ssts_cntg_qty: string; + invt_caful_yn: string; + mrkt_warn_cls_code: string; + short_over_yn: string; + sltr_yn: string; +}; + +export const isOpenapiLiveData = (data: any): data is OpenapiLiveData => { + return ( + typeof data === 'object' && + data !== null && + typeof data.iscd_stat_cls_code === 'string' && + typeof data.marg_rate === 'string' && + typeof data.rprs_mrkt_kor_name === 'string' && + typeof data.bstp_kor_isnm === 'string' && + typeof data.temp_stop_yn === 'string' && + typeof data.oprc_rang_cont_yn === 'string' && + typeof data.clpr_rang_cont_yn === 'string' && + typeof data.crdt_able_yn === 'string' && + typeof data.stck_prpr === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.acml_vol === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.wghn_avrg_stck_prc === 'string' && + typeof data.stck_shrn_iscd === 'string' + ); +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index 6df0ca19..e9f0869d 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -3,6 +3,7 @@ export type TR_ID = | 'FHKST03010200' | 'FHKST66430300' | 'HHKDB669107C0' + | 'FHKST01010100' | 'CTPF1002R'; export const TR_IDS: Record = { @@ -10,4 +11,5 @@ export const TR_IDS: Record = { MINUTE_DATA: 'FHKST03010200', FINANCIAL_DATA: 'FHKST66430300', PRODUCTION_DETAIL: 'CTPF1002R', + LIVE_DATA: 'FHKST01010100', }; diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts new file mode 100644 index 00000000..06451a53 --- /dev/null +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'winston'; +import { RawData, WebSocket } from 'ws'; + +@Injectable() +export class WebsocketClient { + private readonly url = + process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + private client: WebSocket = new WebSocket(this.url); + + constructor(@Inject('winston') private readonly logger: Logger) {} + + subscribe(message: string) { + this.logger.info(`Subscribe : ${message}`); + this.sendMessage(message); + } + + discribe(message: string) { + this.logger.info(`Discribe : ${message}`); + this.sendMessage(message); + } + + // TODO : 분리 + private initDisconnect( + initCloseCallback: () => void, + initErrorCallback: (error: unknown) => void, + ) { + this.client.on('close', initCloseCallback); + + this.client.on('error', initErrorCallback); + } + + private initOpen(fn: () => void) { + this.client.on('open', fn); + } + + private initMessage(fn: (data: RawData) => void) { + this.client.on('message', fn); + } + + connectPacade( + initOpenCallback: (fn: (message: string) => void) => () => void, + initMessageCallback: (client: WebSocket) => (data: RawData) => void, + initCloseCallback: () => void, + initErrorCallback: (error: unknown) => void, + ) { + this.initOpen(initOpenCallback(this.sendMessage)); + this.initMessage(initMessageCallback(this.client)); + this.initDisconnect(initCloseCallback, initErrorCallback); + } + + private sendMessage(message: string) { + if (this.client.readyState === WebSocket.OPEN) { + this.client.send(message); + this.logger.info(`Sent message: ${message}`); + } else { + this.logger.warn('WebSocket is not open. Message not sent.'); + } + } +} diff --git a/packages/backend/src/scraper/openapi/websocketClient.service.ts b/packages/backend/src/scraper/openapi/websocketClient.service.ts deleted file mode 100644 index e784afe5..00000000 --- a/packages/backend/src/scraper/openapi/websocketClient.service.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { Logger } from 'winston'; -import { RawData, WebSocket } from 'ws'; -import { OpenapiLiveData } from './api/openapiLiveData.api'; -import { OpenapiTokenApi } from './api/openapiToken.api'; -import { openApiConfig } from './config/openapi.config'; -import { parseMessage } from './parse/openapi.parser'; - -type TR_IDS = '0' | '1'; - -@Injectable() -export class WebsocketClient { - private client: WebSocket; - private readonly reconnectInterval = 60000; - private readonly url = - process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; - private readonly clientStock: Set = new Set(); - - constructor( - @Inject('winston') private readonly logger: Logger, - private readonly openApiToken: OpenapiTokenApi, - private readonly openapiLiveData: OpenapiLiveData, - ) { - if (process.env.NODE_ENV === 'production') { - this.connect(); - } - } - - // TODO : subscribe 구조로 리팩토링 - subscribe(stockId: string) { - this.clientStock.add(stockId); - // TODO : 하나의 config만 사용중. - const message = this.convertObjectToMessage( - this.openApiToken.configs[0], - stockId, - '1', - ); - this.sendMessage(message); - } - - discribe(stockId: string) { - this.clientStock.delete(stockId); - const message = this.convertObjectToMessage( - this.openApiToken.configs[0], - stockId, - '0', - ); - this.sendMessage(message); - } - - private initDisconnect() { - this.client.on('close', () => { - this.logger.warn( - `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, - ); - }); - - this.client.on('error', (error: any) => { - this.logger.error(`WebSocket error: ${error.message}`); - setTimeout(() => this.connect(), this.reconnectInterval); - }); - } - - private initOpen() { - this.client.on('open', () => { - this.logger.info('WebSocket connection established'); - for (const stockId of this.clientStock.keys()) { - const message = this.convertObjectToMessage( - this.openApiToken.configs[0], - stockId, - '1', - ); - this.sendMessage(message); - } - }); - } - - private initMessage() { - this.client.on('message', async (data: RawData) => { - try { - console.log(data); - const message = this.parseMessage(data); - if (message.header) { - if (message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${data}`); - this.client.pong(data); - } - return; - } - this.logger.info(`Recived data : ${data}`); - this.logger.info(`Stock id : ${message[0]['STOCK_ID']}`); - const liveData = this.openapiLiveData.convertLiveData(message); - await this.openapiLiveData.saveLiveData(liveData); - } catch (error) { - this.logger.warn(error); - } - }); - } - - private parseMessage(data: RawData) { - if (typeof data === 'object' && !(data instanceof Buffer)) { - return data; - } else if (typeof data === 'object') { - return parseMessage(data.toString()); - } else { - return parseMessage(data as string); - } - } - - @Cron('0 2 * * 1-5') - connect() { - this.client = new WebSocket(this.url); - this.initOpen(); - this.initMessage(); - this.initDisconnect(); - } - - private convertObjectToMessage( - config: typeof openApiConfig, - stockId: string, - tr_type: TR_IDS, - ): string { - const message = { - header: { - approval_key: config.STOCK_WEBSOCKET_KEY!, - custtype: 'P', - tr_type, - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: 'H0STCNT0', - tr_key: stockId, - }, - }, - }; - return JSON.stringify(message); - } - - private sendMessage(message: string) { - if (this.client.readyState === WebSocket.OPEN) { - this.client.send(message); - this.logger.info(`Sent message: ${message}`); - } else { - this.logger.warn('WebSocket is not open. Message not sent.'); - } - } -} diff --git a/packages/backend/src/stock/domain/stockLiveData.entity.ts b/packages/backend/src/stock/domain/stockLiveData.entity.ts index bf82f8d6..ea480d6b 100644 --- a/packages/backend/src/stock/domain/stockLiveData.entity.ts +++ b/packages/backend/src/stock/domain/stockLiveData.entity.ts @@ -31,9 +31,6 @@ export class StockLiveData { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) - previousClose: number; - @UpdateDateColumn() @Column({ type: 'timestamp' }) updatedAt: Date; diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 1f4ab32d..2674690d 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -6,6 +6,7 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { LiveData } from '@/scraper/openapi/liveData.service'; @WebSocketGateway({ namespace: '/api/stock/realtime', @@ -14,15 +15,18 @@ export class StockGateway { @WebSocketServer() server: Server; - constructor() {} + constructor(private readonly liveData: LiveData) {} @SubscribeMessage('connectStock') - handleConnectStock( + async handleConnectStock( @MessageBody() stockId: string, @ConnectedSocket() client: Socket, ) { client.join(stockId); + if ((await this.server.in(stockId).fetchSockets()).length === 0) { + this.liveData.subscribe(stockId); + } client.emit('connectionSuccess', { message: `Successfully connected to stock room: ${stockId}`, stockId, diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 13df81b0..3008feb8 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -23,6 +23,10 @@ import { } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { LiveData } from '@/scraper/openapi/liveData.service'; +import { WebsocketClient } from '@/scraper/openapi/websocket/websocketClient.websocket'; @Module({ imports: [ @@ -40,6 +44,10 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; controllers: [StockController], providers: [ StockService, + WebsocketClient, + OpenapiTokenApi, + OpenapiLiveData, + LiveData, StockGateway, StockLiveDataSubscriber, StockDataService, From 0672bf4c65507e388d82fdb4511b8866c745e865 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 15:38:24 +0900 Subject: [PATCH 106/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A3=BC?= =?UTF-8?q?=EC=8B=9D=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=98=EB=AA=BB=EB=90=9C=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stock.controller.ts | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 5ce27bf9..6f18c657 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -177,6 +177,40 @@ export class StockController { return await this.stockService.searchStock(request.name); } + @Get('topViews') + @ApiGetStocks('조회수 기반 주식 리스트 조회 API') + 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.getTopStocksByGainers(limit); + } + + @Get('topLosers') + @ApiGetStocks('가격 하락률 기반 주식 리스트 조회 API') + async getTopStocksByLosers(@LimitQuery(20) limit: number) { + return await this.stockService.getTopStocksByLosers(limit); + } + + @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); + } + @Get('/:stockId') @ApiGetStockData('주식 시간 단위 데이터 조회 API', '일') async getStockDataDaily( @@ -198,40 +232,6 @@ 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); - } - - @Get('topViews') - @ApiGetStocks('조회수 기반 주식 리스트 조회 API') - 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.getTopStocksByGainers(limit); - } - - @Get('topLosers') - @ApiGetStocks('가격 하락률 기반 주식 리스트 조회 API') - async getTopStocksByLosers(@LimitQuery(20) limit: number) { - return await this.stockService.getTopStocksByLosers(limit); - } - private getStockDataYearly( stockId: string, lastStartTime: string | undefined, From 7799a4cefff9aca1455751ed2dd54f450d0bf8a9 Mon Sep 17 00:00:00 2001 From: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:13:53 +0900 Subject: [PATCH 107/223] =?UTF-8?q?Feature/#110=20-=20=ED=8A=B9=EC=A0=95?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A5=BC=20=EB=A9=98=EC=85=98?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20(#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: 유저 서브네임 생성 로직 구현 * ✨ feat: 멘션 엔티티 구현 * ✨ feat: 특정 유저를 멘션하는 기능 구현 * 🐛 fix: 중복된 닉네임 테스터 에러 수정 * ✨ feat: 채팅으로 멘션을 진행 * ✨ feat: 멘션 연관관계 설정 * 🐛 fix: 잘못된 검증으로 멘션이 안되는 문제 해결 * ✨ feat: 채팅스크롤에서 멘션 필드 추가 * ✨ feat: 닉네임과 서브네임으로 유저 닉네임 검색 * ✨ feat: 서브네임 like 적용 --- packages/backend/src/chat/chat.gateway.ts | 61 +++++++++++++----- packages/backend/src/chat/chat.module.ts | 10 ++- packages/backend/src/chat/chat.service.ts | 49 +++++++++++---- .../backend/src/chat/domain/chat.entity.ts | 4 ++ .../backend/src/chat/domain/like.entity.ts | 2 +- .../backend/src/chat/domain/mention.entity.ts | 28 +++++++++ packages/backend/src/chat/dto/chat.request.ts | 9 ++- .../backend/src/chat/dto/chat.response.ts | 2 + packages/backend/src/chat/mention.service.ts | 35 +++++++++++ .../backend/src/user/domain/user.entity.ts | 8 +++ .../backend/src/user/dto/User.response.ts | 33 ++++++++++ packages/backend/src/user/user.controller.ts | 26 ++++++-- .../backend/src/user/user.service.spec.ts | 63 ++++++++++++++++--- packages/backend/src/user/user.service.ts | 52 +++++++++++---- 14 files changed, 323 insertions(+), 59 deletions(-) create mode 100644 packages/backend/src/chat/domain/mention.entity.ts create mode 100644 packages/backend/src/chat/mention.service.ts create mode 100644 packages/backend/src/user/dto/User.response.ts diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 0fd6a1f0..121804f9 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -18,20 +18,22 @@ import { WebsocketSessionService } from '@/auth/session/websocketSession.service import { MEMORY_STORE } from '@/auth/session.module'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; -import { ChatScrollQuery, isChatScrollQuery } from '@/chat/dto/chat.request'; +import { + ChatMessage, + ChatScrollQuery, + isChatScrollQuery, +} from '@/chat/dto/chat.request'; import { LikeResponse } from '@/chat/dto/like.response'; +import { MentionService } from '@/chat/mention.service'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; - -interface chatMessage { - room: string; - content: string; -} +import { User } from '@/user/domain/user.entity'; interface chatResponse { likeCount: number; message: string; type: string; + mentioned: boolean; createdAt: Date; } @@ -39,13 +41,15 @@ interface chatResponse { @UseFilters(WebSocketExceptionFilter) export class ChatGateway implements OnGatewayConnection { @WebSocketServer() - server: Server; - websocketSessionService: WebsocketSessionService; + private server: Server; + private websocketSessionService: WebsocketSessionService; + private users = new Map(); constructor( @Inject('winston') private readonly logger: Logger, private readonly stockService: StockService, private readonly chatService: ChatService, + private readonly mentionService: MentionService, @Inject(MEMORY_STORE) sessionStore: MemoryStore, ) { this.websocketSessionService = new WebsocketSessionService(sessionStore); @@ -54,10 +58,10 @@ export class ChatGateway implements OnGatewayConnection { @UseGuards(WebSocketSessionGuard) @SubscribeMessage('chat') async handleConnectStock( - @MessageBody() message: chatMessage, + @MessageBody() message: ChatMessage, @ConnectedSocket() client: SessionSocket, ) { - const { room, content } = message; + const { room, content, mention } = message; if (!client.rooms.has(room)) { client.emit('error', 'You are not in the room'); this.logger.warn(`client is not in the room ${room}`); @@ -72,6 +76,17 @@ export class ChatGateway implements OnGatewayConnection { stockId: room, message: content, }); + if (mention) { + await this.mentionService.createMention(savedChat.id, mention); + const mentionedSocket = this.users.get(Number(mention)); + if (mentionedSocket) { + const chatResponse = this.toResponse(savedChat); + this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); + chatResponse.mentioned = true; + this.server.to(mentionedSocket).emit('chat', chatResponse); + return; + } + } this.server.to(room).emit('chat', this.toResponse(savedChat)); } @@ -86,15 +101,12 @@ export class ChatGateway implements OnGatewayConnection { const { stockId, pageSize } = await this.getChatScrollQuery(client); await this.validateExistStock(stockId); client.join(stockId); - const messages = await this.chatService.scrollChat( - { - stockId, - pageSize, - }, - user?.id, - ); + const messages = await this.scrollChat(stockId, user, pageSize); this.logger.info(`client joined room ${stockId}`); client.emit('chat', messages); + if (user) { + this.users.set(user.id, client.id); + } } catch (e) { const error = e as Error; this.logger.warn(error.message); @@ -103,6 +115,20 @@ export class ChatGateway implements OnGatewayConnection { } } + private async scrollChat( + stockId: string, + user: User | null, + pageSize?: number, + ) { + return await this.chatService.scrollChat( + { + stockId, + pageSize, + }, + user?.id, + ); + } + private async validateExistStock(stockId: string): Promise { if (!(await this.stockService.checkStockExist(stockId))) { throw new Error(`Stock does not exist: ${stockId}`); @@ -126,6 +152,7 @@ export class ChatGateway implements OnGatewayConnection { likeCount: chat.likeCount, message: chat.message, type: chat.type, + mentioned: false, createdAt: chat.date?.createdAt || new Date(), }; } diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 62dc1c29..b58dc484 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -6,12 +6,18 @@ import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; import { Like } from '@/chat/domain/like.entity'; +import { Mention } from '@/chat/domain/mention.entity'; import { LikeService } from '@/chat/like.service'; +import { MentionService } from '@/chat/mention.service'; import { StockModule } from '@/stock/stock.module'; @Module({ - imports: [TypeOrmModule.forFeature([Chat, Like]), StockModule, SessionModule], + imports: [ + TypeOrmModule.forFeature([Chat, Like, Mention]), + StockModule, + SessionModule, + ], controllers: [ChatController], - providers: [ChatGateway, ChatService, LikeService], + providers: [ChatGateway, ChatService, LikeService, MentionService], }) export class ChatModule {} diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 80417656..03bdaa17 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -102,27 +102,41 @@ export class ChatService { userId?: number, order: Order = ORDER.LATEST, ) { - const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); const { stockId, latestChatId, pageSize } = chatScrollQuery; const size = pageSize ? pageSize : DEFAULT_PAGE_SIZE; + const queryBuilder = await this.buildInitialChatScrollQuery( + stockId, + size, + userId, + ); + if (order === ORDER.LIKE) { + return this.buildLikeCountQuery(queryBuilder, latestChatId); + } + return this.buildLatestChatIdQuery(queryBuilder, latestChatId); + } - queryBuilder + private async buildInitialChatScrollQuery( + stockId: string, + size: number, + userId?: number, + ) { + console.log('stockId', stockId); + return this.dataSource + .createQueryBuilder(Chat, 'chat') .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { userId, }) + .leftJoinAndSelect( + 'chat.mentions', + 'mention', + 'mention.user_id = :userId', + { + userId, + }, + ) .leftJoinAndSelect('chat.user', 'user') .where('chat.stock_id = :stockId', { stockId }) .take(size + 1); - - if (order === ORDER.LIKE) { - return this.buildLikeCountQuery(queryBuilder, latestChatId); - } - queryBuilder.orderBy('chat.id', 'DESC'); - if (latestChatId) { - queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); - } - - return queryBuilder; } private async buildLikeCountQuery( @@ -150,4 +164,15 @@ export class ChatService { } return queryBuilder; } + + private async buildLatestChatIdQuery( + queryBuilder: SelectQueryBuilder, + latestChatId?: number, + ) { + queryBuilder.orderBy('chat.id', 'DESC'); + if (latestChatId) { + queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); + } + return queryBuilder; + } } diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index 2a5ab380..ccaec4c8 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -12,6 +12,7 @@ import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; +import { Mention } from '@/chat/domain/mention.entity'; @Entity() export class Chat { @@ -41,4 +42,7 @@ export class Chat { @Column(() => DateEmbedded, { prefix: '' }) date: DateEmbedded; + + @OneToMany(() => Mention, (mention) => mention.chat) + mentions: Mention[]; } diff --git a/packages/backend/src/chat/domain/like.entity.ts b/packages/backend/src/chat/domain/like.entity.ts index 84238b15..261e96e8 100644 --- a/packages/backend/src/chat/domain/like.entity.ts +++ b/packages/backend/src/chat/domain/like.entity.ts @@ -23,6 +23,6 @@ export class Like { @JoinColumn({ name: 'user_id' }) user: User; - @CreateDateColumn({ name: 'created_at' }) + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) createdAt: Date; } diff --git a/packages/backend/src/chat/domain/mention.entity.ts b/packages/backend/src/chat/domain/mention.entity.ts new file mode 100644 index 00000000..c68ca8fb --- /dev/null +++ b/packages/backend/src/chat/domain/mention.entity.ts @@ -0,0 +1,28 @@ +import { + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { User } from '@/user/domain/user.entity'; + +@Index('chat_user_unique', ['chat', 'user']) +@Entity() +export class Mention { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Chat, (chat) => chat.id) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index 0c68943b..3d970fdd 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -33,17 +33,20 @@ export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { if (typeof object !== 'object' || object === null) { return false; } - if (!('stockId' in object) || typeof object.stockId !== 'string') { return false; } - if ( 'latestChatId' in object && !Number.isInteger(Number(object.latestChatId)) ) { return false; } - return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); } + +export interface ChatMessage { + room: string; + content: string; + mention?: number; +} diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index bf42c903..68a81b4d 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -9,6 +9,7 @@ interface ChatResponse { type: string; liked: boolean; nickname: string; + mentioned: boolean; createdAt: Date; } @@ -43,6 +44,7 @@ export class ChatScrollResponse { type: chat.type, createdAt: chat.date!.createdAt, liked: !!(chat.likes && chat.likes.length > 0), + mentioned: chat.mentions && chat.mentions.length > 0, nickname: chat.user.nickname, })); this.hasMore = hasMore; diff --git a/packages/backend/src/chat/mention.service.ts b/packages/backend/src/chat/mention.service.ts new file mode 100644 index 00000000..f5e49133 --- /dev/null +++ b/packages/backend/src/chat/mention.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Mention } from '@/chat/domain/mention.entity'; +import { User } from '@/user/domain/user.entity'; + +@Injectable() +export class MentionService { + constructor(private readonly dataSource: DataSource) {} + + async createMention(chatId: number, userId: number) { + return this.dataSource.transaction(async (manager) => { + if (!(await this.existsChatAndUser(chatId, userId, manager))) { + return null; + } + return await manager.save(Mention, { + chat: { id: chatId }, + user: { id: userId }, + }); + }); + } + + async existsChatAndUser( + chatId: number, + userId: number, + manager: EntityManager, + ) { + if (!(await manager.exists(User, { where: { id: userId } }))) { + return false; + } + return await manager.exists(Chat, { + where: { id: chatId }, + }); + } +} diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 26cdb345..9f93e968 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -9,7 +9,9 @@ import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; import { OauthType } from '@/user/domain/ouathType'; import { Role } from '@/user/domain/role'; +import { Mention } from '@/chat/domain/mention.entity'; +@Index('nickname_sub_name', ['nickname', 'subName'], { unique: true }) @Index('type_oauth_id', ['type', 'oauthId'], { unique: true }) @Entity({ name: 'users' }) export class User { @@ -19,6 +21,9 @@ export class User { @Column({ length: 50 }) nickname: string; + @Column({ length: 10, default: '0001' }) + subName: string; + @Column({ length: 50 }) email: string; @@ -39,4 +44,7 @@ export class User { @OneToMany(() => UserStock, (userStock) => userStock.user) userStocks: UserStock[]; + + @OneToMany(() => Mention, (mention) => mention.user) + mentions: Mention[]; } diff --git a/packages/backend/src/user/dto/User.response.ts b/packages/backend/src/user/dto/User.response.ts new file mode 100644 index 00000000..98f37930 --- /dev/null +++ b/packages/backend/src/user/dto/User.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '@/user/domain/user.entity'; + +interface UserResponse { + id: number; + nickname: string; + subName: string; + createdAt: Date; +} + +export class UserSearchResult { + @ApiProperty({ + description: '유저 검색 결과', + example: [ + { + id: 1, + nickname: 'nickname', + subName: 'subName', + createdAt: new Date(), + }, + ], + }) + result: UserResponse[]; + + constructor(users: User[]) { + this.result = users.map((user) => ({ + id: user.id, + nickname: user.nickname, + subName: user.subName, + createdAt: user.date.createdAt, + })); + } +} diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 748ae2b7..17d88491 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -1,11 +1,12 @@ import { - Controller, - Patch, - Param, Body, + Controller, + Get, HttpCode, HttpStatus, - Get, + Param, + Patch, + Query, } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; @@ -15,6 +16,23 @@ import { UserService } from './user.service'; export class UserController { constructor(private readonly userService: UserService) {} + @Get() + @ApiOperation({ + summary: '유저 닉네임과 서브 닉네임으로 유저 조회 API', + description: '유저 닉네임과 서브 닉네임으로 유저를 조회합니다.', + }) + @ApiParam({ name: 'nickname', type: 'string', description: '유저 닉네임' }) + @ApiParam({ name: 'subName', type: 'string', description: '유저 서브네임' }) + async searchUser( + @Query('nickname') nickname: string, + @Query('subName') subName: string, + ) { + return await this.userService.searchUserByNicknameAndSubName( + nickname, + subName, + ); + } + @Patch(':id/theme') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index 6af944a1..e4907618 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -5,15 +5,15 @@ import { User } from './domain/user.entity'; import { OauthType } from '@/user/domain/ouathType'; import { UserService } from '@/user/user.service'; +const defaultManagerMock: Partial = { + findOne: jest.fn(), + save: jest.fn(), + exists: jest.fn(), +}; + export function createDataSourceMock( managerMock?: Partial, ): Partial { - const defaultManagerMock: Partial = { - findOne: jest.fn(), - save: jest.fn(), - exists: jest.fn(), - }; - return { getRepository: managerMock?.getRepository, transaction: jest.fn().mockImplementation(async (work) => { @@ -22,6 +22,14 @@ export function createDataSourceMock( }; } +export function createManagerDataSourceMock( + managerMock?: Partial, +) { + return { + manager: managerMock, + }; +} + describe('UserService 테스트', () => { const registerRequest = { email: 'test@naver.com', @@ -58,6 +66,45 @@ describe('UserService 테스트', () => { ).rejects.toThrow('user already exists'); }); + test('같은 닉네임이 없을 때 기본 서브 닉네임을 생성한다.', async () => { + const managerMock = { + exists: jest.fn().mockResolvedValueOnce(false), + save: jest.fn().mockResolvedValue(registerRequest), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const subName = await userService.createSubName('test'); + + expect(subName).toBe('0001'); + }); + + test.each([ + ['0001', '0002'], + ['0009', '0010'], + ['0099', '0100'], + ['0999', '1000'], + ])( + '같은 닉네임이 있을 때 현제 서브네임 최대 값에서 1을 더한 값이 생성', + async (maxSubName, newSubName) => { + const managerMock = { + exists: jest.fn().mockResolvedValue(true), + save: jest.fn().mockResolvedValue(registerRequest), + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: maxSubName }), + }), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const subName = await userService.createSubName('test'); + + expect(subName).toBe(newSubName); + }, + ); + test('유저 테마를 업데이트한다', async () => { const userId = 1; const isLight = false; @@ -117,7 +164,7 @@ describe('UserService 테스트', () => { const managerMock = { findOne: jest.fn().mockResolvedValue(mockUser), }; - const dataSource = createDataSourceMock(managerMock); + const dataSource = createManagerDataSourceMock(managerMock); const userService = new UserService(dataSource as DataSource); const result = await userService.getUserTheme(userId); @@ -135,7 +182,7 @@ describe('UserService 테스트', () => { const managerMock = { findOne: jest.fn().mockResolvedValue(null), }; - const dataSource = createDataSourceMock(managerMock); + const dataSource = createManagerDataSourceMock(managerMock); const userService = new UserService(dataSource as DataSource); await expect(userService.getUserTheme(userId)).rejects.toThrow( diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index dbef92dd..a132262d 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -3,10 +3,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource, EntityManager, Like } from 'typeorm'; import { OauthType } from './domain/ouathType'; import { User } from './domain/user.entity'; import { status, subject } from '@/user/constants/randomNickname'; +import { UserSearchResult } from '@/user/dto/User.response'; type RegisterRequest = Required< Pick @@ -19,25 +20,52 @@ export class UserService { async register({ nickname, email, type, oauthId }: RegisterRequest) { return await this.dataSource.transaction(async (manager) => { await this.validateUserExists(type, oauthId, manager); + const subName = await this.createSubName(nickname); return await manager.save(User, { nickname, email, type, oauthId, + subName, }); }); } + async searchUserByNicknameAndSubName(nickname: string, subName?: string) { + const users = await this.dataSource.manager.find(User, { + where: { nickname: Like(`%${nickname}%`), subName: Like(`${subName}%`) }, + take: 10, + }); + return new UserSearchResult(users); + } + + async createSubName(nickname: string) { + return this.dataSource.transaction(async (manager) => { + console.log(await this.existsUserByNickname(nickname, manager)); + if (!(await this.existsUserByNickname(nickname, manager))) { + return '0001'; + } + + const maxSubName = await manager + .createQueryBuilder(User, 'user') + .select('MAX(user.subName)', 'max') + .where('user.nickname = :nickname', { nickname }) + .getRawOne(); + console.log(maxSubName); + return (parseInt(maxSubName.max, 10) + 1).toString().padStart(4, '0'); + }); + } + + existsUserByNickname(nickname: string, manager: EntityManager) { + return manager.exists(User, { where: { nickname } }); + } + async registerTester() { - return await this.dataSource.transaction(async (manager) => { - return await manager.save(User, { - nickname: this.generateRandomNickname(), - email: 'tester@nav', - type: OauthType.LOCAL, - oauthId: String( - (await this.getMaxOauthId(OauthType.LOCAL, manager)) + 1, - ), - }); + return this.register({ + nickname: this.generateRandomNickname(), + email: 'tester@nav', + type: OauthType.LOCAL, + oauthId: String((await this.getMaxOauthId(OauthType.LOCAL)) + 1), }); } @@ -83,8 +111,8 @@ export class UserService { return `${statusName}${subjectName}`; } - private async getMaxOauthId(oauthType: OauthType, manager: EntityManager) { - const result = await manager + private async getMaxOauthId(oauthType: OauthType) { + const result = await this.dataSource.manager .createQueryBuilder(User, 'user') .select('MAX(user.oauthId)', 'max') .where('user.type = :oauthType', { oauthType }) From 76f6de1b75a4e8cd1de4ef3ed57fe384d7c2dfb2 Mon Sep 17 00:00:00 2001 From: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:23:03 +0900 Subject: [PATCH 108/223] =?UTF-8?q?Feature/#251=20-=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20=EA=B2=BD=EB=A1=9C=20=EB=A7=A4=ED=95=91=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: 유저 서브네임 생성 로직 구현 * ✨ feat: 멘션 엔티티 구현 * ✨ feat: 특정 유저를 멘션하는 기능 구현 * 🐛 fix: 중복된 닉네임 테스터 에러 수정 * ✨ feat: 채팅으로 멘션을 진행 * ✨ feat: 멘션 연관관계 설정 * 🐛 fix: 잘못된 검증으로 멘션이 안되는 문제 해결 * ✨ feat: 채팅스크롤에서 멘션 필드 추가 * ✨ feat: 닉네임과 서브네임으로 유저 닉네임 검색 * ✨ feat: 서브네임 like 적용 * 🐛 fix: 주식 컨트롤러에서 잘못된 경로 매핑문제 해결 --- .../backend/src/stock/stock.controller.ts | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 5ce27bf9..6f18c657 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -177,6 +177,40 @@ export class StockController { return await this.stockService.searchStock(request.name); } + @Get('topViews') + @ApiGetStocks('조회수 기반 주식 리스트 조회 API') + 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.getTopStocksByGainers(limit); + } + + @Get('topLosers') + @ApiGetStocks('가격 하락률 기반 주식 리스트 조회 API') + async getTopStocksByLosers(@LimitQuery(20) limit: number) { + return await this.stockService.getTopStocksByLosers(limit); + } + + @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); + } + @Get('/:stockId') @ApiGetStockData('주식 시간 단위 데이터 조회 API', '일') async getStockDataDaily( @@ -198,40 +232,6 @@ 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); - } - - @Get('topViews') - @ApiGetStocks('조회수 기반 주식 리스트 조회 API') - 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.getTopStocksByGainers(limit); - } - - @Get('topLosers') - @ApiGetStocks('가격 하락률 기반 주식 리스트 조회 API') - async getTopStocksByLosers(@LimitQuery(20) limit: number) { - return await this.stockService.getTopStocksByLosers(limit); - } - private getStockDataYearly( stockId: string, lastStartTime: string | undefined, From 3ed0e295e17ad25bc6f2a56fdf7b8abb5f4fb073 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:27:28 +0900 Subject: [PATCH 109/223] =?UTF-8?q?Bug/#250=20stock=20data=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> --- .../openapi/api/openapiDetailData.api.ts | 8 +-- .../openapi/api/openapiNewDetailData.api.ts | 0 .../openapi/api/openapiPeriodData.api.ts | 58 ++++++++----------- .../scraper/openapi/api/openapiToken.api.ts | 9 +++ .../scraper/openapi/config/openapi.config.ts | 2 + .../src/scraper/openapi/liveData.service.ts | 7 +-- .../openapi/type/openapiDetailData.type.ts | 1 + .../openapi/type/openapiNewDetailData.type.ts | 0 .../websocket/websocketClient.websocket.ts | 23 ++++---- .../src/stock/domain/stockData.entity.ts | 2 + 10 files changed, 51 insertions(+), 59 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiNewDetailData.api.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiNewDetailData.type.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 982ebaf6..15484f0a 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -31,7 +31,7 @@ export class OpenapiDetailData { private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { - //setTimeout(() => this.getDetailData(), 5000); + //this.getDetailData(); } @Cron('0 8 * * 1-5') @@ -191,7 +191,7 @@ export class OpenapiDetailData { dataQuery, TR_IDS.FINANCIAL_DATA, ); - if (response.output) { + if (response.output && response.output[0]) { const output1 = response.output; return output1[0]; } @@ -224,8 +224,6 @@ export class OpenapiDetailData { const output1 = await this.getFinancialRatio(stock, conf); const output2 = await this.getProductData(stock, conf); - this.logger.info(JSON.stringify(output1)); - this.logger.info(JSON.stringify(output2)); if (isFinancialRatioData(output1) && isProductDetail(output2)) { const stockDetail = await this.makeStockDetailObject( output1, @@ -236,7 +234,7 @@ export class OpenapiDetailData { const kospiStock = await this.makeKospiStockObject(output2, stock.id!); this.saveKospiData(kospiStock); - this.logger.info(`${stock.id!} is saved`); + this.logger.info(`${stock.id!} detail data is saved`); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiNewDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiNewDetailData.api.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 5471cf5e..1d5d86a0 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource } from 'typeorm'; import { Logger } from 'winston'; import { ChartData, @@ -32,13 +32,13 @@ const DATE_TO_ENTITY = { }; const DATE_TO_MONTH = { - D: 3, + D: 1, W: 6, - M: 12, - Y: 24, + M: 24, + Y: 120, }; -const INTERVALS = 4000; +const INTERVALS = 10000; @Injectable() export class OpenapiPeriodData { @@ -48,9 +48,7 @@ export class OpenapiPeriodData { private readonly datasource: DataSource, private readonly openApiToken: OpenapiTokenApi, @Inject('winston') private readonly logger: Logger, - ) { - //this.getItemChartPriceCheck(); - } + ) {} @Cron('0 1 * * 1-5') async getItemChartPriceCheck() { @@ -60,20 +58,15 @@ export class OpenapiPeriodData { isTrading: true, }, }); - const configCount = this.openApiToken.configs.length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getChartData(chunk, 'D'); - setTimeout(() => this.getChartData(chunk, 'W'), INTERVALS); - setTimeout(() => this.getChartData(chunk, 'M'), INTERVALS * 2); - setTimeout(() => this.getChartData(chunk, 'Y'), INTERVALS * 3); - } + + await this.getChartData(stocks, 'Y'); + await this.getChartData(stocks, 'M'); + await this.getChartData(stocks, 'W'); + await this.getChartData(stocks, 'D'); } private async getChartData(chunk: Stock[], period: Period) { - const baseTime = INTERVALS * 4; + const baseTime = INTERVALS; const entity = DATE_TO_ENTITY[period]; let time = 0; @@ -89,26 +82,23 @@ export class OpenapiPeriodData { entity: typeof StockData, ) { const stockPeriod = new StockData(); - const manager = this.datasource.manager; let configIdx = 0; let end = getTodayDate(); - let start = getPreviousDate(end, 3); + let start = getPreviousDate(end, DATE_TO_MONTH[period]); let isFail = false; while (!isFail) { + await new Promise((resolve) => setTimeout(resolve, INTERVALS / 10)); configIdx = (configIdx + 1) % (await this.openApiToken.configs()).length; this.setStockPeriod(stockPeriod, stock.id!, end); - // chart 데이터가 있는 지 확인 -> 리턴 - if (await this.existsChartData(stockPeriod, manager, entity)) return; - const query = this.getItemChartPriceQuery(stock.id!, start, end, period); const output = await this.fetchChartData(query, configIdx); if (output) { await this.saveChartData(entity, stock.id!, output); - ({ endDate: end, startDate: start } = this.updateDates(start, period)); + ({ endDate: end, startDate: start } = this.updateDates(end, period)); } else isFail = true; } } @@ -137,34 +127,32 @@ export class OpenapiPeriodData { return response.output2 as ChartData[]; } catch (error) { this.logger.warn(error); + setTimeout(() => this.fetchChartData(query, configIdx), INTERVALS / 10); } } private updateDates( - startDate: string, + endDate: string, period: Period, ): { endDate: string; startDate: string } { - const endDate = getPreviousDate(startDate, DATE_TO_MONTH[period]); - startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); + endDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); + const startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); return { endDate, startDate }; } - private async existsChartData( - stock: StockData, - manager: EntityManager, - entity: typeof StockData, - ) { + private async existsChartData(stock: StockData, entity: typeof StockData) { + const manager = this.datasource.manager; return await manager.findOne(entity, { where: { stock: { id: stock.stock.id }, - createdAt: stock.startTime, + startTime: stock.startTime, }, }); } private async insertChartData(stock: StockData, entity: typeof StockData) { const manager = this.datasource.manager; - if (!(await this.existsChartData(stock, manager, entity))) { + if (!(await this.existsChartData(stock, entity))) { await manager.save(entity, stock); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index fee72534..73ec62e0 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -42,6 +42,13 @@ export class OpenapiTokenApi { @Cron('30 0 * * 1-5') async init() { + const expired_config = this.config.filter( + (val) => + this.isTokenExpired(val.STOCK_API_TIMEOUT) && + this.isTokenExpired(val.STOCK_WEBSOCKET_TIMEOUT), + ); + const isUndefined = this.config[0].STOCK_WEBSOCKET_TIMEOUT ? false : true; + if (!isUndefined && !expired_config.length) return; const tokens = this.convertConfigToTokenEntity(this.config); const config = await this.getPropertyFromDB(tokens); const expired = config.filter( @@ -79,6 +86,8 @@ export class OpenapiTokenApi { STOCK_API_TOKEN: val.api_token, STOCK_URL: val.api_url, STOCK_WEBSOCKET_KEY: val.websocket_key, + STOCK_API_TIMEOUT: val.api_token_expire, + STOCK_WEBSOCKET_TIMEOUT: val.websocket_key_expire, }; result.push(config); }); diff --git a/packages/backend/src/scraper/openapi/config/openapi.config.ts b/packages/backend/src/scraper/openapi/config/openapi.config.ts index 8aa12ea3..85a66363 100644 --- a/packages/backend/src/scraper/openapi/config/openapi.config.ts +++ b/packages/backend/src/scraper/openapi/config/openapi.config.ts @@ -9,6 +9,8 @@ export const openApiConfig: { STOCK_API_PASSWORD: string | undefined; STOCK_API_TOKEN?: string; STOCK_WEBSOCKET_KEY?: string; + STOCK_API_TIMEOUT?: Date; + STOCK_WEBSOCKET_TIMEOUT?: Date; } = { STOCK_URL: process.env.STOCK_URL, STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 68ed8c0f..9d19c52d 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -24,13 +24,11 @@ export class LiveData { @Inject('winston') private readonly logger: Logger, ) { this.connect(); - this.subscribe('000020'); } private async openapiSubscribe(stockId: string) { const config = (await this.openApiToken.configs())[0]; const result = await this.openapiLiveData.connectLiveData(stockId, config); - this.logger.info(JSON.stringify(result)); try { const stockLiveData = this.openapiLiveData.convertResponseToStockLiveData( result.output, @@ -58,6 +56,7 @@ export class LiveData { this.webSocketClient.subscribe(message); } } + async discribe(stockId: string) { if (this.clientStock.has(stockId)) { @@ -90,13 +89,10 @@ export class LiveData { const message = this.parseMessage(data); if (message.header) { if (message.header.tr_id === 'PINGPONG') { - this.logger.info(`Received PING: ${data}`); client.pong(data); } return; } - this.logger.info(`Recived data : ${data}`); - this.logger.info(`Stock id : ${message[0]['STOCK_ID']}`); const liveData = this.openapiLiveData.convertLiveData(message); await this.openapiLiveData.saveLiveData(liveData); } catch (error) { @@ -142,7 +138,6 @@ export class LiveData { stockId: string, tr_type: TR_IDS, ): string { - this.logger.info(JSON.stringify(config)); const message = { header: { approval_key: config.STOCK_WEBSOCKET_KEY!, diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index 38015d48..fa4b3a88 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -106,6 +106,7 @@ export type ProductDetail = { export const isProductDetail = (data: any): data is ProductDetail => { return ( + data && typeof data.pdno === 'string' && typeof data.prdt_type_cd === 'string' && typeof data.mket_id_cd === 'string' && diff --git a/packages/backend/src/scraper/openapi/type/openapiNewDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiNewDetailData.type.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts index 06451a53..9661abde 100644 --- a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -12,25 +12,13 @@ export class WebsocketClient { constructor(@Inject('winston') private readonly logger: Logger) {} subscribe(message: string) { - this.logger.info(`Subscribe : ${message}`); this.sendMessage(message); } discribe(message: string) { - this.logger.info(`Discribe : ${message}`); this.sendMessage(message); } - // TODO : 분리 - private initDisconnect( - initCloseCallback: () => void, - initErrorCallback: (error: unknown) => void, - ) { - this.client.on('close', initCloseCallback); - - this.client.on('error', initErrorCallback); - } - private initOpen(fn: () => void) { this.client.on('open', fn); } @@ -39,6 +27,14 @@ export class WebsocketClient { this.client.on('message', fn); } + private initDisconnect(initCloseCallback: () => void) { + this.client.on('close', initCloseCallback); + } + + private initError(initErrorCallback: (error: unknown) => void) { + this.client.on('error', initErrorCallback); + } + connectPacade( initOpenCallback: (fn: (message: string) => void) => () => void, initMessageCallback: (client: WebSocket) => (data: RawData) => void, @@ -47,7 +43,8 @@ export class WebsocketClient { ) { this.initOpen(initOpenCallback(this.sendMessage)); this.initMessage(initMessageCallback(this.client)); - this.initDisconnect(initCloseCallback, initErrorCallback); + this.initDisconnect(initCloseCallback); + this.initError(initErrorCallback); } private sendMessage(message: string) { diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index 7f6790d3..fc28e77e 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -5,9 +5,11 @@ import { CreateDateColumn, JoinColumn, ManyToOne, + Index, } from 'typeorm'; import { Stock } from './stock.entity'; +@Index('stock_id_start_time', ['stock.id', 'startTime'], { unique: true }) export class StockData { @PrimaryGeneratedColumn() id: number; From a2cc91a1c56d7f2d846b69bffa2017de1f4ac7b6 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 21:44:28 +0900 Subject: [PATCH 110/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EB=9E=AD=ED=82=B9=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/FluctuationRankStock.entity.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/backend/src/stock/domain/FluctuationRankStock.entity.ts diff --git a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts new file mode 100644 index 00000000..fd443465 --- /dev/null +++ b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Stock } from '@/stock/domain/stock.entity'; + +@Entity() +export class FluctuationRankStock { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Stock, (stock) => stock.id) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @Column({ name: 'fluctuation_rate', type: 'decimal', precision: 5, scale: 2 }) + fluctuationRate: string; + + @Column() + isRising: boolean; + + @Column() + rank: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} \ No newline at end of file From 2779b819a5084dccaaf8fb9352ec272fc5a912e0 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 21:46:23 +0900 Subject: [PATCH 111/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EB=9E=AD=ED=82=B9=20api=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiFluctuationData.api.ts | 95 +++++++++++++++++++ .../src/scraper/openapi/constants/query.ts | 26 +++++ .../scraper/openapi/openapi-scraper.module.ts | 4 + .../scraper/openapi/type/openapiUtil.type.ts | 4 +- 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts create mode 100644 packages/backend/src/scraper/openapi/constants/query.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts new file mode 100644 index 00000000..eb7f86a8 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -0,0 +1,95 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; +import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; +import { + DECREASE_STOCK_QUERY, + INCREASE_STOCK_QUERY, +} from '@/scraper/openapi/constants/query'; +import { Cron } from '@nestjs/schedule'; + +@Injectable() +export class OpenapiFluctuationData { + private readonly fluctuationUrl: string = + '/uapi/domestic-stock/v1/ranking/fluctuation'; + constructor( + private readonly openApiToken: OpenapiTokenApi, + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + setTimeout(() => this.getFluctuationRankStocks(), 1000); + } + + @Cron('*/1 9-16 * * 1-5') + async getFluctuationRankStocks() { + await this.getDecreaseRankStocks(); + await this.getIncreaseRankStocks(); + } + + async getDecreaseRankStocks(count = 5) { + try { + if (count === 0) return; + await this.datasource.transaction(async (manager) => { + const result = await this.getFluctuationRankApiStocks(false); + await this.datasource.manager.delete(FluctuationRankStock, { + isRising: false, + }); + await this.saveFluctuationRankStocks(result, manager); + this.logger.info('decrease rank stocks updated'); + }); + } catch (error) { + this.logger.warn(error); + this.getDecreaseRankStocks(--count); + } + } + + async getIncreaseRankStocks(count = 5) { + try { + if (count === 0) return; + await this.datasource.transaction(async (manager) => { + const result = await this.getFluctuationRankApiStocks(true); + await this.datasource.manager.delete(FluctuationRankStock, { + isRising: true, + }); + await this.saveFluctuationRankStocks(result, manager); + this.logger.info('increase rank stocks updated'); + }); + } catch (error) { + this.logger.warn(error); + this.getIncreaseRankStocks(--count); + } + } + + private async saveFluctuationRankStocks( + result: FluctuationRankStock[], + manager: EntityManager, + ) { + await manager + .getRepository(FluctuationRankStock) + .createQueryBuilder() + .insert() + .into(FluctuationRankStock) + .values(result) + .execute(); + } + + private async getFluctuationRankApiStocks(isRising: boolean) { + const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + const result = await getOpenApi( + this.fluctuationUrl, + this.openApiToken.configs[0], + query, + TR_IDS.FLUCTUATION_DATA, + ); + + return result.output.map((result: Record) => ({ + rank: result.data_rank, + fluctuationRate: result.prdy_ctrt, + stock: { id: result.stck_shrn_iscd }, + isRising, + })); + } +} diff --git a/packages/backend/src/scraper/openapi/constants/query.ts b/packages/backend/src/scraper/openapi/constants/query.ts new file mode 100644 index 00000000..8d0de49e --- /dev/null +++ b/packages/backend/src/scraper/openapi/constants/query.ts @@ -0,0 +1,26 @@ +const BASE_QUERY = { + fid_cond_mrkt_div_code: 'J', + fid_cond_scr_div_code: '20170', + fid_input_iscd: '0000', + fid_input_cnt_1: '0', + fid_input_price_1: '', + fid_input_price_2: '', + fid_vol_cnt: '', + fid_trgt_cls_code: '0', + fid_trgt_exls_cls_code: '0', + fid_div_cls_code: '0', + fid_rsfl_rate1: '', + fid_rsfl_rate2: '', +}; + +export const DECREASE_STOCK_QUERY = { + ...BASE_QUERY, + fid_rank_sort_cls_code: '1', + fid_prc_cls_code: '1', +}; + +export const INCREASE_STOCK_QUERY = { + ...BASE_QUERY, + fid_rank_sort_cls_code: '0', + fid_prc_cls_code: '1', +}; \ No newline at end of file diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 73f33f23..4150d86f 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -17,6 +17,8 @@ import { } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @Module({ imports: [ @@ -29,6 +31,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; StockYearly, StockLiveData, StockDetail, + FluctuationRankStock, ]), ], controllers: [], @@ -39,6 +42,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiDetailData, OpenapiScraperService, OpenapiLiveData, + OpenapiFluctuationData, WebsocketClient, ], exports: [WebsocketClient], diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index 6df0ca19..27f8bd11 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -3,11 +3,13 @@ export type TR_ID = | 'FHKST03010200' | 'FHKST66430300' | 'HHKDB669107C0' - | 'CTPF1002R'; + | 'CTPF1002R' + | 'FHPST01700000'; export const TR_IDS: Record = { ITEM_CHART_PRICE: 'FHKST03010100', MINUTE_DATA: 'FHKST03010200', FINANCIAL_DATA: 'FHKST66430300', PRODUCTION_DETAIL: 'CTPF1002R', + FLUCTUATION_DATA : 'FHPST01700000', }; From ee5e460f7a8b1cdcae5fcf818fe30f49ed723f54 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 22:37:05 +0900 Subject: [PATCH 112/223] =?UTF-8?q?=F0=9F=92=84=20style:=20=EB=B3=80?= =?UTF-8?q?=EB=8F=99=20=EB=9E=AD=ED=82=B9=20=EC=A3=BC=EC=8B=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20eslint=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiFluctuationData.api.ts | 6 +++--- packages/backend/src/scraper/openapi/constants/query.ts | 2 +- .../backend/src/stock/domain/FluctuationRankStock.entity.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index eb7f86a8..d5671e73 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -2,13 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; -import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; -import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; -import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { DECREASE_STOCK_QUERY, INCREASE_STOCK_QUERY, } from '@/scraper/openapi/constants/query'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; +import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Cron } from '@nestjs/schedule'; @Injectable() diff --git a/packages/backend/src/scraper/openapi/constants/query.ts b/packages/backend/src/scraper/openapi/constants/query.ts index 8d0de49e..7e0aa0e6 100644 --- a/packages/backend/src/scraper/openapi/constants/query.ts +++ b/packages/backend/src/scraper/openapi/constants/query.ts @@ -23,4 +23,4 @@ export const INCREASE_STOCK_QUERY = { ...BASE_QUERY, fid_rank_sort_cls_code: '0', fid_prc_cls_code: '1', -}; \ No newline at end of file +}; diff --git a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts index fd443465..5ece2107 100644 --- a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts +++ b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts @@ -28,4 +28,4 @@ export class FluctuationRankStock { @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) createdAt: Date; -} \ No newline at end of file +} From e1ceb5c29e9751d06a3f5cd73120ed873e64a4b9 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 26 Nov 2024 22:38:31 +0900 Subject: [PATCH 113/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=20api=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/domain/stock.entity.ts | 7 +++ .../backend/src/stock/dto/stock.response.ts | 63 +++++++++++++++++++ packages/backend/src/stock/stock.service.ts | 35 +++++++---- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 994a6de1..eaa38aa7 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -10,6 +10,7 @@ import { import { StockLiveData } from './stockLiveData.entity'; import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() @@ -58,4 +59,10 @@ export class Stock { @OneToOne(() => KospiStock, (kospiStock) => kospiStock.stock) kospiStock?: KospiStock; + + @OneToMany( + () => FluctuationRankStock, + (fluctuationRankStock) => fluctuationRankStock.stock, + ) + fluctuationRankStocks?: FluctuationRankStock[]; } diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index 6ac76f31..0d00104e 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -101,3 +101,66 @@ export class StockSearchResponse { })); } } + +export class StockRankResponse { + @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, + }) + @Transform(({ value }) => parseInt(value)) + volume: number; + + @ApiProperty({ + description: '주식 시가 총액', + example: '500000000000.00', + }) + marketCap: string; + + @ApiProperty({ + description: '랭킹', + example: 1, + }) + rank: number; +} + +export class StockRankResponses { + result: StockRankResponse[]; + + constructor(stocks: Record[]) { + this.result = stocks.map((stock) => ({ + id: stock.id, + name: stock.name, + currentPrice: parseFloat(stock.currentPrice), + volume: parseInt(stock.volume), + marketCap: stock.marketCap, + changeRate: parseFloat(stock.fluctuationRate), + rank: parseInt(stock.stockRank), + })); + } +} diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index c0e50a9c..c72fb091 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -3,7 +3,11 @@ 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 { + StockRankResponses, + StockSearchResponse, + StocksResponse, +} from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; @Injectable() @@ -94,7 +98,7 @@ export class StockService { } async getTopStocksByViews(limit: number) { - const rawData = await this.StocksQuery() + const rawData = await this.getStocksQuery() .orderBy('stock.views', 'DESC') .limit(limit) .getRawMany(); @@ -103,21 +107,17 @@ export class StockService { } async getTopStocksByGainers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'DESC') - .limit(limit) - .getRawMany(); + const rawData = await this.getStockRankQuery(true).take(limit).getRawMany(); - return plainToInstance(StocksResponse, rawData); + return new StockRankResponses(rawData); } async getTopStocksByLosers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'ASC') - .limit(limit) + const rawData = await this.getStockRankQuery(false) + .take(limit) .getRawMany(); - return plainToInstance(StocksResponse, rawData); + return new StockRankResponses(rawData); } private async validateStockExists(stockId: string, manager: EntityManager) { @@ -153,7 +153,7 @@ export class StockService { return await manager.exists(Stock, { where: { id: stockId } }); } - private StocksQuery() { + private getStocksQuery() { return this.datasource .getRepository(Stock) .createQueryBuilder('stock') @@ -176,4 +176,15 @@ export class StockService { 'stockDetail.marketCap AS marketCap', ]); } + + private getStockRankQuery(isRising: boolean) { + return this.getStocksQuery() + .innerJoinAndSelect('stock.fluctuationRankStocks', 'FluctuationRankStock') + .addSelect([ + 'fluctuationRankStock.rank AS stockRank', + 'fluctuationRankStock.isRising AS isRising', + 'fluctuationRankStock.fluctuation_rate AS fluctuationRate', + ]) + .where('FluctuationRankStock.isRising = :isRising', { isRising }); + } } From ca95b47ea40af177db2263f0556663c039daef89 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Tue, 26 Nov 2024 23:39:55 +0900 Subject: [PATCH 114/223] =?UTF-8?q?Bug/#257=20detail=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20(#258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 * ✨ feat: detail 구현 완료 * 🐛 fix: insert시 데이터 있으면 발생하는 오류 수정 * 💄 style: 테스트용 start 삭제, cron만 남겨놓음 * 🐛 fix: unique column 조건 rollback * 🐛 fix: detail data에 시범적으로 추상클래스 적용 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> --- .../scraper/openapi/api/openapi.abstract.ts | 30 ++ .../openapi/api/openapiDetailData.api.ts | 318 ++++----------- .../openapi/api/openapiLiveData.api.ts | 2 +- .../openapi/api/openapiPeriodData.api.ts | 2 +- .../openapi/type/openapiDetailData.type.ts | 363 ++++++++---------- .../openapi/type/openapiPeriodData.type.ts | 45 +++ .../src/stock/domain/stockDetail.entity.ts | 2 +- .../backend/src/user/domain/user.entity.ts | 2 +- packages/backend/tsconfig.json | 6 +- 9 files changed, 318 insertions(+), 452 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapi.abstract.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts diff --git a/packages/backend/src/scraper/openapi/api/openapi.abstract.ts b/packages/backend/src/scraper/openapi/api/openapi.abstract.ts new file mode 100644 index 00000000..446bbcd6 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapi.abstract.ts @@ -0,0 +1,30 @@ +import { DataSource } from 'typeorm'; +import { openApiConfig } from '../config/openapi.config'; +import { Stock } from '@/stock/domain/stock.entity'; + +export abstract class Openapi { + constructor(protected readonly datasource: DataSource) {} + + protected abstract start(): Promise; + + protected abstract interval(idx: number, stocks: Stock[]): Promise; + + protected abstract step(idx: number, stock: Stock): Promise; + + protected abstract getFromUrl( + config: typeof openApiConfig, + stockId: string, + ): object; + + protected abstract convertResToEntity(res: object, stockId: string): object; + + protected async getStockId() { + const entity = Stock; + const manager = this.datasource.manager; + const result = await manager.find(entity, { + select: { id: true }, + where: { isTrading: true }, + }); + return result; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 15484f0a..5f0168bd 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -1,278 +1,112 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { Between, DataSource } from 'typeorm'; +import { DataSource } from 'typeorm'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { - DetailDataQuery, - FinancialRatio, - isFinancialRatioData, - isProductDetail, - ProductDetail, - StockDetailQuery, -} from '../type/openapiDetailData.type'; -import { TR_IDS } from '../type/openapiUtil.type'; +import { DetailData, isDetailData } from '../type/openapiDetailData.type'; +import { TR_ID } from '../type/openapiUtil.type'; import { getOpenApi } from '../util/openapiUtil.api'; +import { Openapi } from './openapi.abstract'; import { OpenapiTokenApi } from './openapiToken.api'; -import { KospiStock } from '@/stock/domain/kospiStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; -import { StockDaily } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; @Injectable() -export class OpenapiDetailData { - private readonly financialUrl: string = - '/uapi/domestic-stock/v1/finance/financial-ratio'; - private readonly productUrl: string = - '/uapi/domestic-stock/v1/quotations/search-stock-info'; - private readonly intervals = 1000; +export class OpenapiDetailData extends Openapi { + private readonly TR_ID: TR_ID = 'FHKST01010100'; + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-price'; constructor( - private readonly openApiToken: OpenapiTokenApi, - private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, + protected readonly datasource: DataSource, + private readonly config: OpenapiTokenApi, ) { - //this.getDetailData(); - } - - @Cron('0 8 * * 1-5') - async getDetailData() { - if (process.env.NODE_ENV !== 'production') return; - const entityManager = this.datasource.manager; - const stocks = await entityManager.find(Stock); - const configCount = (await this.openApiToken.configs()).length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - this.logger.info((await this.openApiToken.configs())[i]); - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getDetailDataChunk(chunk, (await this.openApiToken.configs())[i]); - } - } - - private async saveDetailData(stockDetail: StockDetail) { - const manager = this.datasource.manager; - const entity = StockDetail; - const existingStockDetail = await manager.findOne(entity, { - where: { - stock: { id: stockDetail.stock.id }, - }, - }); - if (existingStockDetail) { - manager.update( - entity, - { stock: { id: stockDetail.stock.id } }, - stockDetail, - ); - } else { - manager.save(entity, stockDetail); - } - } - - private async saveKospiData(stockDetail: KospiStock) { - const manager = this.datasource.manager; - const entity = KospiStock; - const existingStockDetail = await manager.findOne(entity, { - where: { - stock: { id: stockDetail.stock.id }, - }, - }); - - if (existingStockDetail) { - manager.update( - entity, - { stock: { id: stockDetail.stock.id } }, - stockDetail, - ); - } else { - manager.save(entity, stockDetail); + super(datasource); + } + + @Cron('35 0 * * 1-5') + async start() { + const stock = await this.getStockId(); + const len = (await this.config.configs()).length; + const stockSize = Math.ceil(stock.length / len); + let i = 0; + while (i < len) { + this.interval(i, stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; } } - private async calPer(eps: number): Promise { - if (eps <= 0) return NaN; - const manager = this.datasource.manager; - const latestResult = await manager.find(StockDaily, { - skip: 0, - take: 1, - order: { createdAt: 'desc' }, - }); - // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 - if (latestResult && latestResult[0] && latestResult[0].close) { - const currentPrice = latestResult[0].close; - const per = currentPrice / eps; - - if (isNaN(per)) return 0; - else return per; - } else { - return 0; + protected async interval(idx: number, stocks: Stock[]) { + const interval = 100; + let time = 0; + for (const stock of stocks) { + setTimeout(() => this.step(idx, stock), time); + time += interval; } } - private async calMarketCap(lstg: number) { - const manager = this.datasource.manager; - const latestResult = await manager.find(StockDaily, { - skip: 0, - take: 1, - order: { createdAt: 'desc' }, - }); - - // TODO : price가 없는 경우 0으로 리턴, 나중에 NaN과 대응되게 리턴 - if (latestResult && latestResult[0] && latestResult[0].close) { - const currentPrice = latestResult[0].close; - const marketCap = lstg * currentPrice; - - if (isNaN(marketCap)) return 0; - else return marketCap; - } else { - return 0; + protected async step(idx: number, stock: Stock) { + try { + const config = (await this.config.configs())[idx]; + const res = await this.getFromUrl(config, stock.id); + if (res.output && isDetailData(res.output)) { + const entity = this.convertResToEntity(res.output, stock.id); + await this.save(entity); + } + } catch (error) { + this.logger.warn(`Error in detail data : ${error}`); + setTimeout(() => this.step(idx, stock), 100); } } - private async get52WeeksLowHigh() { - const manager = this.datasource.manager; - const nowDate = new Date(); - const weeksAgoDate = this.getDate52WeeksAgo(); - // 주식의 52주간 일단위 데이터 전체 중에 최고, 최저가를 바탕으로 최저가, 최고가 계산해서 가져오기 - const output = await manager.find(StockDaily, { - select: ['low', 'high'], - where: { - startTime: Between(weeksAgoDate, nowDate), - }, - }); - const result = output.reduce((prev, cur) => { - if (prev.low > cur.low) prev.low = cur.low; - if (prev.high < cur.high) prev.high = cur.high; - return cur; - }, new StockDaily()); - let low = 0; - let high = 0; - if (result.low && !isNaN(result.low)) low = result.low; - if (result.high && !isNaN(result.high)) high = result.high; - return { low, high }; + protected async getFromUrl(config: typeof openApiConfig, stockId: string) { + const query = this.query(stockId); + const res = await getOpenApi(this.url, config, query, this.TR_ID); + if (res) return res; + else throw new Error(); } - private async makeStockDetailObject( - output1: FinancialRatio, - output2: ProductDetail, - stockId: string, - ): Promise { + protected convertResToEntity(res: DetailData, stockId: string): StockDetail { const result = new StockDetail(); + result.eps = parseInt(res.eps); + result.high52w = parseInt(res.w52_hgpr); + result.low52w = parseInt(res.w52_lwpr); + result.marketCap = res.hts_avls; + result.per = parseFloat(res.per); result.stock = { id: stockId } as Stock; - result.marketCap = - (await this.calMarketCap(parseInt(output2.lstg_stqt))) + ''; - result.eps = parseInt(output1.eps); - const { low, high } = await this.get52WeeksLowHigh(); - result.low52w = low; - result.high52w = high; - const eps = parseInt(output1.eps); - if (isNaN(eps)) result.eps = 0; - else result.eps = eps; - const per = await this.calPer(eps); - if (isNaN(per)) result.per = 0; - else result.per = per; result.updatedAt = new Date(); return result; } - private async makeKospiStockObject(output: ProductDetail, stockId: string) { - const ret = new KospiStock(); - ret.isKospi = output.kospi200_item_yn === 'Y' ? true : false; - ret.stock = { id: stockId } as Stock; - return ret; - } - - private async getFinancialRatio(stock: Stock, conf: typeof openApiConfig) { - const dataQuery = this.getDetailDataQuery(stock.id!); - // 여기서 가져올 건 eps -> eps와 per 계산하자. - try { - const response = await getOpenApi( - this.financialUrl, - conf, - dataQuery, - TR_IDS.FINANCIAL_DATA, - ); - if (response.output && response.output[0]) { - const output1 = response.output; - return output1[0]; - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getProductData(stock: Stock, conf: typeof openApiConfig) { - const defaultQuery = this.getFinancialDataQuery(stock.id!); - - // 여기서 가져올 건 lstg-stqt - 상장주수를 바탕으로 시가총액 계산, kospi200_item_yn 코스피200종목여부 업데이트 - try { - const response = await getOpenApi( - this.productUrl, - conf, - defaultQuery, - TR_IDS.PRODUCTION_DETAIL, - ); - if (response.output) { - const output2 = response.output; - return output2; - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getDetailDataDelay(stock: Stock, conf: typeof openApiConfig) { - const output1 = await this.getFinancialRatio(stock, conf); - const output2 = await this.getProductData(stock, conf); - - if (isFinancialRatioData(output1) && isProductDetail(output2)) { - const stockDetail = await this.makeStockDetailObject( - output1, - output2, - stock.id!, - ); - this.saveDetailData(stockDetail); - const kospiStock = await this.makeKospiStockObject(output2, stock.id!); - this.saveKospiData(kospiStock); - - this.logger.info(`${stock.id!} detail data is saved`); - } - } - - private async getDetailDataChunk(chunk: Stock[], conf: typeof openApiConfig) { - let delay = 0; - for await (const stock of chunk) { - setTimeout(() => this.getDetailDataDelay(stock, conf), delay); - delay += this.intervals; - } - } + //private async getStockId() { + // const entity = Stock; + // const manager = this.datasource.manager; + // const result = await manager.find(entity, { + // select: { id: true }, + // where: { isTrading: true }, + // }); + // return result; + //} - private getFinancialDataQuery( - stockId: string, - code: '300' | '301' | '302' | '306' = '300', - ): StockDetailQuery { - return { - pdno: stockId, - prdt_type_cd: code, - }; - } - - private getDetailDataQuery( - stockId: string, - divCode: 'J' = 'J', - classify: '0' | '1' = '0', - ): DetailDataQuery { + private async save(saveEntity: StockDetail) { + const entity = StockDetail; + const manager = this.datasource.manager; + await manager + .createQueryBuilder() + .insert() + .into(entity) + .values(saveEntity) + .orUpdate( + ['market_cap', 'eps', 'per', 'high52w', 'low52w', 'updated_at'], + ['stock_id'], + ) + .execute(); + } + + protected query(stockId: string, code: 'J' = 'J') { return { - fid_div_cls_code: classify, - fid_cond_mrkt_div_code: divCode, + fid_cond_mrkt_div_code: code, fid_input_iscd: stockId, }; } - - private getDate52WeeksAgo(): Date { - const today = new Date(); - const weeksAgo = 52 * 7; - const date52WeeksAgo = new Date(today.setDate(today.getDate() - weeksAgo)); - date52WeeksAgo.setHours(0, 0, 0, 0); - return date52WeeksAgo; - } } diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 7db12b2c..2c02f87d 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -42,7 +42,7 @@ export class OpenapiLiveData { } } - // 현재가 체결로는 데이터가 부족해 현재가 시세를 사용함. + // 현재가 체결 convertResponseToStockLiveData = ( data: OpenapiLiveData, stockId: string, diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 1d5d86a0..778f82ee 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -7,7 +7,7 @@ import { isChartData, ItemChartPriceQuery, Period, -} from '../type/openapiPeriodData'; +} from '../type/openapiPeriodData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { getOpenApi, diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index fa4b3a88..a8d21d02 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -1,215 +1,168 @@ /* eslint-disable @typescript-eslint/no-explicit-any*/ /* eslint-disable max-lines-per-function */ -export type DetailDataQuery = { - fid_cond_mrkt_div_code: 'J'; - fid_input_iscd: string; - fid_div_cls_code: '0' | '1'; +export type DetailData = { + iscd_stat_cls_code: string; + marg_rate: string; + rprs_mrkt_kor_name: string; + bstp_kor_isnm: string; + temp_stop_yn: string; + oprc_rang_cont_yn: string; + clpr_rang_cont_yn: string; + crdt_able_yn: string; + grmn_rate_cls_code: string; + elw_pblc_yn: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + acml_tr_pbmn: string; + acml_vol: string; + prdy_vrss_vol_rate: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_mxpr: string; + stck_llam: string; + stck_sdpr: string; + wghn_avrg_stck_prc: string; + hts_frgn_ehrt: string; + frgn_ntby_qty: string; + pgtr_ntby_qty: string; + pvt_scnd_dmrs_prc: string; + pvt_frst_dmrs_prc: string; + pvt_pont_val: string; + pvt_frst_dmsp_prc: string; + pvt_scnd_dmsp_prc: string; + dmrs_val: string; + dmsp_val: string; + cpfn: string; + rstc_wdth_prc: string; + stck_fcam: string; + stck_sspr: string; + aspr_unit: string; + hts_deal_qty_unit_val: string; + lstn_stcn: string; + hts_avls: string; + per: string; + pbr: string; + stac_month: string; + vol_tnrt: string; + eps: string; + bps: string; + d250_hgpr: string; + d250_hgpr_date: string; + d250_hgpr_vrss_prpr_rate: string; + d250_lwpr: string; + d250_lwpr_date: string; + d250_lwpr_vrss_prpr_rate: string; + stck_dryy_hgpr: string; + dryy_hgpr_vrss_prpr_rate: string; + dryy_hgpr_date: string; + stck_dryy_lwpr: string; + dryy_lwpr_vrss_prpr_rate: string; + dryy_lwpr_date: string; + w52_hgpr: string; + w52_hgpr_vrss_prpr_ctrt: string; + w52_hgpr_date: string; + w52_lwpr: string; + w52_lwpr_vrss_prpr_ctrt: string; + w52_lwpr_date: string; + whol_loan_rmnd_rate: string; + ssts_yn: string; + stck_shrn_iscd: string; + fcam_cnnm: string; + cpfn_cnnm: string; + frgn_hldn_qty: string; + vi_cls_code: string; + ovtm_vi_cls_code: string; + last_ssts_cntg_qty: string; + invt_caful_yn: string; + mrkt_warn_cls_code: string; + short_over_yn: string; + sltr_yn: string; }; -export type FinancialRatio = { - stac_yymm: string; // 결산 년월 - grs: string; // 매출액 증가율 - bsop_prfi_inrt: string; // 영업 이익 증가율 - ntin_inrt: string; // 순이익 증가율 - roe_val: string; // ROE 값 - eps: string; // EPS - sps: string; // 주당매출액 - bps: string; // BPS - rsrv_rate: string; // 유보 비율 - lblt_rate: string; // 부채 비율 -}; - -export function isFinancialRatioData(data: any): data is FinancialRatio { +export function isDetailData(data: any): data is DetailData { return ( - data && - typeof data.stac_yymm === 'string' && - typeof data.grs === 'string' && - typeof data.bsop_prfi_inrt === 'string' && - typeof data.ntin_inrt === 'string' && - typeof data.roe_val === 'string' && + typeof data.iscd_stat_cls_code === 'string' && + typeof data.marg_rate === 'string' && + typeof data.rprs_mrkt_kor_name === 'string' && + typeof data.bstp_kor_isnm === 'string' && + typeof data.temp_stop_yn === 'string' && + typeof data.oprc_rang_cont_yn === 'string' && + typeof data.clpr_rang_cont_yn === 'string' && + typeof data.crdt_able_yn === 'string' && + typeof data.grmn_rate_cls_code === 'string' && + typeof data.elw_pblc_yn === 'string' && + typeof data.stck_prpr === 'string' && + typeof data.prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.acml_vol === 'string' && + typeof data.prdy_vrss_vol_rate === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.stck_mxpr === 'string' && + typeof data.stck_llam === 'string' && + typeof data.stck_sdpr === 'string' && + typeof data.wghn_avrg_stck_prc === 'string' && + typeof data.hts_frgn_ehrt === 'string' && + typeof data.frgn_ntby_qty === 'string' && + typeof data.pgtr_ntby_qty === 'string' && + typeof data.pvt_scnd_dmrs_prc === 'string' && + typeof data.pvt_frst_dmrs_prc === 'string' && + typeof data.pvt_pont_val === 'string' && + typeof data.pvt_frst_dmsp_prc === 'string' && + typeof data.pvt_scnd_dmsp_prc === 'string' && + typeof data.dmrs_val === 'string' && + typeof data.dmsp_val === 'string' && + typeof data.cpfn === 'string' && + typeof data.rstc_wdth_prc === 'string' && + typeof data.stck_fcam === 'string' && + typeof data.stck_sspr === 'string' && + typeof data.aspr_unit === 'string' && + typeof data.hts_deal_qty_unit_val === 'string' && + typeof data.lstn_stcn === 'string' && + typeof data.hts_avls === 'string' && + typeof data.per === 'string' && + typeof data.pbr === 'string' && + typeof data.stac_month === 'string' && + typeof data.vol_tnrt === 'string' && typeof data.eps === 'string' && - typeof data.sps === 'string' && typeof data.bps === 'string' && - typeof data.rsrv_rate === 'string' && - typeof data.lblt_rate === 'string' + typeof data.d250_hgpr === 'string' && + typeof data.d250_hgpr_date === 'string' && + typeof data.d250_hgpr_vrss_prpr_rate === 'string' && + typeof data.d250_lwpr === 'string' && + typeof data.d250_lwpr_date === 'string' && + typeof data.d250_lwpr_vrss_prpr_rate === 'string' && + typeof data.stck_dryy_hgpr === 'string' && + typeof data.dryy_hgpr_vrss_prpr_rate === 'string' && + typeof data.dryy_hgpr_date === 'string' && + typeof data.stck_dryy_lwpr === 'string' && + typeof data.dryy_lwpr_vrss_prpr_rate === 'string' && + typeof data.dryy_lwpr_date === 'string' && + typeof data.w52_hgpr === 'string' && + typeof data.w52_hgpr_vrss_prpr_ctrt === 'string' && + typeof data.w52_hgpr_date === 'string' && + typeof data.w52_lwpr === 'string' && + typeof data.w52_lwpr_vrss_prpr_ctrt === 'string' && + typeof data.w52_lwpr_date === 'string' && + typeof data.whol_loan_rmnd_rate === 'string' && + typeof data.ssts_yn === 'string' && + typeof data.stck_shrn_iscd === 'string' && + typeof data.fcam_cnnm === 'string' && + typeof data.cpfn_cnnm === 'string' && + typeof data.frgn_hldn_qty === 'string' && + typeof data.vi_cls_code === 'string' && + typeof data.ovtm_vi_cls_code === 'string' && + typeof data.last_ssts_cntg_qty === 'string' && + typeof data.invt_caful_yn === 'string' && + typeof data.mrkt_warn_cls_code === 'string' && + typeof data.short_over_yn === 'string' && + typeof data.sltr_yn === 'string' ); } - -export type ProductDetail = { - pdno: string; // 상품번호 - prdt_type_cd: string; // 상품유형코드 - mket_id_cd: string; // 시장ID코드 - scty_grp_id_cd: string; // 증권그룹ID코드 - excg_dvsn_cd: string; // 거래소구분코드 - setl_mmdd: string; // 결산월일 - lstg_stqt: string; // 상장주수 - 이거 사용 - lstg_cptl_amt: string; // 상장자본금액 - cpta: string; // 자본금 - papr: string; // 액면가 - issu_pric: string; // 발행가격 - kospi200_item_yn: string; // 코스피200종목여부 - 이것도 사용 - scts_mket_lstg_dt: string; // 유가증권시장상장일자 - scts_mket_lstg_abol_dt: string; // 유가증권시장상장폐지일자 - kosdaq_mket_lstg_dt: string; // 코스닥시장상장일자 - kosdaq_mket_lstg_abol_dt: string; // 코스닥시장상장폐지일자 - frbd_mket_lstg_dt: string; // 프리보드시장상장일자 - frbd_mket_lstg_abol_dt: string; // 프리보드시장상장폐지일자 - reits_kind_cd: string; // 리츠종류코드 - etf_dvsn_cd: string; // ETF구분코드 - oilf_fund_yn: string; // 유전펀드여부 - idx_bztp_lcls_cd: string; // 지수업종대분류코드 - idx_bztp_mcls_cd: string; // 지수업종중분류코드 - idx_bztp_scls_cd: string; // 지수업종소분류코드 - stck_kind_cd: string; // 주식종류코드 - mfnd_opng_dt: string; // 뮤추얼펀드개시일자 - mfnd_end_dt: string; // 뮤추얼펀드종료일자 - dpsi_erlm_cncl_dt: string; // 예탁등록취소일자 - etf_cu_qty: string; // ETFCU수량 - prdt_name: string; // 상품명 - prdt_name120: string; // 상품명120 - prdt_abrv_name: string; // 상품약어명 - std_pdno: string; // 표준상품번호 - prdt_eng_name: string; // 상품영문명 - prdt_eng_name120: string; // 상품영문명120 - prdt_eng_abrv_name: string; // 상품영문약어명 - dpsi_aptm_erlm_yn: string; // 예탁지정등록여부 - etf_txtn_type_cd: string; // ETF과세유형코드 - etf_type_cd: string; // ETF유형코드 - lstg_abol_dt: string; // 상장폐지일자 - nwst_odst_dvsn_cd: string; // 신주구주구분코드 - sbst_pric: string; // 대용가격 - thco_sbst_pric: string; // 당사대용가격 - thco_sbst_pric_chng_dt: string; // 당사대용가격변경일자 - tr_stop_yn: string; // 거래정지여부 - admn_item_yn: string; // 관리종목여부 - thdt_clpr: string; // 당일종가 - bfdy_clpr: string; // 전일종가 - clpr_chng_dt: string; // 종가변경일자 - std_idst_clsf_cd: string; // 표준산업분류코드 - std_idst_clsf_cd_name: string; // 표준산업분류코드명 - idx_bztp_lcls_cd_name: string; // 지수업종대분류코드명 - idx_bztp_mcls_cd_name: string; // 지수업종중분류코드명 - idx_bztp_scls_cd_name: string; // 지수업종소분류코드명 - ocr_no: string; // OCR번호 - crfd_item_yn: string; // 크라우드펀딩종목여부 - elec_scty_yn: string; // 전자증권여부 - issu_istt_cd: string; // 발행기관코드 - etf_chas_erng_rt_dbnb: string; // ETF추적수익율배수 - etf_etn_ivst_heed_item_yn: string; // ETFETN투자유의종목여부 - stln_int_rt_dvsn_cd: string; // 대주이자율구분코드 - frnr_psnl_lmt_rt: string; // 외국인개인한도비율 - lstg_rqsr_issu_istt_cd: string; // 상장신청인발행기관코드 - lstg_rqsr_item_cd: string; // 상장신청인종목코드 - trst_istt_issu_istt_cd: string; // 신탁기관발행기관코드 -}; - -export const isProductDetail = (data: any): data is ProductDetail => { - return ( - data && - typeof data.pdno === 'string' && - typeof data.prdt_type_cd === 'string' && - typeof data.mket_id_cd === 'string' && - typeof data.scty_grp_id_cd === 'string' && - typeof data.excg_dvsn_cd === 'string' && - typeof data.setl_mmdd === 'string' && - typeof data.lstg_stqt === 'string' && - typeof data.lstg_cptl_amt === 'string' && - typeof data.cpta === 'string' && - typeof data.papr === 'string' && - typeof data.issu_pric === 'string' && - typeof data.kospi200_item_yn === 'string' && - typeof data.scts_mket_lstg_dt === 'string' && - typeof data.scts_mket_lstg_abol_dt === 'string' && - typeof data.kosdaq_mket_lstg_dt === 'string' && - typeof data.kosdaq_mket_lstg_abol_dt === 'string' && - typeof data.frbd_mket_lstg_dt === 'string' && - typeof data.frbd_mket_lstg_abol_dt === 'string' && - typeof data.reits_kind_cd === 'string' && - typeof data.etf_dvsn_cd === 'string' && - typeof data.oilf_fund_yn === 'string' && - typeof data.idx_bztp_lcls_cd === 'string' && - typeof data.idx_bztp_mcls_cd === 'string' && - typeof data.idx_bztp_scls_cd === 'string' && - typeof data.stck_kind_cd === 'string' && - typeof data.mfnd_opng_dt === 'string' && - typeof data.mfnd_end_dt === 'string' && - typeof data.dpsi_erlm_cncl_dt === 'string' && - typeof data.etf_cu_qty === 'string' && - typeof data.prdt_name === 'string' && - typeof data.prdt_name120 === 'string' && - typeof data.prdt_abrv_name === 'string' && - typeof data.std_pdno === 'string' && - typeof data.prdt_eng_name === 'string' && - typeof data.prdt_eng_name120 === 'string' && - typeof data.prdt_eng_abrv_name === 'string' && - typeof data.dpsi_aptm_erlm_yn === 'string' && - typeof data.etf_txtn_type_cd === 'string' && - typeof data.etf_type_cd === 'string' && - typeof data.lstg_abol_dt === 'string' && - typeof data.nwst_odst_dvsn_cd === 'string' && - typeof data.sbst_pric === 'string' && - typeof data.thco_sbst_pric === 'string' && - typeof data.thco_sbst_pric_chng_dt === 'string' && - typeof data.tr_stop_yn === 'string' && - typeof data.admn_item_yn === 'string' && - typeof data.thdt_clpr === 'string' && - typeof data.bfdy_clpr === 'string' && - typeof data.clpr_chng_dt === 'string' && - typeof data.std_idst_clsf_cd === 'string' && - typeof data.std_idst_clsf_cd_name === 'string' && - typeof data.idx_bztp_lcls_cd_name === 'string' && - typeof data.idx_bztp_mcls_cd_name === 'string' && - typeof data.idx_bztp_scls_cd_name === 'string' && - typeof data.ocr_no === 'string' && - typeof data.crfd_item_yn === 'string' && - typeof data.elec_scty_yn === 'string' && - typeof data.issu_istt_cd === 'string' && - typeof data.etf_chas_erng_rt_dbnb === 'string' && - typeof data.etf_etn_ivst_heed_item_yn === 'string' && - typeof data.stln_int_rt_dvsn_cd === 'string' && - typeof data.frnr_psnl_lmt_rt === 'string' && - typeof data.lstg_rqsr_issu_istt_cd === 'string' && - typeof data.lstg_rqsr_item_cd === 'string' && - typeof data.trst_istt_issu_istt_cd === 'string' - ); -}; - -export type StockDetailQuery = { - pdno: string; - prdt_type_cd: string; -}; - -//export type FinancialDetail = { -// stac_yymm: string; // 결산 년월 -// sale_account: string; // 매출액 -// sale_cost: string; // 매출원가 -// sale_totl_prfi: string; // 매출총이익 -// depr_cost: string; // 감가상각비 -// sell_mang: string; // 판매관리비 -// bsop_prti: string; // 영업이익 -// bsop_non_ernn: string; // 영업외수익 -// bsop_non_expn: string; // 영업외비용 -// op_prfi: string; // 영업이익 -// spec_prfi: string; // 특별이익 -// spec_loss: string; // 특별손실 -// thtr_ntin: string; // 세전순이익 -//}; - -//export const isFinancialDetail = (data: any): data is FinancialDetail => { -// return ( -// typeof data.stac_yymm === 'string' && -// typeof data.sale_account === 'string' && -// typeof data.sale_cost === 'string' && -// typeof data.sale_totl_prfi === 'string' && -// typeof data.depr_cost === 'string' && -// typeof data.sell_mang === 'string' && -// typeof data.bsop_prti === 'string' && -// typeof data.bsop_non_ernn === 'string' && -// typeof data.bsop_non_expn === 'string' && -// typeof data.op_prfi === 'string' && -// typeof data.spec_prfi === 'string' && -// typeof data.spec_loss === 'string' && -// typeof data.thtr_ntin === 'string' -// ); -//}; diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts new file mode 100644 index 00000000..e4066f7c --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type Period = 'D' | 'W' | 'M' | 'Y'; +export type ChartData = { + stck_bsop_date: string; + stck_clpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + acml_vol: string; + acml_tr_pbmn: string; + flng_cls_code: string; + prtt_rate: string; + mod_yn: string; + prdy_vrss_sign: string; + prdy_vrss: string; + revl_issu_reas: string; +}; + +export type ItemChartPriceQuery = { + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_date_1: string; + fid_input_date_2: string; + fid_period_div_code: Period; + fid_org_adj_prc: number; +}; + +export const isChartData = (data?: any) => { + return ( + data && + typeof data.stck_bsop_date === 'string' && + typeof data.stck_clpr === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.acml_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.flng_cls_code === 'string' && + typeof data.prtt_rate === 'string' && + typeof data.mod_yn === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_vrss === 'string' && + typeof data.revl_issu_reas === 'string' + ); +}; diff --git a/packages/backend/src/stock/domain/stockDetail.entity.ts b/packages/backend/src/stock/domain/stockDetail.entity.ts index e11d1cd9..22a084e4 100644 --- a/packages/backend/src/stock/domain/stockDetail.entity.ts +++ b/packages/backend/src/stock/domain/stockDetail.entity.ts @@ -27,7 +27,7 @@ export class StockDetail { @Column({ type: 'integer' }) eps: number; - @Column({ type: 'decimal', precision: 6, scale: 3 }) + @Column({ type: 'decimal', precision: 15, scale: 2 }) per: number; @Column({ type: 'integer' }) diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 9f93e968..91a02c0b 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -5,11 +5,11 @@ import { OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { Mention } from '@/chat/domain/mention.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; import { OauthType } from '@/user/domain/ouathType'; import { Role } from '@/user/domain/role'; -import { Mention } from '@/chat/domain/mention.entity'; @Index('nickname_sub_name', ['nickname', 'subName'], { unique: true }) @Index('type_oauth_id', ['type', 'oauthId'], { unique: true }) diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index f01a23e5..2eb3e242 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -8,5 +8,9 @@ }, "strictPropertyInitialization": false }, - "include": ["src/**/*", "test/**/*", "src/scraper/openapi/type/openapiPeriodData.ts"] + "include": [ + "src/**/*", + "test/**/*", + "src/scraper/openapi/type/openapiPeriodData.type.ts" + ] } From a62b313e5735dccd75006f922eb569174d640183 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 10:51:41 +0900 Subject: [PATCH 115/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20openApiToken=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20=EB=B0=9C=EC=83=9D=ED=95=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiFluctuationData.api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index d5671e73..39626588 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; @@ -6,10 +7,9 @@ import { DECREASE_STOCK_QUERY, INCREASE_STOCK_QUERY, } from '@/scraper/openapi/constants/query'; -import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; -import { Cron } from '@nestjs/schedule'; @Injectable() export class OpenapiFluctuationData { @@ -80,7 +80,7 @@ export class OpenapiFluctuationData { const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; const result = await getOpenApi( this.fluctuationUrl, - this.openApiToken.configs[0], + (await this.openApiToken.configs())[0], query, TR_IDS.FLUCTUATION_DATA, ); From d3b6d65fe9708d91ee47b6ef92e94558cacf2114 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 10:56:45 +0900 Subject: [PATCH 116/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B5=AC=EA=B8=80?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=20=EC=A3=BC=EC=86=8C=20=EC=9E=84=EC=8B=9C?= =?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 --- packages/backend/src/auth/google/googleAuth.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/auth/google/googleAuth.controller.ts b/packages/backend/src/auth/google/googleAuth.controller.ts index 1d460748..73f39449 100644 --- a/packages/backend/src/auth/google/googleAuth.controller.ts +++ b/packages/backend/src/auth/google/googleAuth.controller.ts @@ -21,7 +21,7 @@ export class GoogleAuthController { @Get('/redirect') @UseGuards(GoogleAuthGuard) async handleRedirect(@Res() response: Response) { - response.redirect('/'); + response.redirect('http://localhost:5173'); } @ApiOperation({ From 743dee3d51b325552dea41f4bb2e54ebacd969ed Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 11:12:09 +0900 Subject: [PATCH 117/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A3=BC=EC=8B=9D=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?stock=20id=20=EA=B0=92=EC=9D=84=20=ED=86=B5=ED=95=B4=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/dto/userStock.request.ts | 12 +++++------- packages/backend/src/stock/dto/userStock.response.ts | 6 +++--- packages/backend/src/stock/stock.controller.ts | 11 ++++------- packages/backend/src/stock/stock.service.ts | 12 +++++++----- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/backend/src/stock/dto/userStock.request.ts b/packages/backend/src/stock/dto/userStock.request.ts index 992f6465..0c3f33e2 100644 --- a/packages/backend/src/stock/dto/userStock.request.ts +++ b/packages/backend/src/stock/dto/userStock.request.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsInt, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; export class UserStockRequest { @ApiProperty({ @@ -13,10 +12,9 @@ export class UserStockRequest { export class UserStockDeleteRequest { @ApiProperty({ - example: 1, - description: '유저 소유 주식 id', + example: '005390', + description: '종목 id', }) - @IsInt() - @Transform(({ value }) => parseInt(value)) - userStockId: number; + @IsString() + stockId: string; } diff --git a/packages/backend/src/stock/dto/userStock.response.ts b/packages/backend/src/stock/dto/userStock.response.ts index 5e3c12fa..fd485289 100644 --- a/packages/backend/src/stock/dto/userStock.response.ts +++ b/packages/backend/src/stock/dto/userStock.response.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; export class UserStockResponse { - @ApiProperty({ description: '사용자 주식 id', example: 1 }) - id: number; + @ApiProperty({ description: '소유 주식 id', example: '005930' }) + id: string; @ApiProperty({ description: '응답 메시지', @@ -16,7 +16,7 @@ export class UserStockResponse { }) date: Date; - constructor(id: number, message: string) { + constructor(id: string, message: string) { this.id = id; this.message = message; this.date = new Date(); diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 6f18c657..2d094cb8 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -105,12 +105,9 @@ export class StockController { @Body() requestBody: UserStockRequest, @GetUser() user: User, ): Promise { - const stock = await this.stockService.createUserStock( - user.id, - requestBody.stockId, - ); + await this.stockService.createUserStock(user.id, requestBody.stockId); return new UserStockResponse( - Number(stock.identifiers[0].id), + requestBody.stockId, '사용자 소유 주식을 추가했습니다.', ); } @@ -133,9 +130,9 @@ export class StockController { @Body() request: UserStockDeleteRequest, @GetUser() user: User, ): Promise { - await this.stockService.deleteUserStock(user.id, request.userStockId); + await this.stockService.deleteUserStock(user.id, request.stockId); return new UserStockResponse( - request.userStockId, + request.stockId, '사용자 소유 주식을 삭제했습니다.', ); } diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index c72fb091..83d5f6b9 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -59,16 +59,18 @@ export class StockService { }); } - async deleteUserStock(userId: number, userStockId: number) { + async deleteUserStock(userId: number, stockId: string) { await this.datasource.transaction(async (manager) => { const userStock = await manager.findOne(UserStock, { - where: { id: userStockId }, + where: { user: { id: userId }, stock: { id: stockId } }, relations: ['user'], }); this.validateUserStock(userId, userStock); - await manager.delete(UserStock, { - id: userStockId, - }); + if (userStock) { + await manager.delete(UserStock, { + id: userStock.id, + }); + } }); } From 54a4d6d33d80626f24526d6ac9eb3fb52465d227 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 11:53:47 +0900 Subject: [PATCH 118/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=86=8C=EC=9C=A0=20=EC=A3=BC=EC=8B=9D=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B3=B5=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=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/domain/userStock.entity.ts | 8 +-- .../src/stock/dto/userStock.response.ts | 58 +++++++++++++++++++ .../backend/src/stock/stock.controller.ts | 15 +++++ .../backend/src/stock/stock.service.spec.ts | 7 +-- packages/backend/src/stock/stock.service.ts | 12 ++++ 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/stock/domain/userStock.entity.ts b/packages/backend/src/stock/domain/userStock.entity.ts index cf4e8e8f..b0c8a7eb 100644 --- a/packages/backend/src/stock/domain/userStock.entity.ts +++ b/packages/backend/src/stock/domain/userStock.entity.ts @@ -13,14 +13,14 @@ import { User } from '@/user/domain/user.entity'; @Entity() export class UserStock { @PrimaryGeneratedColumn() - id?: number; + id: number; @ManyToOne(() => User) - user?: User; + user: User; @ManyToOne(() => Stock) - stock?: Stock; + stock: Stock; @Column(() => DateEmbedded, { prefix: '' }) - date?: DateEmbedded; + date: DateEmbedded; } diff --git a/packages/backend/src/stock/dto/userStock.response.ts b/packages/backend/src/stock/dto/userStock.response.ts index fd485289..0e25e1e0 100644 --- a/packages/backend/src/stock/dto/userStock.response.ts +++ b/packages/backend/src/stock/dto/userStock.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { UserStock } from '@/stock/domain/userStock.entity'; export class UserStockResponse { @ApiProperty({ description: '소유 주식 id', example: '005930' }) @@ -38,3 +39,60 @@ export class UserStockOwnerResponse { this.date = new Date(); } } + +class UserStockResult { + @ApiProperty({ + description: '유저 주식 id', + example: 1, + }) + id: number; + + @ApiProperty({ + description: '종목 id', + example: '005930', + }) + stockId: string; + + @ApiProperty({ + description: '종목 이름', + example: '삼성전자', + }) + name: string; + + @ApiProperty({ + description: '거래 가능 여부', + example: true, + }) + isTrading: boolean; + + @ApiProperty({ + description: '그룹 코드', + example: 'A', + }) + groupCode: string; + + @ApiProperty({ + description: '생성일', + example: new Date(), + }) + createdAt: Date; +} + +export class UserStocksResponse { + @ApiProperty({ + description: '사용자 주식 정보', + type: [UserStockResult], + }) + userStocks: UserStockResult[]; + + constructor(userStocks: UserStock[]) { + this.userStocks = userStocks.map((userStock) => ({ + id: userStock.id, + stockId: userStock.stock.id, + name: userStock.stock.name, + isTrading: userStock.stock.isTrading, + groupCode: userStock.stock.groupCode, + createdAt: userStock.date.createdAt, + })); + } +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 2d094cb8..9e8405eb 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -45,6 +45,7 @@ import { import { UserStockOwnerResponse, UserStockResponse, + UserStocksResponse, } from '@/stock/dto/userStock.response'; import { User } from '@/user/domain/user.entity'; @@ -161,6 +162,20 @@ export class StockController { return new UserStockOwnerResponse(result); } + @Get('/user') + @ApiOperation({ + summary: '유저 주식 조회 API', + description: '유저 주식을 조회', + }) + @ApiOkResponse({ + description: '유저 주식 조회 성공', + type: UserStocksResponse, + }) + async getUserStocks(@Req() request: Request) { + const user = request.user as User; + return await this.stockService.getUserStocks(user?.id); + } + @ApiOperation({ summary: '주식 검색 API', description: '주식 이름에 매칭되는 주식을 검색', diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 2675373e..36e4d77e 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -15,7 +15,6 @@ const logger: Logger = { describe('StockService 테스트', () => { const stockId = 'A005930'; const userId = 1; - const userStockId = 1; test('주식의 조회수를 증가시킨다.', async () => { const managerMock = { @@ -91,7 +90,7 @@ describe('StockService 테스트', () => { const dataSource = createDataSourceMock(managerMock); const stockService = new StockService(dataSource as DataSource, logger); - await stockService.deleteUserStock(userId, userStockId); + await stockService.deleteUserStock(userId, stockId); expect(managerMock.findOne).toHaveBeenCalled(); expect(managerMock.delete).toHaveBeenCalled(); @@ -104,7 +103,7 @@ describe('StockService 테스트', () => { const dataSource = createDataSourceMock(managerMock); const stockService = new StockService(dataSource as DataSource, logger); - await expect(() => stockService.deleteUserStock(userId, 2)).rejects.toThrow( + await expect(() => stockService.deleteUserStock(userId, "13")).rejects.toThrow( 'user stock not found', ); }); @@ -118,7 +117,7 @@ describe('StockService 테스트', () => { const stockService = new StockService(dataSource as DataSource, logger); await expect(() => - stockService.deleteUserStock(notOwnerUserId, userStockId), + stockService.deleteUserStock(notOwnerUserId, stockId), ).rejects.toThrow('you are not owner of user stock'); }); diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 83d5f6b9..a8952496 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -9,6 +9,7 @@ import { StocksResponse, } from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; +import { UserStocksResponse } from '@/stock/dto/userStock.response'; @Injectable() export class StockService { @@ -53,6 +54,17 @@ export class StockService { }); } + async getUserStocks(userId?: number) { + if (!userId) { + return new UserStocksResponse([]); + } + const result = await this.datasource.manager.find(UserStock, { + where: { user: { id: userId } }, + relations: ['stock'], + }); + return new UserStocksResponse(result); + } + async checkStockExist(stockId: string) { return await this.datasource.manager.exists(Stock, { where: { id: stockId }, From 00ef29dd2abfd50d64169226f1fcb8aa16293937 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 15:24:32 +0900 Subject: [PATCH 119/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiDetailData.api.ts | 14 ++-- .../openapi/api/openapiFluctuationData.api.ts | 78 ++++++++++++++++--- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 5f0168bd..118a0b95 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -88,6 +88,13 @@ export class OpenapiDetailData extends Openapi { // return result; //} + protected query(stockId: string, code: 'J' = 'J') { + return { + fid_cond_mrkt_div_code: code, + fid_input_iscd: stockId, + }; + } + private async save(saveEntity: StockDetail) { const entity = StockDetail; const manager = this.datasource.manager; @@ -102,11 +109,4 @@ export class OpenapiDetailData extends Openapi { ) .execute(); } - - protected query(stockId: string, code: 'J' = 'J') { - return { - fid_cond_mrkt_div_code: code, - fid_input_iscd: stockId, - }; - } } diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 39626588..804b558a 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -10,11 +10,14 @@ import { import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { Stock } from '@/stock/domain/stock.entity'; @Injectable() export class OpenapiFluctuationData { - private readonly fluctuationUrl: string = + private readonly fluctuationUrl = '/uapi/domestic-stock/v1/ranking/fluctuation'; + private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; constructor( private readonly openApiToken: OpenapiTokenApi, private readonly datasource: DataSource, @@ -32,12 +35,14 @@ export class OpenapiFluctuationData { async getDecreaseRankStocks(count = 5) { try { if (count === 0) return; + const result = await this.getFluctuationRankApiStocks(false); + const liveResult = await this.getFluctuationRankApiLive(result); await this.datasource.transaction(async (manager) => { - const result = await this.getFluctuationRankApiStocks(false); await this.datasource.manager.delete(FluctuationRankStock, { isRising: false, }); await this.saveFluctuationRankStocks(result, manager); + await this.saveLiveData(liveResult, manager); this.logger.info('decrease rank stocks updated'); }); } catch (error) { @@ -49,12 +54,14 @@ export class OpenapiFluctuationData { async getIncreaseRankStocks(count = 5) { try { if (count === 0) return; + const result = await this.getFluctuationRankApiStocks(true); + const liveResult = await this.getFluctuationRankApiLive(result); await this.datasource.transaction(async (manager) => { - const result = await this.getFluctuationRankApiStocks(true); await this.datasource.manager.delete(FluctuationRankStock, { isRising: true, }); await this.saveFluctuationRankStocks(result, manager); + await this.saveLiveData(liveResult, manager); this.logger.info('increase rank stocks updated'); }); } catch (error) { @@ -76,6 +83,20 @@ export class OpenapiFluctuationData { .execute(); } + private async saveLiveData(data: StockLiveData[], manager: EntityManager) { + return await manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .orUpdate( + ['current_price', 'change_rate', 'volume', 'high', 'low', 'open'], + ['stock_id'], + ) + .execute(); + } + private async getFluctuationRankApiStocks(isRising: boolean) { const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; const result = await getOpenApi( @@ -85,11 +106,50 @@ export class OpenapiFluctuationData { TR_IDS.FLUCTUATION_DATA, ); - return result.output.map((result: Record) => ({ - rank: result.data_rank, - fluctuationRate: result.prdy_ctrt, - stock: { id: result.stck_shrn_iscd }, - isRising, - })); + return result.output.slice(0, 20).map((result: Record) => { + return { + rank: result.data_rank, + fluctuationRate: result.prdy_ctrt, + stock: { id: result.stck_shrn_iscd }, + isRising, + }; + }); + } + + private async getFluctuationRankApiLive(data: FluctuationRankStock[]) { + const result: StockLiveData[] = []; + for (let i = 0; i < 20; ++i) { + if (i >= data.length) break; + else if (i == 10) + await new Promise((resolve) => setTimeout(resolve, 1000)); + const stockId = data[i].stock.id; + const stockData = await getOpenApi( + this.liveUrl, + (await this.openApiToken.configs())[0], + { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockId, + }, + TR_IDS.LIVE_DATA, + ); + result.push(this.convertToStockLiveData(stockData.output, stockId)); + } + return result; + } + + private convertToStockLiveData( + stockData: Record, + stockId: string, + ): StockLiveData { + const stockLiveData = new StockLiveData(); + stockLiveData.stock = { id: stockId } as Stock; + stockLiveData.currentPrice = parseFloat(stockData.stck_prpr); + stockLiveData.changeRate = parseFloat(stockData.prdy_ctrt); + stockLiveData.volume = parseInt(stockData.acml_vol); + stockLiveData.high = parseFloat(stockData.stck_hgpr); + stockLiveData.low = parseFloat(stockData.stck_lwpr); + stockLiveData.open = parseFloat(stockData.stck_oprc); + stockLiveData.updatedAt = new Date(); + return stockLiveData; } } From 26243ddb5bfe1551910a569a39b3719861de1e74 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 16:10:23 +0900 Subject: [PATCH 120/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B5=AC=EA=B8=80?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9B=84=20=EB=A6=AC=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EB=A0=89=ED=8A=B8=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/auth/google/googleAuth.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/auth/google/googleAuth.controller.ts b/packages/backend/src/auth/google/googleAuth.controller.ts index 73f39449..1d460748 100644 --- a/packages/backend/src/auth/google/googleAuth.controller.ts +++ b/packages/backend/src/auth/google/googleAuth.controller.ts @@ -21,7 +21,7 @@ export class GoogleAuthController { @Get('/redirect') @UseGuards(GoogleAuthGuard) async handleRedirect(@Res() response: Response) { - response.redirect('http://localhost:5173'); + response.redirect('/'); } @ApiOperation({ From 6294a4d3ef258f2725966318190017d7ee2f5d08 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 16:52:20 +0900 Subject: [PATCH 121/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20console=20log=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.ts | 1 - packages/backend/src/user/user.service.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 03bdaa17..a0a0df68 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -120,7 +120,6 @@ export class ChatService { size: number, userId?: number, ) { - console.log('stockId', stockId); return this.dataSource .createQueryBuilder(Chat, 'chat') .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index a132262d..48093a04 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -41,7 +41,6 @@ export class UserService { async createSubName(nickname: string) { return this.dataSource.transaction(async (manager) => { - console.log(await this.existsUserByNickname(nickname, manager)); if (!(await this.existsUserByNickname(nickname, manager))) { return '0001'; } @@ -51,7 +50,7 @@ export class UserService { .select('MAX(user.subName)', 'max') .where('user.nickname = :nickname', { nickname }) .getRawOne(); - console.log(maxSubName); + return (parseInt(maxSubName.max, 10) + 1).toString().padStart(4, '0'); }); } From 01b3a77b5304df496a2d2d79cdb804d31a0fb913 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 16:54:09 +0900 Subject: [PATCH 122/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A7=91=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiFluctuationData.api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 804b558a..bce63ddb 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -10,8 +10,8 @@ import { import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; import { Stock } from '@/stock/domain/stock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @Injectable() export class OpenapiFluctuationData { @@ -26,7 +26,7 @@ export class OpenapiFluctuationData { setTimeout(() => this.getFluctuationRankStocks(), 1000); } - @Cron('*/1 9-16 * * 1-5') + @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { await this.getDecreaseRankStocks(); await this.getIncreaseRankStocks(); From cfaafad53b1b021a80a6ce99e89608d591b166c0 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 17:58:01 +0900 Subject: [PATCH 123/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=ED=95=98=EB=8A=94=20=EC=B9=BC=EB=9F=BC=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/dto/stock.response.ts | 5 +++-- packages/backend/src/stock/stock.service.ts | 17 +++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index 0d00104e..ffd7f4f8 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -153,14 +153,15 @@ export class StockRankResponses { result: StockRankResponse[]; constructor(stocks: Record[]) { + console.log(stocks); this.result = stocks.map((stock) => ({ id: stock.id, name: stock.name, currentPrice: parseFloat(stock.currentPrice), volume: parseInt(stock.volume), marketCap: stock.marketCap, - changeRate: parseFloat(stock.fluctuationRate), - rank: parseInt(stock.stockRank), + changeRate: parseFloat(stock.rank_fluctuation_rate), + rank: parseInt(stock.rank_rank), })); } } diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index a8952496..dfc7682e 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -10,13 +10,19 @@ import { } from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; import { UserStocksResponse } from '@/stock/dto/userStock.response'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @Injectable() export class StockService { constructor( private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, - ) {} + ) { + const repository = datasource.getRepository(FluctuationRankStock); + const metadata = repository.metadata; + const columns = metadata.columns.map((column) => column.propertyName); + console.log(columns); + } async increaseView(stockId: string) { await this.datasource.transaction(async (manager) => { @@ -193,12 +199,7 @@ export class StockService { private getStockRankQuery(isRising: boolean) { return this.getStocksQuery() - .innerJoinAndSelect('stock.fluctuationRankStocks', 'FluctuationRankStock') - .addSelect([ - 'fluctuationRankStock.rank AS stockRank', - 'fluctuationRankStock.isRising AS isRising', - 'fluctuationRankStock.fluctuation_rate AS fluctuationRate', - ]) - .where('FluctuationRankStock.isRising = :isRising', { isRising }); + .innerJoinAndSelect('stock.fluctuationRankStocks', 'rank') + .where('rank.isRising = :isRising', { isRising }); } } From df12230fa4c743390f6f31c41b8bb319ddaddca2 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:07:42 +0900 Subject: [PATCH 124/223] =?UTF-8?q?Bug/#267=20-=20typeorm=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9C=BC=EB=A1=9C=20exist=20=ED=99=95=EC=9D=B8,=20tok?= =?UTF-8?q?en=20=EB=B0=9C=EA=B8=89=EC=A3=BC=EA=B8=B0=20=EB=8B=A8=EC=B6=95(?= =?UTF-8?q?12=EC=8B=9C=EA=B0=84)=20(#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 * ✨ feat: detail 구현 완료 * 🐛 fix: insert시 데이터 있으면 발생하는 오류 수정 * 💄 style: 테스트용 start 삭제, cron만 남겨놓음 * 🐛 fix: unique column 조건 rollback * 🐛 fix: detail data에 시범적으로 추상클래스 적용 * 🐛 fix: abstract에 넣을 함수 start, step는 추상이 아닌 function으로 정의 * ✨ feat: index type 추가 * ✨ feat: type 추가 = TR_ID * ✨ feat: index 기능 틀만 추가 * 💄 style: logger livedata에 추가 * 💄 style: 테스트용 데이터 삭제 * 💄 style: 토큰 만료시간을 20시간에서 12시간으로 단축 * ♻️ refactor: 데이터 존재여부를 내가 직접 하는 것이 아닌 typeorm 기능으로 변경 * ♻️ refactor: openapiIndex query 추가 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> --- .../scraper/openapi/api/openapi.abstract.ts | 33 +++++- .../openapi/api/openapiDetailData.api.ts | 32 +---- .../scraper/openapi/api/openapiIndex.api.ts | 70 +++++++++++ .../openapi/api/openapiLiveData.api.ts | 40 +++---- .../openapi/api/openapiNewDetailData.api.ts | 0 .../scraper/openapi/api/openapiToken.api.ts | 4 +- .../src/scraper/openapi/liveData.service.ts | 9 +- .../scraper/openapi/type/openapiIndex.type.ts | 112 ++++++++++++++++++ .../openapi/type/openapiNewDetailData.type.ts | 0 .../scraper/openapi/type/openapiUtil.type.ts | 5 +- 10 files changed, 243 insertions(+), 62 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiIndex.api.ts delete mode 100644 packages/backend/src/scraper/openapi/api/openapiNewDetailData.api.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiIndex.type.ts delete mode 100644 packages/backend/src/scraper/openapi/type/openapiNewDetailData.type.ts diff --git a/packages/backend/src/scraper/openapi/api/openapi.abstract.ts b/packages/backend/src/scraper/openapi/api/openapi.abstract.ts index 446bbcd6..f5b63bf9 100644 --- a/packages/backend/src/scraper/openapi/api/openapi.abstract.ts +++ b/packages/backend/src/scraper/openapi/api/openapi.abstract.ts @@ -1,14 +1,14 @@ import { DataSource } from 'typeorm'; import { openApiConfig } from '../config/openapi.config'; +import { OpenapiTokenApi } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; export abstract class Openapi { - constructor(protected readonly datasource: DataSource) {} - - protected abstract start(): Promise; - - protected abstract interval(idx: number, stocks: Stock[]): Promise; - + constructor( + protected readonly datasource: DataSource, + protected readonly config: OpenapiTokenApi, + protected readonly gapTime: number, + ) {} protected abstract step(idx: number, stock: Stock): Promise; protected abstract getFromUrl( @@ -18,6 +18,27 @@ export abstract class Openapi { protected abstract convertResToEntity(res: object, stockId: string): object; + protected abstract save(entity: object): Promise; + + async start() { + const stock = await this.getStockId(); + const len = (await this.config.configs()).length; + const stockSize = Math.ceil(stock.length / len); + let i = 0; + while (i < len) { + this.interval(i, stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; + } + } + + protected async interval(idx: number, stocks: Stock[]) { + let time = 0; + for (const stock of stocks) { + setTimeout(() => this.step(idx, stock), time); + time += this.gapTime; + } + } + protected async getStockId() { const entity = Stock; const manager = this.datasource.manager; diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 118a0b95..19381c16 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -19,30 +19,14 @@ export class OpenapiDetailData extends Openapi { constructor( @Inject('winston') private readonly logger: Logger, protected readonly datasource: DataSource, - private readonly config: OpenapiTokenApi, + protected readonly config: OpenapiTokenApi, ) { - super(datasource); + super(datasource, config, 100); } @Cron('35 0 * * 1-5') async start() { - const stock = await this.getStockId(); - const len = (await this.config.configs()).length; - const stockSize = Math.ceil(stock.length / len); - let i = 0; - while (i < len) { - this.interval(i, stock.slice(i * stockSize, (i + 1) * stockSize)); - i++; - } - } - - protected async interval(idx: number, stocks: Stock[]) { - const interval = 100; - let time = 0; - for (const stock of stocks) { - setTimeout(() => this.step(idx, stock), time); - time += interval; - } + super.start(); } protected async step(idx: number, stock: Stock) { @@ -78,16 +62,6 @@ export class OpenapiDetailData extends Openapi { return result; } - //private async getStockId() { - // const entity = Stock; - // const manager = this.datasource.manager; - // const result = await manager.find(entity, { - // select: { id: true }, - // where: { isTrading: true }, - // }); - // return result; - //} - protected query(stockId: string, code: 'J' = 'J') { return { fid_cond_mrkt_div_code: code, diff --git a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts new file mode 100644 index 00000000..3f82df53 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts @@ -0,0 +1,70 @@ +import { Inject } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { Openapi } from '../api/openapi.abstract'; +import { OpenapiTokenApi } from '../api/openapiToken.api'; +import { TR_ID } from '../type/openapiUtil.type'; + +/** + * 국내 업종 현재 지수 - 코스피, 코스닥 + * 해외주식 종목/지수/환율기간별시세 - 환율 + */ +export class OpenapiIndex extends Openapi { + private readonly TR_ID_INDEX: TR_ID = 'FHPUP02100000'; + private readonly TR_ID_RATE: TR_ID = 'FHKST03030100'; + private readonly index_url: string = + '/uapi/domestic-stock/v1/quotations/inquire-index-price'; + private readonly rate_url: string = + '/uapi/overseas-price/v1/quotations/inquire-daily-chartprice'; + private kospi: string = '1001'; + private kosdaq: string = '2001'; + private rate: string = 'FX@KRW'; + constructor( + @Inject('winston') private readonly logger: Logger, + protected readonly datasource: DataSource, + protected readonly config: OpenapiTokenApi, + ) { + super(datasource, config, 100); + } + + //환율 정보의 경우 하루에 한번만 업데이트 됨 + @Cron('30 8 * * 1-5') + async init() {} + + //kospi, kosdaq의 경우 여러번 업데이트 가능 + @Cron('* 9-14 * * 1-5') + @Cron('0-30 15 * * 1-5') + async start() {} + + protected async step() {} + + protected async getFromUrl() {} + + protected async convertResToEntity() {} + + protected async save() {} + + protected indexQuery(iscd: string, code: 'U' = 'U') { + return { + FID_COND_MRKT_DIV_CODE: code, + FID_INPUT_ISCD: iscd, + }; + } + + protected rateQuery( + startDate: Date, + endDate: Date, + iscd: string, + period: 'D' | 'W' | 'M' | 'Y' = 'D', + code: 'N' | 'X' | 'I' | 'S' = 'X', + ) { + return { + FID_COND_MRKT_DIV_CODE: code, + FID_INPUT_ISCD: iscd, + FID_INPUT_DATE_1: startDate, + FID_INPUT_DATE_2: endDate, + FID_PERIOD_DIV_CODE: period, + }; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 2c02f87d..9836714f 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -18,28 +18,24 @@ export class OpenapiLiveData { ) {} async saveLiveData(data: StockLiveData[]) { - const exists = await this.datasource.manager.exists(StockLiveData, { - where: { - stock: { id: data[0].stock.id }, - }, - }); - if (exists) { - await this.datasource.manager - .getRepository(StockLiveData) - .createQueryBuilder() - .update() - .set(data[0]) - .where('stock.id = :stockId', { stockId: data[0].stock.id }) - .execute(); - } else { - await this.datasource.manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .execute(); - } + await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .values(data[0]) + .orUpdate( + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updatedAt', + ], + ['stock_id'], + ) + .execute(); } // 현재가 체결 diff --git a/packages/backend/src/scraper/openapi/api/openapiNewDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiNewDetailData.api.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 73ec62e0..437283d3 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -69,8 +69,8 @@ export class OpenapiTokenApi { private isTokenExpired(startDate?: Date) { if (!startDate) return true; const now = new Date(); - //실제 만료 시간은 24시간이지만, 문제가 발생할 여지를 줄이기 위해 20시간으로 설정 - const baseTimeToMilliSec = 20 * 60 * 60 * 1000; + //실제 만료 시간은 24시간이지만, 문제가 발생할 여지를 줄이기 위해 12시간으로 설정 + const baseTimeToMilliSec = 12 * 60 * 60 * 1000; const timeDiff = now.getTime() - startDate.getTime(); return timeDiff >= baseTimeToMilliSec; diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 9d19c52d..56c4b08b 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -49,14 +49,13 @@ export class LiveData { // TODO : 하나의 config만 사용중. this.clientStock.add(stockId); const message = this.convertObjectToMessage( - (await this.openApiToken.configs())[0], + (await this.openApiToken.configs())[1], stockId, '1', ); this.webSocketClient.subscribe(message); } } - async discribe(stockId: string) { if (this.clientStock.has(stockId)) { @@ -90,6 +89,11 @@ export class LiveData { if (message.header) { if (message.header.tr_id === 'PINGPONG') { client.pong(data); + this.logger.info('Client ping pong'); + } else if (message.body) { + this.logger.info( + `${message.header.tr_key} : ${JSON.stringify(message.body)}`, + ); } return; } @@ -155,6 +159,7 @@ export class LiveData { return JSON.stringify(message); } + //TODO : type narrowing 필요 private parseMessage(data: RawData) { if (typeof data === 'object' && !(data instanceof Buffer)) { return data; diff --git a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts new file mode 100644 index 00000000..61d91c41 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-lines-per-function */ + +export type ExchangeRateQuery = { + fid_cond_mrkt_div_code: string; + fid_input_date_1: string; + fid_input_date_2: string; + fid_input_iscd: string; + fid_period_div_code: string; +}; + +export type ExchangeRate = { + acml_vol: string; + mod_yn: string; + ovrs_nmix_hgpr: string; + ovrs_nmix_lwpr: string; + ovrs_nmix_oprc: string; + ovrs_nmix_prpr: string; + stck_bsop_date: string; +}; + +export function isExchangeRate(data: any): data is ExchangeRate { + return ( + typeof data.acml_vol === 'string' && + typeof data.mod_yn === 'string' && + typeof data.ovrs_nmix_hgpr === 'string' && + typeof data.ovrs_nmix_lwpr === 'string' && + typeof data.ovrs_nmix_oprc === 'string' && + typeof data.ovrs_nmix_prpr === 'string' && + typeof data.stck_bsop_date === 'string' + ); +} + +export type StockIndex = { + bstp_nmix_prpr: string; + bstp_nmix_prdy_vrss: string; + prdy_vrss_sign: string; + bstp_nmix_prdy_ctrt: string; + acml_vol: string; + prdy_vol: string; + acml_tr_pbmn: string; + prdy_tr_pbmn: string; + bstp_nmix_oprc: string; + prdy_nmix_vrss_nmix_oprc: string; + oprc_vrss_prpr_sign: string; + bstp_nmix_oprc_prdy_ctrt: string; + bstp_nmix_hgpr: string; + prdy_nmix_vrss_nmix_hgpr: string; + hgpr_vrss_prpr_sign: string; + bstp_nmix_hgpr_prdy_ctrt: string; + bstp_nmix_lwpr: string; + prdy_clpr_vrss_lwpr: string; + lwpr_vrss_prpr_sign: string; + prdy_clpr_vrss_lwpr_rate: string; + ascn_issu_cnt: string; + uplm_issu_cnt: string; + stnr_issu_cnt: string; + down_issu_cnt: string; + lslm_issu_cnt: string; + dryy_bstp_nmix_hgpr: string; + dryy_hgpr_vrss_prpr_rate: string; + dryy_bstp_nmix_hgpr_date: string; + dryy_bstp_nmix_lwpr: string; + dryy_lwpr_vrss_prpr_rate: string; + dryy_bstp_nmix_lwpr_date: string; + total_askp_rsqn: string; + total_bidp_rsqn: string; + seln_rsqn_rate: string; + shnu_rsqn_rate: string; + ntby_rsqn: string; +}; + +export function isStockIndex(data: any): data is StockIndex { + return ( + typeof data.bstp_nmix_prpr === 'string' && + typeof data.bstp_nmix_prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.bstp_nmix_prdy_ctrt === 'string' && + typeof data.acml_vol === 'string' && + typeof data.prdy_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.prdy_tr_pbmn === 'string' && + typeof data.bstp_nmix_oprc === 'string' && + typeof data.prdy_nmix_vrss_nmix_oprc === 'string' && + typeof data.oprc_vrss_prpr_sign === 'string' && + typeof data.bstp_nmix_oprc_prdy_ctrt === 'string' && + typeof data.bstp_nmix_hgpr === 'string' && + typeof data.prdy_nmix_vrss_nmix_hgpr === 'string' && + typeof data.hgpr_vrss_prpr_sign === 'string' && + typeof data.bstp_nmix_hgpr_prdy_ctrt === 'string' && + typeof data.bstp_nmix_lwpr === 'string' && + typeof data.prdy_clpr_vrss_lwpr === 'string' && + typeof data.lwpr_vrss_prpr_sign === 'string' && + typeof data.prdy_clpr_vrss_lwpr_rate === 'string' && + typeof data.ascn_issu_cnt === 'string' && + typeof data.uplm_issu_cnt === 'string' && + typeof data.stnr_issu_cnt === 'string' && + typeof data.down_issu_cnt === 'string' && + typeof data.lslm_issu_cnt === 'string' && + typeof data.dryy_bstp_nmix_hgpr === 'string' && + typeof data.dryy_hgpr_vrss_prpr_rate === 'string' && + typeof data.dryy_bstp_nmix_hgpr_date === 'string' && + typeof data.dryy_bstp_nmix_lwpr === 'string' && + typeof data.dryy_lwpr_vrss_prpr_rate === 'string' && + typeof data.dryy_bstp_nmix_lwpr_date === 'string' && + typeof data.total_askp_rsqn === 'string' && + typeof data.total_bidp_rsqn === 'string' && + typeof data.seln_rsqn_rate === 'string' && + typeof data.shnu_rsqn_rate === 'string' && + typeof data.ntby_rsqn === 'string' + ); +} diff --git a/packages/backend/src/scraper/openapi/type/openapiNewDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiNewDetailData.type.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index 20b7de26..053bfd19 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -5,6 +5,8 @@ export type TR_ID = | 'HHKDB669107C0' | 'FHPST01700000' | 'FHKST01010100' + | 'FHPUP02100000' + | 'FHKST03030100' | 'CTPF1002R'; export const TR_IDS: Record = { @@ -13,5 +15,6 @@ export const TR_IDS: Record = { FINANCIAL_DATA: 'FHKST66430300', PRODUCTION_DETAIL: 'CTPF1002R', LIVE_DATA: 'FHKST01010100', - FLUCTUATION_DATA : 'FHPST01700000', + INDEX_DATA: 'FHPUP02100000', + RATE_DATA: 'FHKST03030100', }; From 6ad952dcaed23ad6d5595e00049a61977102f73c Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Wed, 27 Nov 2024 20:42:38 +0900 Subject: [PATCH 125/223] =?UTF-8?q?Feature/#259=20=EC=BD=94=EC=8A=A4?= =?UTF-8?q?=ED=94=BC,=20=EC=BD=94=EC=8A=A4=EB=8B=A5,=20=EC=9B=90=EB=8B=AC?= =?UTF-8?q?=EB=9F=AC=20=ED=99=98=EC=9C=A8=20=EB=B0=9B=EC=95=84=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 * ✨ feat: detail 구현 완료 * 🐛 fix: insert시 데이터 있으면 발생하는 오류 수정 * 💄 style: 테스트용 start 삭제, cron만 남겨놓음 * 🐛 fix: unique column 조건 rollback * 🐛 fix: detail data에 시범적으로 추상클래스 적용 * 🐛 fix: abstract에 넣을 함수 start, step는 추상이 아닌 function으로 정의 * ✨ feat: index type 추가 * ✨ feat: type 추가 = TR_ID * ✨ feat: index 기능 틀만 추가 * 💄 style: logger livedata에 추가 * 💄 style: 테스트용 데이터 삭제 * 💄 style: 토큰 만료시간을 20시간에서 12시간으로 단축 * ♻️ refactor: 데이터 존재여부를 내가 직접 하는 것이 아닌 typeorm 기능으로 변경 * ♻️ refactor: openapiIndex query 추가 * ✨ feat: index, rate 수집 완료 * 💄 style: 경에러 발생시logging 형식 변경 * 💄 style: live data pingpong logger 제거 * 💄 style: decorator eslint 형식 맞추기 * ✨ feat: 주가 지표, 환율 지표 response dto 추가 * ✨ feat: 주가 지표, 환율 컨트롤러 추가 * ✨ feat: controller추가, module 의존성 추ㅏㄱ * ✨ feat: 이전에 했던 내용들 머지 * 💄 style: minute cron 삭제 * ♻️ refactor: index 엔드포인트 하나로 축약 * ✨ feat: 테스트 코드 추가 * 💄 style: cron필요하지 않는 거 삭제 * ♻️ refactor: 안 쓰이는 openapi util 함수 삭제 * 📝 docs: 지표 설명이 부족한 거 같아 추가 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> --- .../openapi/api/openapiDetailData.api.ts | 2 +- .../openapi/api/openapiFluctuationData.api.ts | 1 + .../scraper/openapi/api/openapiIndex.api.ts | 217 +++++++++++++++--- .../openapi/api/openapiLiveData.api.ts | 4 +- .../openapi/api/openapiMinuteData.api.ts | 3 - .../src/scraper/openapi/liveData.service.ts | 9 +- .../scraper/openapi/openapi-scraper.module.ts | 8 +- .../scraper/openapi/type/openapiIndex.type.ts | 48 +++- .../scraper/openapi/util/openapiUtil.api.ts | 19 +- .../src/stock/decorator/stock.decorator.ts | 11 +- .../src/stock/dto/stockIndexRate.response.ts | 41 ++++ .../backend/src/stock/stock.controller.ts | 17 ++ packages/backend/src/stock/stock.module.ts | 2 + .../src/stock/stockRateIndex.service.spec.ts | 70 ++++++ .../src/stock/stockRateIndex.service.ts | 43 ++++ 15 files changed, 425 insertions(+), 70 deletions(-) create mode 100644 packages/backend/src/stock/dto/stockIndexRate.response.ts create mode 100644 packages/backend/src/stock/stockRateIndex.service.spec.ts create mode 100644 packages/backend/src/stock/stockRateIndex.service.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index 19381c16..b653c13c 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -69,7 +69,7 @@ export class OpenapiDetailData extends Openapi { }; } - private async save(saveEntity: StockDetail) { + protected async save(saveEntity: StockDetail) { const entity = StockDetail; const manager = this.datasource.manager; await manager diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index bce63ddb..dad3de13 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -26,6 +26,7 @@ export class OpenapiFluctuationData { setTimeout(() => this.getFluctuationRankStocks(), 1000); } + @Cron('* 9-15 * * 1-5') @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { await this.getDecreaseRankStocks(); diff --git a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts index 3f82df53..d303f076 100644 --- a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts @@ -4,7 +4,24 @@ import { DataSource } from 'typeorm'; import { Logger } from 'winston'; import { Openapi } from '../api/openapi.abstract'; import { OpenapiTokenApi } from '../api/openapiToken.api'; +import { openApiConfig } from '../config/openapi.config'; +import { + ExchangeRate, + ExchangeRateQuery, + IndexRateGroupCodeStock, + IndexRateId, + IndexRateStockId, + isExchangeRate, + isStockIndex, + StockIndex, + StockIndexQuery, +} from '../type/openapiIndex.type'; + import { TR_ID } from '../type/openapiUtil.type'; +import { getOpenApi, getTodayDate } from '../util/openapiUtil.api'; +import { OpenapiLiveData } from './openapiLiveData.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; /** * 국내 업종 현재 지수 - 코스피, 코스닥 @@ -13,58 +30,204 @@ import { TR_ID } from '../type/openapiUtil.type'; export class OpenapiIndex extends Openapi { private readonly TR_ID_INDEX: TR_ID = 'FHPUP02100000'; private readonly TR_ID_RATE: TR_ID = 'FHKST03030100'; - private readonly index_url: string = + private readonly INDEX_URL: string = '/uapi/domestic-stock/v1/quotations/inquire-index-price'; - private readonly rate_url: string = + private readonly RATE_URL: string = '/uapi/overseas-price/v1/quotations/inquire-daily-chartprice'; - private kospi: string = '1001'; - private kosdaq: string = '2001'; - private rate: string = 'FX@KRW'; + private KOSPI_ID: IndexRateId = IndexRateStockId.kospi; + private KOSDAQ_ID: IndexRateId = IndexRateStockId.kosdaq; + private USD_KRW_RATE: IndexRateId = IndexRateStockId.usd_krw; + private readonly INTERVAL: number; constructor( @Inject('winston') private readonly logger: Logger, protected readonly datasource: DataSource, protected readonly config: OpenapiTokenApi, + private readonly openapiLiveData: OpenapiLiveData, ) { - super(datasource, config, 100); + const interval = 1000; + super(datasource, config, interval); + this.INTERVAL = interval; + this.initData().then(() => this.start()); } - //환율 정보의 경우 하루에 한번만 업데이트 됨 - @Cron('30 8 * * 1-5') - async init() {} - - //kospi, kosdaq의 경우 여러번 업데이트 가능 @Cron('* 9-14 * * 1-5') @Cron('0-30 15 * * 1-5') - async start() {} + async start() { + await this.step((await this.config.configs()).length - 1); + } + + private initKospiData() { + const name = '코스피'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.kospi; + initStockData.groupCode = IndexRateGroupCodeStock.kospi; + initStockData.name = name; + return initStockData; + } + + private initKosdaqData() { + const name = '코스닥'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.kosdaq; + initStockData.groupCode = IndexRateGroupCodeStock.kosdaq; + initStockData.name = name; + return initStockData; + } + + private initUsdKrwData() { + const name = '원 달러 환율'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.usd_krw; + initStockData.groupCode = IndexRateGroupCodeStock.usd_krw; + initStockData.name = name; + return initStockData; + } + + private async initData() { + await this.saveStock(this.initKosdaqData()); + await this.saveStock(this.initKospiData()); + await this.saveStock(this.initUsdKrwData()); + } + + private async saveStock(data: Stock) { + const target = Stock; + + await this.datasource.manager + .getRepository(target) + .createQueryBuilder() + .insert() + .values(data) + .orUpdate(['is_trading'], ['id']) + .execute(); + } + + protected async step(idx: number) { + const config = (await this.config.configs())[idx]; + this.getFromUrl(config); + } + + protected async getFromUrl(config: typeof openApiConfig) { + const indexOutputKospi = await this.getFromIndex(config, this.KOSPI_ID); + const indexOutputKosdaq = await this.getFromIndex(config, this.KOSDAQ_ID); + const rateOutput = await this.getFromRate(config, this.USD_KRW_RATE); + if ( + isStockIndex(indexOutputKospi) && + isStockIndex(indexOutputKosdaq) && + isExchangeRate(rateOutput) + ) { + const liveData: StockLiveData[] = []; + liveData.push(this.convertResToEntity(indexOutputKospi, this.KOSPI_ID)); + liveData.push(this.convertResToEntity(indexOutputKosdaq, this.KOSDAQ_ID)); + liveData.push(this.convertResToEntity(rateOutput, this.USD_KRW_RATE)); + for await (const data of liveData) { + this.save(data); + } + } else { + this.logger.warn('Index data save failed'); + } + } + + protected async getFromIndex(config: typeof openApiConfig, stockId: string) { + const query = this.indexQuery(stockId); - protected async step() {} + try { + const result = await getOpenApi( + this.INDEX_URL, + config, + query, + this.TR_ID_INDEX, + ); + if (result && result.output) return result.output; + } catch (error) { + this.logger.warn( + `Get index data failed : ${error}, try in ${this.INTERVAL / 1000} sec`, + ); + setTimeout(() => this.getFromIndex(config, stockId), this.INTERVAL); + } + } + + protected async getFromRate(config: typeof openApiConfig, stockId: string) { + const date = getTodayDate(); - protected async getFromUrl() {} + const query = this.rateQuery(date, date, stockId); - protected async convertResToEntity() {} + try { + const result = await getOpenApi( + this.RATE_URL, + config, + query, + this.TR_ID_RATE, + ); + if (result && result.output1) return result.output1; + } catch (error) { + this.logger.warn( + `Get rate data failed : ${error}, try in ${this.INTERVAL / 1000} sec`, + ); + setTimeout(() => this.getFromRate(config, stockId), this.INTERVAL); + } + } - protected async save() {} + private convertResToStockIndex(res: StockIndex, stockId: string) { + const result = new StockLiveData(); + result.currentPrice = parseFloat(res.bstp_nmix_prpr); + result.changeRate = parseFloat(res.bstp_nmix_prdy_ctrt); + result.high = parseFloat(res.bstp_nmix_hgpr); + result.low = parseFloat(res.bstp_nmix_lwpr); + result.open = parseFloat(res.bstp_nmix_oprc); + result.volume = parseInt(res.acml_vol); + result.updatedAt = new Date(); + result.stock = { id: stockId } as Stock; + return result; + } - protected indexQuery(iscd: string, code: 'U' = 'U') { + private convertResToExchangeRate(res: ExchangeRate, stockId: string) { + const result = new StockLiveData(); + result.currentPrice = parseFloat(res.ovrs_nmix_prpr); + result.changeRate = parseFloat(res.prdy_ctrt); + result.high = parseFloat(res.ovrs_prod_hgpr); + result.low = parseFloat(res.ovrs_prod_lwpr); + result.open = parseFloat(res.ovrs_prod_oprc); + result.volume = parseInt(res.acml_vol); + result.updatedAt = new Date(); + result.stock = { id: stockId } as Stock; + return result; + } + + protected convertResToEntity( + res: StockIndex | ExchangeRate, + stockId: string, + ): StockLiveData { + if (isStockIndex(res)) { + return this.convertResToStockIndex(res, stockId); + } else { + return this.convertResToExchangeRate(res, stockId); + } + } + + protected async save(entity: StockLiveData) { + await this.openapiLiveData.saveLiveData(entity); + } + + protected indexQuery(iscd: string, code: 'U' = 'U'): StockIndexQuery { return { - FID_COND_MRKT_DIV_CODE: code, - FID_INPUT_ISCD: iscd, + fid_cond_mrkt_div_code: code, + fid_input_iscd: iscd, }; } protected rateQuery( - startDate: Date, - endDate: Date, + startDate: string, + endDate: string, iscd: string, period: 'D' | 'W' | 'M' | 'Y' = 'D', code: 'N' | 'X' | 'I' | 'S' = 'X', - ) { + ): ExchangeRateQuery { return { - FID_COND_MRKT_DIV_CODE: code, - FID_INPUT_ISCD: iscd, - FID_INPUT_DATE_1: startDate, - FID_INPUT_DATE_2: endDate, - FID_PERIOD_DIV_CODE: period, + fid_cond_mrkt_div_code: code, + fid_input_iscd: iscd, + fid_input_date_1: startDate, + fid_input_date_2: endDate, + fid_period_div_code: period, }; } } diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 9836714f..d65b3e32 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -17,12 +17,12 @@ export class OpenapiLiveData { @Inject('winston') private readonly logger: Logger, ) {} - async saveLiveData(data: StockLiveData[]) { + async saveLiveData(data: StockLiveData) { await this.datasource.manager .getRepository(StockLiveData) .createQueryBuilder() .insert() - .values(data[0]) + .values(data) .orUpdate( [ 'current_price', diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 4e193b74..608d39cc 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; @@ -33,7 +32,6 @@ export class OpenapiMinuteData { //this.getStockData(); } - @Cron('0 1 * * 1-5') async getStockData() { if (process.env.NODE_ENV !== 'production') return; const stock = await this.datasource.manager.findBy(Stock, { @@ -127,7 +125,6 @@ export class OpenapiMinuteData { } } - //@Cron(`*/${STOCK_CUT} 9-15 * * 1-5`) async getMinuteData() { if (process.env.NODE_ENV !== 'production') return; const configCount = (await this.openApiToken.configs()).length; diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 56c4b08b..26e8ee76 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -35,7 +35,7 @@ export class LiveData { stockId, ); if (stockLiveData) { - this.openapiLiveData.saveLiveData([stockLiveData]); + this.openapiLiveData.saveLiveData(stockLiveData); } } catch (error) { this.logger.warn(`Subscribe error in open api : ${error}`); @@ -89,16 +89,11 @@ export class LiveData { if (message.header) { if (message.header.tr_id === 'PINGPONG') { client.pong(data); - this.logger.info('Client ping pong'); - } else if (message.body) { - this.logger.info( - `${message.header.tr_key} : ${JSON.stringify(message.body)}`, - ); } return; } const liveData = this.openapiLiveData.convertLiveData(message); - await this.openapiLiveData.saveLiveData(liveData); + await this.openapiLiveData.saveLiveData(liveData[0]); } catch (error) { this.logger.warn(error); } diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index cb13a87b..5676b150 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,10 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiIndex } from './api/openapiIndex.api'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; +import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily, @@ -15,8 +19,6 @@ import { } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; -import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; -import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @Module({ imports: [ @@ -34,12 +36,14 @@ import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity ], controllers: [], providers: [ + OpenapiLiveData, OpenapiTokenApi, OpenapiPeriodData, OpenapiMinuteData, OpenapiDetailData, OpenapiScraperService, OpenapiFluctuationData, + OpenapiIndex, ], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts index 61d91c41..d061f3df 100644 --- a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts @@ -1,6 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-lines-per-function */ +export type IndexRateId = '0001' | '1001' | 'FX@KRW'; + +export const IndexRateStockId: { [key: string]: IndexRateId } = { + kospi: '0001', + kosdaq: '1001', + usd_krw: 'FX@KRW', +}; + +export type IndexRateGroupCode = 'INX' | 'RATE'; +export const IndexRateGroupCodeStock: { [key: string]: IndexRateGroupCode } = { + kospi: 'INX', + kosdaq: 'INX', + usd_krw: 'RATE', +}; + export type ExchangeRateQuery = { fid_cond_mrkt_div_code: string; fid_input_date_1: string; @@ -11,26 +26,39 @@ export type ExchangeRateQuery = { export type ExchangeRate = { acml_vol: string; - mod_yn: string; - ovrs_nmix_hgpr: string; - ovrs_nmix_lwpr: string; - ovrs_nmix_oprc: string; ovrs_nmix_prpr: string; - stck_bsop_date: string; + ovrs_nmix_prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + ovrs_nmix_prdy_clpr: string; + hts_kor_isnm: string; + stck_shrn_iscd: string; + ovrs_prod_oprc: string; + ovrs_prod_hgpr: string; + ovrs_prod_lwpr: string; }; export function isExchangeRate(data: any): data is ExchangeRate { return ( typeof data.acml_vol === 'string' && - typeof data.mod_yn === 'string' && - typeof data.ovrs_nmix_hgpr === 'string' && - typeof data.ovrs_nmix_lwpr === 'string' && - typeof data.ovrs_nmix_oprc === 'string' && typeof data.ovrs_nmix_prpr === 'string' && - typeof data.stck_bsop_date === 'string' + typeof data.ovrs_nmix_prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.ovrs_nmix_prdy_clpr === 'string' && + typeof data.hts_kor_isnm === 'string' && + typeof data.stck_shrn_iscd === 'string' && + typeof data.ovrs_prod_oprc === 'string' && + typeof data.ovrs_prod_hgpr === 'string' && + typeof data.ovrs_prod_lwpr === 'string' ); } +export type StockIndexQuery = { + fid_cond_mrkt_div_code: string; + fid_input_iscd: string; +}; + export type StockIndex = { bstp_nmix_prpr: string; bstp_nmix_prdy_vrss: string; diff --git a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts index fa8f75b4..5fa93d7e 100644 --- a/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts @@ -6,10 +6,10 @@ import { openApiConfig } from '../config/openapi.config'; import { TR_ID } from '../type/openapiUtil.type'; import { OpenapiException } from './openapiCustom.error'; -const throwOpenapiException = (error: any) => { +const throwOpenapiException = (error: any, url: string) => { if (error.message && error.response && error.response.status) { throw new OpenapiException( - `Request failed: ${error.message}`, + `${url} : ${error.message} `, error.response.status, error, ); @@ -31,7 +31,7 @@ const postOpenApi = async ( const response = await axios.post(config.STOCK_URL + url, body); return response.data; } catch (error) { - throwOpenapiException(error); + throwOpenapiException(error, url); } }; @@ -54,7 +54,7 @@ const getOpenApi = async ( }); return response.data; } catch (error) { - throwOpenapiException(error); + throwOpenapiException(error, url); } }; @@ -94,16 +94,6 @@ const decryptAES256 = ( return decrypted; }; -const bufferToObject = (buffer: Buffer): any => { - try { - const jsonString = buffer.toString('utf-8'); - return JSON.parse(jsonString); - } catch (error) { - console.error('Failed to convert buffer to object:', error); - throw error; - } -}; - export { postOpenApi, getOpenApi, @@ -111,5 +101,4 @@ export { getPreviousDate, getCurrentTime, decryptAES256, - bufferToObject, }; diff --git a/packages/backend/src/stock/decorator/stock.decorator.ts b/packages/backend/src/stock/decorator/stock.decorator.ts index 4e2f3469..0b80892e 100644 --- a/packages/backend/src/stock/decorator/stock.decorator.ts +++ b/packages/backend/src/stock/decorator/stock.decorator.ts @@ -1,7 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { applyDecorators, DefaultValuePipe, ParseIntPipe, Query } 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/stockIndexRate.response.ts b/packages/backend/src/stock/dto/stockIndexRate.response.ts new file mode 100644 index 00000000..03f254af --- /dev/null +++ b/packages/backend/src/stock/dto/stockIndexRate.response.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockLiveData } from '../domain/stockLiveData.entity'; +export class StockIndexRateResponse { + @ApiProperty({ description: '지표 이름', example: '원 달러 환율' }) + name: string; + + @ApiProperty({ description: '현재 가격', example: 1400 }) + currentPrice: number; + + @ApiProperty({ description: '거래량', example: 0 }) + changeRate: number; + + @ApiProperty({ description: '거래량', example: 10000 }) + volume: number; + + @ApiProperty({ description: '최고가', example: 1050 }) + high: number; + + @ApiProperty({ description: '최저가', example: 950 }) + low: number; + + @ApiProperty({ description: '시가', example: 980 }) + open: number; + + @ApiProperty({ + description: '마지막 업데이트 날짜', + example: '2023-10-01T00:00:00Z', + }) + updatedAt: Date; + + constructor(stockLiveData: StockLiveData) { + this.name = stockLiveData.stock.name; + this.currentPrice = stockLiveData.currentPrice; + this.changeRate = stockLiveData.changeRate; + this.volume = stockLiveData.volume; + this.high = stockLiveData.high; + this.low = stockLiveData.low; + this.open = stockLiveData.open; + this.updatedAt = stockLiveData.updatedAt; + } +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 9e8405eb..b753c671 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -20,6 +20,7 @@ import { Request } from 'express'; import { ApiGetStocks, LimitQuery } from './decorator/stock.decorator'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; +import { StockIndexRateResponse } from './dto/stockIndexRate.response'; import { StockService } from './stock.service'; import { StockDataDailyService, @@ -29,6 +30,7 @@ import { StockDataYearlyService, } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; +import { StockRateIndexService } from './stockRateIndex.service'; import SessionGuard from '@/auth/session/session.guard'; import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; @@ -69,6 +71,7 @@ export class StockController { private readonly stockDataMonthlyService: StockDataMonthlyService, private readonly stockDataYearlyService: StockDataYearlyService, private readonly stockDetailService: StockDetailService, + private readonly stockRateIndexService: StockRateIndexService, ) {} @HttpCode(200) @@ -223,6 +226,20 @@ export class StockController { return await this.stockDetailService.getStockDetailByStockId(stockId); } + @Get('index') + @ApiOperation({ + summary: '지표(코스피, 코스닥, 환율) API', + description: + '지표(코스피, 코스닥, 환율)의 최고, 최저, 현재, 변동률을 조회합니다.', + }) + @ApiOkResponse({ + description: '지표(코스피, 코스닥, 환율) 조회 성공', + type: [StockIndexRateResponse], + }) + async getIndexData() { + return await this.stockRateIndexService.getStockRateIndexDate(); + } + @Get('/:stockId') @ApiGetStockData('주식 시간 단위 데이터 조회 API', '일') async getStockDataDaily( diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 3008feb8..3d79014f 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -23,6 +23,7 @@ import { } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; +import { StockRateIndexService } from './stockRateIndex.service'; import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; import { LiveData } from '@/scraper/openapi/liveData.service'; @@ -57,6 +58,7 @@ import { WebsocketClient } from '@/scraper/openapi/websocket/websocketClient.web StockDataYearlyService, StockDataMonthlyService, StockDetailService, + StockRateIndexService, ], exports: [StockService], }) diff --git a/packages/backend/src/stock/stockRateIndex.service.spec.ts b/packages/backend/src/stock/stockRateIndex.service.spec.ts new file mode 100644 index 00000000..7b20dbcb --- /dev/null +++ b/packages/backend/src/stock/stockRateIndex.service.spec.ts @@ -0,0 +1,70 @@ +/* eslint-disable max-lines-per-function */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { Stock } from './domain/stock.entity'; +import { StockLiveData } from './domain/stockLiveData.entity'; +import { StockIndexRateResponse } from './dto/stockIndexRate.response'; +import { StockRateIndexService } from './stockRateIndex.service'; + +describe('StockRateIndexService', () => { + let stockRateIndexService: StockRateIndexService; + let dataSource: Partial; + const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + } as unknown as Logger; + + const createDataSourceMock = (managerMock: any): Partial => { + return { + manager: managerMock, + }; + }; + + beforeEach(() => { + const mockStockLiveData: Partial = { + volume: 1000, + high: 1200, + low: 800, + open: 1000, + stock: { id: 'KRW', name: 'KRW', groupCode: 'IDX' } as Stock, + }; + + const managerMock = { + find: jest.fn().mockResolvedValue([mockStockLiveData]), + }; + + dataSource = createDataSourceMock(managerMock); + stockRateIndexService = new StockRateIndexService( + dataSource as DataSource, + logger as Logger, + ); + }); + + it('존재하면 데이터를 리턴한다', async () => { + const response = await stockRateIndexService.getStockRateIndexDate(); + + expect(response).toBeInstanceOf(Array); + expect(response[0]).toBeInstanceOf(StockIndexRateResponse); + expect(response[0].name).toBe('KRW'); + expect(response[0].volume).toBe(1000); + }); + + it('존재하지 않으면 에러를 발생시킨다', async () => { + const managerMock = { + find: jest.fn().mockResolvedValue([]), + }; + + dataSource = createDataSourceMock(managerMock); + stockRateIndexService = new StockRateIndexService( + dataSource as DataSource, + logger as Logger, + ); + + await expect(stockRateIndexService.getStockRateIndexDate()).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/packages/backend/src/stock/stockRateIndex.service.ts b/packages/backend/src/stock/stockRateIndex.service.ts new file mode 100644 index 00000000..cbde49cb --- /dev/null +++ b/packages/backend/src/stock/stockRateIndex.service.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { StockLiveData } from './domain/stockLiveData.entity'; +import { StockIndexRateResponse } from './dto/stockIndexRate.response'; +import { IndexRateGroupCode } from '@/scraper/openapi/type/openapiIndex.type'; + +@Injectable() +export class StockRateIndexService { + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) {} + + async getRateIndexData(groupCode: IndexRateGroupCode) { + const result = await this.datasource.manager.find(StockLiveData, { + where: { stock: { groupCode } }, + relations: ['stock'], + }); + + if (!result.length) { + this.logger.warn(`Rate data not found for group code: ${groupCode}`); + throw new NotFoundException('Rate data not found'); + } + return result; + } + + async getStockRateData() { + const groupCode: IndexRateGroupCode = 'RATE'; + const result = await this.getRateIndexData(groupCode); + return result.map((val) => new StockIndexRateResponse(val)); + } + async getStockIndexData() { + const groupCode: IndexRateGroupCode = 'INX'; + const result = await this.getRateIndexData(groupCode); + return result.map((val) => new StockIndexRateResponse(val)); + } + async getStockRateIndexDate(): Promise { + const index = await this.getStockIndexData(); + const rate = await this.getStockRateData(); + return [...index, ...rate]; + } +} From 3c173f8e579f9f8f90d4aee18fd4d0608de39ced Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 21:03:30 +0900 Subject: [PATCH 126/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=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 --- packages/backend/src/auth/auth.module.ts | 3 +- .../src/auth/session/logout.controller.ts | 32 +++++++++++++++++++ .../backend/src/stock/dto/stock.response.ts | 1 - packages/backend/src/stock/stock.service.ts | 8 +---- 4 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 packages/backend/src/auth/session/logout.controller.ts diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index c32261b7..d638565e 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { PassportModule } from '@nestjs/passport'; import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; +import { LogoutController } from '@/auth/session/logout.controller'; import { SessionSerializer } from '@/auth/session/session.serializer'; import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; @@ -11,7 +12,7 @@ import { UserModule } from '@/user/user.module'; @Module({ imports: [UserModule, PassportModule.register({ session: true })], - controllers: [GoogleAuthController, TesterAuthController], + controllers: [GoogleAuthController, TesterAuthController, LogoutController], providers: [ GoogleStrategy, GoogleAuthService, diff --git a/packages/backend/src/auth/session/logout.controller.ts b/packages/backend/src/auth/session/logout.controller.ts new file mode 100644 index 00000000..17d739e6 --- /dev/null +++ b/packages/backend/src/auth/session/logout.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Post, Req, Res } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { sessionConfig } from '@/configs/session.config'; + +@ApiTags('Auth') +@Controller('auth/logout') +export class LogoutController { + @ApiOperation({ + summary: '로그아웃', + description: '로그아웃을 진행한다.', + }) + @Post() + logout(@Req() req: Request, @Res() res: Response) { + req.logout((err) => { + if (err) { + return res + .status(500) + .send({ message: 'Failed to logout', error: err }); + } + req.session.destroy((destroyErr) => { + if (destroyErr) { + return res + .status(500) + .send({ message: 'Failed to destroy session', error: destroyErr }); + } + res.clearCookie(sessionConfig.name || 'connect.sid'); + return res.status(200).send({ message: 'Logged out successfully' }); + }); + }); + } +} diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index ffd7f4f8..d5a747c6 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -153,7 +153,6 @@ export class StockRankResponses { result: StockRankResponse[]; constructor(stocks: Record[]) { - console.log(stocks); this.result = stocks.map((stock) => ({ id: stock.id, name: stock.name, diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index dfc7682e..e9dc20a5 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -10,19 +10,13 @@ import { } from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; import { UserStocksResponse } from '@/stock/dto/userStock.response'; -import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @Injectable() export class StockService { constructor( private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, - ) { - const repository = datasource.getRepository(FluctuationRankStock); - const metadata = repository.metadata; - const columns = metadata.columns.map((column) => column.propertyName); - console.log(columns); - } + ) {} async increaseView(stockId: string) { await this.datasource.transaction(async (manager) => { From 495b544a9e9d1628a295832b6d41f82d6b0c9089 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 21:24:44 +0900 Subject: [PATCH 127/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=20=EB=8B=89=EB=84=A4=EC=9E=84=EB=8F=84=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ogout.controller.ts => auth.controller.ts} | 28 +++++++++++++++---- packages/backend/src/auth/auth.module.ts | 4 +-- .../src/auth/google/googleAuth.controller.ts | 22 ++------------- 3 files changed, 28 insertions(+), 26 deletions(-) rename packages/backend/src/auth/{session/logout.controller.ts => auth.controller.ts} (51%) diff --git a/packages/backend/src/auth/session/logout.controller.ts b/packages/backend/src/auth/auth.controller.ts similarity index 51% rename from packages/backend/src/auth/session/logout.controller.ts rename to packages/backend/src/auth/auth.controller.ts index 17d739e6..56af9df9 100644 --- a/packages/backend/src/auth/session/logout.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -1,16 +1,17 @@ -import { Controller, Post, Req, Res } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Post, Req, Res } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { sessionConfig } from '@/configs/session.config'; +import { User } from '@/user/domain/user.entity'; @ApiTags('Auth') -@Controller('auth/logout') -export class LogoutController { +@Controller('auth') +export class AuthController { @ApiOperation({ summary: '로그아웃', description: '로그아웃을 진행한다.', }) - @Post() + @Post('/logout') logout(@Req() req: Request, @Res() res: Response) { req.logout((err) => { if (err) { @@ -29,4 +30,21 @@ export class LogoutController { }); }); } + + @ApiOperation({ + summary: '로그인 상태 확인', + description: '로그인 상태를 확인합니다.', + }) + @ApiOkResponse({ + description: '로그인된 상태', + example: { message: 'Authenticated' }, + }) + @Get('/status') + async user(@Req() request: Request) { + if (request.user) { + const user = request.user as User; + return { message: 'Authenticated', nickname: user.nickname }; + } + return { message: 'Not Authenticated', nickname: null }; + } } diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index d638565e..bb43faf7 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -3,7 +3,7 @@ import { PassportModule } from '@nestjs/passport'; import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; -import { LogoutController } from '@/auth/session/logout.controller'; +import { AuthController } from '@/auth/auth.controller'; import { SessionSerializer } from '@/auth/session/session.serializer'; import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; @@ -12,7 +12,7 @@ import { UserModule } from '@/user/user.module'; @Module({ imports: [UserModule, PassportModule.register({ session: true })], - controllers: [GoogleAuthController, TesterAuthController, LogoutController], + controllers: [GoogleAuthController, TesterAuthController, AuthController], providers: [ GoogleStrategy, GoogleAuthService, diff --git a/packages/backend/src/auth/google/googleAuth.controller.ts b/packages/backend/src/auth/google/googleAuth.controller.ts index 1d460748..12c1adec 100644 --- a/packages/backend/src/auth/google/googleAuth.controller.ts +++ b/packages/backend/src/auth/google/googleAuth.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Request, Response } from 'express'; +import { Controller, Get, Res, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { GoogleAuthGuard } from '@/auth/google/guard/google.guard'; @ApiTags('Auth') @@ -23,20 +23,4 @@ export class GoogleAuthController { async handleRedirect(@Res() response: Response) { response.redirect('/'); } - - @ApiOperation({ - summary: '로그인 상태 확인', - description: '로그인 상태를 확인합니다.', - }) - @ApiOkResponse({ - description: '로그인된 상태', - example: { message: 'Authenticated' }, - }) - @Get('/status') - async user(@Req() request: Request) { - if (request.user) { - return { message: 'Authenticated' }; - } - return { message: 'Not Authenticated' }; - } } From f908bbc61de3e2775e319347078a32840d771e3c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 21:37:56 +0900 Subject: [PATCH 128/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=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 --- .../backend/src/user/dto/User.response.ts | 33 --------- .../backend/src/user/dto/user.response.ts | 74 +++++++++++++++++++ packages/backend/src/user/user.controller.ts | 17 +++++ packages/backend/src/user/user.service.ts | 13 +++- 4 files changed, 103 insertions(+), 34 deletions(-) delete mode 100644 packages/backend/src/user/dto/User.response.ts create mode 100644 packages/backend/src/user/dto/user.response.ts diff --git a/packages/backend/src/user/dto/User.response.ts b/packages/backend/src/user/dto/User.response.ts deleted file mode 100644 index 98f37930..00000000 --- a/packages/backend/src/user/dto/User.response.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { User } from '@/user/domain/user.entity'; - -interface UserResponse { - id: number; - nickname: string; - subName: string; - createdAt: Date; -} - -export class UserSearchResult { - @ApiProperty({ - description: '유저 검색 결과', - example: [ - { - id: 1, - nickname: 'nickname', - subName: 'subName', - createdAt: new Date(), - }, - ], - }) - result: UserResponse[]; - - constructor(users: User[]) { - this.result = users.map((user) => ({ - id: user.id, - nickname: user.nickname, - subName: user.subName, - createdAt: user.date.createdAt, - })); - } -} diff --git a/packages/backend/src/user/dto/user.response.ts b/packages/backend/src/user/dto/user.response.ts new file mode 100644 index 00000000..f0f1e20a --- /dev/null +++ b/packages/backend/src/user/dto/user.response.ts @@ -0,0 +1,74 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '@/user/domain/user.entity'; +import { OauthType } from '@/user/domain/ouathType'; + +interface UserResponse { + id: number; + nickname: string; + subName: string; + createdAt: Date; +} + +export class UserSearchResult { + @ApiProperty({ + description: '유저 검색 결과', + example: [ + { + id: 1, + nickname: 'nickname', + subName: 'subName', + createdAt: new Date(), + }, + ], + }) + result: UserResponse[]; + + constructor(users: User[]) { + this.result = users.map((user) => ({ + id: user.id, + nickname: user.nickname, + subName: user.subName, + createdAt: user.date.createdAt, + })); + } +} + +export class UserInformationResponse { + @ApiProperty({ + description: '유저 닉네임', + example: 'nickname', + }) + nickname: string; + + @ApiProperty({ + description: '유저 서브 닉네임', + example: 'subName', + }) + subName: string; + + @ApiProperty({ + description: '유저 생성일', + example: new Date(), + }) + createdAt: Date; + + @ApiProperty({ + description: '유저 이메일', + example: 'test@nav.com', + }) + email: string; + + @ApiProperty({ + description: '유저타입 (google: 구글 로그인, local: 테스터)', + example: new Date(), + }) + type: OauthType; + + constructor(user: User) { + this.nickname = user.nickname; + this.subName = user.subName; + this.createdAt = user.date.createdAt; + this.email = user.email; + this.type = user.type; + } +} diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 17d88491..bb9e329a 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -1,16 +1,20 @@ import { Body, Controller, + ForbiddenException, Get, HttpCode, HttpStatus, Param, Patch, Query, + Req, } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; import { UserService } from './user.service'; +import { Request } from 'express'; +import { User } from '@/user/domain/user.entity'; @Controller('user') export class UserController { @@ -33,6 +37,19 @@ export class UserController { ); } + @Get('info') + @ApiOperation({ + summary: '유저 정보를 조회한다.', + description: '유저 정보를 조회한다.', + }) + async getUserInfo(@Req() request: Request) { + if (!request.user) { + throw new ForbiddenException('Forbidden access to user info'); + } + const user = request.user as User; + return await this.userService.getUserInfo(user.id); + } + @Patch(':id/theme') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 48093a04..537f109c 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -7,7 +7,10 @@ import { DataSource, EntityManager, Like } from 'typeorm'; import { OauthType } from './domain/ouathType'; import { User } from './domain/user.entity'; import { status, subject } from '@/user/constants/randomNickname'; -import { UserSearchResult } from '@/user/dto/User.response'; +import { + UserInformationResponse, + UserSearchResult, +} from '@/user/dto/user.response'; type RegisterRequest = Required< Pick @@ -55,6 +58,14 @@ export class UserService { }); } + async getUserInfo(id: number) { + const user = await this.dataSource.manager.findOne(User, { where: { id } }); + if (!user) { + throw new BadRequestException('User not found'); + } + return new UserInformationResponse(user); + } + existsUserByNickname(nickname: string, manager: EntityManager) { return manager.exists(User, { where: { nickname } }); } From 57894d683eb38d383e260ce4e5475450efc119fe Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 21:46:39 +0900 Subject: [PATCH 129/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=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 --- packages/backend/src/user/user.controller.ts | 18 ++++++++++++++++++ packages/backend/src/user/user.service.ts | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index bb9e329a..4a41796e 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -7,6 +7,7 @@ import { HttpStatus, Param, Patch, + Post, Query, Req, } from '@nestjs/common'; @@ -50,6 +51,23 @@ export class UserController { return await this.userService.getUserInfo(user.id); } + @Post('info') + @ApiOperation({ + summary: '유저 닉네임을 변경한다.', + description: '유저 닉네임을 변경한다.', + }) + async updateNickname( + @Req() request: Request, + @Body('nickname') nickname: string, + ) { + if (!request.user) { + throw new ForbiddenException('Forbidden access to change nickname'); + } + const user = request.user as User; + await this.userService.updateNickname(user.id, nickname); + return { message: '닉네임 변경 완료', date: new Date() }; + } + @Patch(':id/theme') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 537f109c..cc62193b 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -70,6 +70,20 @@ export class UserService { return manager.exists(User, { where: { nickname } }); } + async updateNickname(userId: number, nickname: string) { + const user = await this.dataSource.manager.findOne(User, { + where: { id: userId }, + }); + if (!user) { + throw new BadRequestException('User not found'); + } else if (user.nickname === nickname) { + throw new BadRequestException('Same nickname'); + } + user.nickname = nickname; + user.subName = await this.createSubName(nickname); + return await this.dataSource.manager.save(user); + } + async registerTester() { return this.register({ nickname: this.generateRandomNickname(), From 7a49ef8c26d72e4dbb708892d2302024faf94539 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 21:52:12 +0900 Subject: [PATCH 130/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EB=B3=80=EA=B2=BD=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=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/user/dto/user.request.ts | 12 ++++++++++++ packages/backend/src/user/user.controller.ts | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/user/dto/user.request.ts diff --git a/packages/backend/src/user/dto/user.request.ts b/packages/backend/src/user/dto/user.request.ts new file mode 100644 index 00000000..fde10d87 --- /dev/null +++ b/packages/backend/src/user/dto/user.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ChangeNicknameRequest { + @ApiProperty({ + description: '변경할 닉네임', + example: '9만전자 개미', + }) + @IsString() + @IsNotEmpty() + nickname: string; +} diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 4a41796e..6abc5b56 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -11,11 +11,18 @@ import { Query, Req, } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiResponse, +} from '@nestjs/swagger'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; import { UserService } from './user.service'; import { Request } from 'express'; import { User } from '@/user/domain/user.entity'; +import { ChangeNicknameRequest } from '@/user/dto/user.request'; @Controller('user') export class UserController { @@ -56,15 +63,19 @@ export class UserController { summary: '유저 닉네임을 변경한다.', description: '유저 닉네임을 변경한다.', }) + @ApiOkResponse({ + description: '닉네임 변경 완료', + example: { message: '닉네임 변경 완료', date: new Date() }, + }) async updateNickname( @Req() request: Request, - @Body('nickname') nickname: string, + @Body() body: ChangeNicknameRequest, ) { if (!request.user) { throw new ForbiddenException('Forbidden access to change nickname'); } const user = request.user as User; - await this.userService.updateNickname(user.id, nickname); + await this.userService.updateNickname(user.id, body.nickname); return { message: '닉네임 변경 완료', date: new Date() }; } From 624d81e122c6863e75be3a404a969f30addd453d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 22:45:36 +0900 Subject: [PATCH 131/223] =?UTF-8?q?=E2=9C=A8=20feat:=20cors=EB=A5=BC=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=ED=99=98=EA=B2=BD=EC=97=90=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/main.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 935fea99..d9c69898 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -1,4 +1,4 @@ -import { ValidationPipe } from '@nestjs/common'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import * as session from 'express-session'; import * as passport from 'passport'; @@ -7,6 +7,19 @@ import { MEMORY_STORE } from '@/auth/session.module'; import { sessionConfig } from '@/configs/session.config'; import { useSwagger } from '@/configs/swagger.config'; +const setCors = (app: INestApplication) => { + app.enableCors({ + origin: [ + 'http://localhost:3000', + 'https://juchum.info', + 'http://localhost:5173', + ], + methods: '*', + allowedHeaders: '*', + credentials: true, + }); +}; + async function bootstrap() { const app = await NestFactory.create(AppModule); const store = app.get(MEMORY_STORE); @@ -19,10 +32,7 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true }, }), ); - app.enableCors({ - origin: true, - credentials: true, - }); + setCors(app); useSwagger(app); app.use(passport.initialize()); app.use(passport.session()); From 211c59193b2c31afbf3fae34fe53cc1eb802f33f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 27 Nov 2024 23:20:43 +0900 Subject: [PATCH 132/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=83=81=EC=9C=84?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=A3=BC=EC=8B=9D=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiRankView.api.ts | 101 ++++++++++++++++++ .../scraper/openapi/openapi-scraper.module.ts | 6 +- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/api/openapiRankView.api.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts new file mode 100644 index 00000000..c2450e66 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +@Injectable() +export class OpenapiRankViewApi { + private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; + constructor( + private readonly datasource: DataSource, + private readonly openApiToken: OpenapiTokenApi, + @Inject('winston') private readonly logger: Logger, + ) { + setTimeout(() => this.getTopViewsStockLiveData(), 1000); + } + + @Cron('* 9-15 * * 1-5') + async getTopViewsStockLiveData(count = 5) { + try { + if (count === 0) return; + const topViewsStocks = await this.getTopViewsStocks(); + const liveResult = await this.getFluctuationRankApiLive(topViewsStocks); + await this.datasource.transaction(async (manager) => { + await this.datasource.manager.delete(FluctuationRankStock, { + isRising: false, + }); + await this.saveLiveData(liveResult, manager); + this.logger.info('decrease rank stocks updated'); + }); + } catch (error) { + this.logger.warn(error); + this.getTopViewsStockLiveData(--count); + } + } + + private async getTopViewsStocks() { + return await this.datasource.manager + .getRepository(Stock) + .createQueryBuilder('stock') + .orderBy('stock.views', 'DESC') + .limit(10) + .getMany(); + } + + private async getFluctuationRankApiLive(data: Stock[]) { + const result: StockLiveData[] = []; + for (let i = 0; i < 20; ++i) { + if (i >= data.length) break; + else if (i == 10) + await new Promise((resolve) => setTimeout(resolve, 1000)); + const stockId = data[i].id; + const stockData = await getOpenApi( + this.liveUrl, + (await this.openApiToken.configs())[0], + { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockId, + }, + TR_IDS.LIVE_DATA, + ); + result.push(this.convertToStockLiveData(stockData.output, stockId)); + } + return result; + } + + private async saveLiveData(data: StockLiveData[], manager: EntityManager) { + return await manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .orUpdate( + ['current_price', 'change_rate', 'volume', 'high', 'low', 'open'], + ['stock_id'], + ) + .execute(); + } + + private convertToStockLiveData( + stockData: Record, + stockId: string, + ): StockLiveData { + const stockLiveData = new StockLiveData(); + stockLiveData.stock = { id: stockId } as Stock; + stockLiveData.currentPrice = parseFloat(stockData.stck_prpr); + stockLiveData.changeRate = parseFloat(stockData.prdy_ctrt); + stockLiveData.volume = parseInt(stockData.acml_vol); + stockLiveData.high = parseFloat(stockData.stck_hgpr); + stockLiveData.low = parseFloat(stockData.stck_lwpr); + stockLiveData.open = parseFloat(stockData.stck_oprc); + stockLiveData.updatedAt = new Date(); + return stockLiveData; + } +} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index cb13a87b..3bb3319c 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -5,6 +5,9 @@ import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; +import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; +import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockDaily, @@ -15,8 +18,6 @@ import { } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; -import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; -import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @Module({ imports: [ @@ -40,6 +41,7 @@ import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity OpenapiDetailData, OpenapiScraperService, OpenapiFluctuationData, + OpenapiRankViewApi, ], }) export class OpenapiScraperModule {} From 2f15e87c1beb7da4f9869dc83ee80bf9f836f42b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 02:43:08 +0900 Subject: [PATCH 133/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EA=B0=80=EA=B2=A9=20=EC=83=81=EC=8A=B9=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=98=EB=9D=BD=EB=A5=A0=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=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 | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 9e8405eb..7b8d8ca5 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -207,6 +207,12 @@ export class StockController { return await this.stockService.getTopStocksByLosers(limit); } + @Get('fluctuation') + @ApiGetStocks('가격 상승률, 하락률 기반 주식 리스트 조회 API') + async getTopStocksByFluctuation() { + return await this.stockService.getTopStocksByFluctuation(); + } + @ApiOperation({ summary: '주식 상세 정보 조회 API', description: '시가 총액, EPS, PER, 52주 최고가, 52주 최저가를 조회합니다', diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index e9dc20a5..759717d6 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -134,6 +134,13 @@ export class StockService { return new StockRankResponses(rawData); } + async getTopStocksByFluctuation() { + const data = await this.getStocksQuery() + .innerJoinAndSelect('stock.fluctuationRankStocks', 'rank') + .getRawMany(); + return new StockRankResponses(data); + } + private async validateStockExists(stockId: string, manager: EntityManager) { if (!(await this.existsStock(stockId, manager))) { throw new BadRequestException('not exists stock'); From 36a031c7ca64a46d8f9dfb71e442e7963c7cd423 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 10:25:45 +0900 Subject: [PATCH 134/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=83=81=EC=9C=84?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9A=94=EC=B2=AD=20=ED=81=90?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiFluctuationData.api.ts | 8 +- .../openapi/api/openapiRankView.api.ts | 56 +++++++++--- .../scraper/openapi/openapi-scraper.module.ts | 6 ++ .../scraper/openapi/queue/openapi.queue.ts | 89 +++++++++++++++++++ .../src/scraper/openapi/util/priorityQueue.ts | 58 ++++++------ 5 files changed, 173 insertions(+), 44 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/queue/openapi.queue.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index bce63ddb..1a322c11 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -47,7 +47,9 @@ export class OpenapiFluctuationData { }); } catch (error) { this.logger.warn(error); - this.getDecreaseRankStocks(--count); + await new Promise((resolve) => + setTimeout(() => resolve(this.getDecreaseRankStocks(--count)), 2000), + ); } } @@ -66,7 +68,9 @@ export class OpenapiFluctuationData { }); } catch (error) { this.logger.warn(error); - this.getIncreaseRankStocks(--count); + await new Promise((resolve) => + setTimeout(() => resolve(this.getIncreaseRankStocks(--count)), 3000), + ); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts index c2450e66..a553a87e 100644 --- a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -3,36 +3,31 @@ import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; -import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @Injectable() export class OpenapiRankViewApi { private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; + constructor( private readonly datasource: DataSource, private readonly openApiToken: OpenapiTokenApi, + private readonly openApiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, ) { - setTimeout(() => this.getTopViewsStockLiveData(), 1000); + setTimeout(() => this.getTopViewsStockLiveData(), 6000); } @Cron('* 9-15 * * 1-5') async getTopViewsStockLiveData(count = 5) { try { if (count === 0) return; - const topViewsStocks = await this.getTopViewsStocks(); - const liveResult = await this.getFluctuationRankApiLive(topViewsStocks); - await this.datasource.transaction(async (manager) => { - await this.datasource.manager.delete(FluctuationRankStock, { - isRising: false, - }); - await this.saveLiveData(liveResult, manager); - this.logger.info('decrease rank stocks updated'); - }); + await this.getTopViewsStocks(); + // const liveResult = await this.getFluctuationRankApiLive(topViewsStocks); } catch (error) { this.logger.warn(error); this.getTopViewsStockLiveData(--count); @@ -40,15 +35,33 @@ export class OpenapiRankViewApi { } private async getTopViewsStocks() { - return await this.datasource.manager + const date = await this.datasource.manager .getRepository(Stock) .createQueryBuilder('stock') .orderBy('stock.views', 'DESC') .limit(10) .getMany(); + date.forEach((stock) => { + const callback: (value: T) => Promise = async ( + liveResult: Json, + ) => { + const data = this.convertToStockLiveData(liveResult.output, stock.id); + await this.saveIndividualLiveData(data, this.datasource.manager); + }; + this.openApiQueue.enqueue({ + url: this.liveUrl, + query: { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stock.id, + }, + trId: TR_IDS.LIVE_DATA, + callback, + }); + }); + return date.slice(0, 10); } - private async getFluctuationRankApiLive(data: Stock[]) { + private async getViewsRankApiLive(data: Stock[]) { const result: StockLiveData[] = []; for (let i = 0; i < 20; ++i) { if (i >= data.length) break; @@ -83,6 +96,23 @@ export class OpenapiRankViewApi { .execute(); } + private async saveIndividualLiveData( + data: StockLiveData, + manager: EntityManager, + ) { + return await manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .orUpdate( + ['current_price', 'change_rate', 'volume', 'high', 'low', 'open'], + ['stock_id'], + ) + .execute(); + } + private convertToStockLiveData( stockData: Record, stockId: string, diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 3bb3319c..bf8cb1a8 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -7,6 +7,10 @@ import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; +import { + OpenapiConsumer, + OpenapiQueue, +} from '@/scraper/openapi/queue/openapi.queue'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { @@ -42,6 +46,8 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiScraperService, OpenapiFluctuationData, OpenapiRankViewApi, + OpenapiQueue, + OpenapiConsumer, ], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts new file mode 100644 index 00000000..da75287f --- /dev/null +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -0,0 +1,89 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'winston'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { TR_ID } from '@/scraper/openapi/type/openapiUtil.type'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; +import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; + +export type Json = { + output: Record; +}; + +export interface OpenapiQueueNodeValue { + url: string; + query: object; + trId: TR_ID; + callback: (value: T) => Promise; +} + +@Injectable() +export class OpenapiQueue { + private queue: PriorityQueue = new PriorityQueue(); + constructor() {} + + enqueue(value: OpenapiQueueNodeValue, priority?: number) { + if (!priority) { + priority = 2; + } + this.queue.enqueue(value, priority); + } + + dequeue(): OpenapiQueueNodeValue | undefined { + return this.queue.dequeue(); + } + + isEmpty(): boolean { + return this.queue.isEmpty(); + } +} + +@Injectable() +export class OpenapiConsumer { + private readonly REQUEST_COUNT_PER_SECOND = 20; + private isProcessing: boolean = false; + + constructor( + private readonly queue: OpenapiQueue, + private readonly openapiTokenApi: OpenapiTokenApi, + @Inject('winston') private readonly logger: Logger, + ) { + this.start(); + } + + async start() { + setInterval(() => this.consume(), 1000); + } + + async consume() { + if (this.isProcessing) { + return; + } + while (!this.queue.isEmpty()) { + this.isProcessing = true; + await this.processRequest(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + this.isProcessing = false; + } + + private async processRequest() { + for (let i = 0; i < this.REQUEST_COUNT_PER_SECOND; i++) { + const node = this.queue.dequeue(); + if (!node) { + return (this.isProcessing = false); + } + try { + const data = await getOpenApi( + node.url, + (await this.openapiTokenApi.configs())[0], + node.query, + node.trId, + ); + await node.callback(data); + } catch (error) { + this.logger.warn(error); + this.queue.enqueue(node, 1); + } + } + } +} diff --git a/packages/backend/src/scraper/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/openapi/util/priorityQueue.ts index 190718a5..c0abf838 100644 --- a/packages/backend/src/scraper/openapi/util/priorityQueue.ts +++ b/packages/backend/src/scraper/openapi/util/priorityQueue.ts @@ -5,6 +5,35 @@ export class PriorityQueue { this.heap = []; } + enqueue(value: T, priority: number) { + this.heap.push({ value, priority }); + this.heapifyUp(); + } + + dequeue(): T | undefined { + if (this.isEmpty()) { + return undefined; + } + + const root = this.heap[0]; + const last = this.heap.pop(); + + if (this.heap.length > 0 && last) { + this.heap[0] = last; + this.heapifyDown(); + } + + return root.value; + } + + peek(): T | undefined { + return this.heap.length > 0 ? this.heap[0].value : undefined; + } + + isEmpty(): boolean { + return this.heap.length === 0; + } + private getParentIndex(index: number): number { return Math.floor((index - 1) / 2); } @@ -54,33 +83,4 @@ export class PriorityQueue { index = smallerChildIndex; } } - - enqueue(value: T, priority: number) { - this.heap.push({ value, priority }); - this.heapifyUp(); - } - - dequeue(): T | undefined { - if (this.isEmpty()) { - return undefined; - } - - const root = this.heap[0]; - const last = this.heap.pop(); - - if (this.heap.length > 0 && last) { - this.heap[0] = last; - this.heapifyDown(); - } - - return root.value; - } - - peek(): T | undefined { - return this.heap.length > 0 ? this.heap[0].value : undefined; - } - - isEmpty(): boolean { - return this.heap.length === 0; - } } From f71f521ea37bba64d09547aff8a4503d8d3347fc Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 11:08:26 +0900 Subject: [PATCH 135/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20update=20at=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiFluctuationData.api.ts | 10 +++++----- .../openapi/api/openapiRankView.api.ts | 20 +++++++++++++++++-- .../scraper/openapi/openapi-scraper.module.ts | 2 +- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 1efb846c..6cc474f9 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; @@ -23,11 +22,11 @@ export class OpenapiFluctuationData { private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { - setTimeout(() => this.getFluctuationRankStocks(), 1000); + // setTimeout(() => this.getFluctuationRankStocks(), 1000); } - @Cron('* 9-15 * * 1-5') - @Cron('*/1 9-15 * * 1-5') + // @Cron('* 9-15 * * 1-5') + // @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { await this.getDecreaseRankStocks(); await this.getIncreaseRankStocks(); @@ -104,6 +103,7 @@ export class OpenapiFluctuationData { private async getFluctuationRankApiStocks(isRising: boolean) { const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + const result = await getOpenApi( this.fluctuationUrl, (await this.openApiToken.configs())[0], @@ -126,7 +126,7 @@ export class OpenapiFluctuationData { for (let i = 0; i < 20; ++i) { if (i >= data.length) break; else if (i == 10) - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1500)); const stockId = data[i].stock.id; const stockData = await getOpenApi( this.liveUrl, diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts index a553a87e..9fa50469 100644 --- a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -90,7 +90,15 @@ export class OpenapiRankViewApi { .into(StockLiveData) .values(data) .orUpdate( - ['current_price', 'change_rate', 'volume', 'high', 'low', 'open'], + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updated_at', + ], ['stock_id'], ) .execute(); @@ -107,7 +115,15 @@ export class OpenapiRankViewApi { .into(StockLiveData) .values(data) .orUpdate( - ['current_price', 'change_rate', 'volume', 'high', 'low', 'open'], + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updatedAt', + ], ['stock_id'], ) .execute(); diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 7b58b88f..607f6a85 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -7,12 +7,12 @@ import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; +import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; import { OpenapiConsumer, OpenapiQueue, } from '@/scraper/openapi/queue/openapi.queue'; -import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { From 36812e3b4c493bec49a6cf681ed7e7b9114168a4 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 11:25:33 +0900 Subject: [PATCH 136/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EB=90=9C=20=ED=82=A4=20=EB=8B=A4=EC=8B=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/scraper/openapi/type/openapiUtil.type.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index 053bfd19..6ccdeec1 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -17,4 +17,5 @@ export const TR_IDS: Record = { LIVE_DATA: 'FHKST01010100', INDEX_DATA: 'FHPUP02100000', RATE_DATA: 'FHKST03030100', + FLUCTUATION_DATA: 'FHPST01700000', }; From c1b3351fa13ad009379d22e9d91d7145d4cd6d87 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 11:30:20 +0900 Subject: [PATCH 137/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A7=91=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=9E=AC=EA=B0=80=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiFluctuationData.api.ts | 7 ++++--- .../backend/src/scraper/openapi/type/openapiIndex.type.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 6cc474f9..b36ab327 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; @@ -22,11 +23,11 @@ export class OpenapiFluctuationData { private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { - // setTimeout(() => this.getFluctuationRankStocks(), 1000); + setTimeout(() => this.getFluctuationRankStocks(), 1000); } - // @Cron('* 9-15 * * 1-5') - // @Cron('*/1 9-15 * * 1-5') + @Cron('* 9-15 * * 1-5') + @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { await this.getDecreaseRankStocks(); await this.getIncreaseRankStocks(); diff --git a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts index d061f3df..cf533114 100644 --- a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts @@ -100,6 +100,7 @@ export type StockIndex = { export function isStockIndex(data: any): data is StockIndex { return ( + data && typeof data.bstp_nmix_prpr === 'string' && typeof data.bstp_nmix_prdy_vrss === 'string' && typeof data.prdy_vrss_sign === 'string' && From 2ff278cb815f2f2d6960d594ee67f9ee8c2eff22 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 11:33:35 +0900 Subject: [PATCH 138/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20response=20id=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.gateway.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 121804f9..db9446d4 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -30,6 +30,7 @@ import { StockService } from '@/stock/stock.service'; import { User } from '@/user/domain/user.entity'; interface chatResponse { + id: number; likeCount: number; message: string; type: string; @@ -149,6 +150,7 @@ export class ChatGateway implements OnGatewayConnection { private toResponse(chat: Chat): chatResponse { return { + id: chat.id, likeCount: chat.likeCount, message: chat.message, type: chat.type, From 24c5e5a349540176a898c26981b705f90c8ff74f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 13:27:58 +0900 Subject: [PATCH 139/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EC=A3=BC=EA=B0=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=81=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiFluctuationData.api.ts | 84 ++++++++++++++++++- .../openapi/api/openapiRankView.api.ts | 1 + .../scraper/openapi/queue/openapi.queue.ts | 6 +- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index b36ab327..775ae52a 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -12,6 +12,7 @@ import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; @Injectable() export class OpenapiFluctuationData { @@ -21,6 +22,7 @@ export class OpenapiFluctuationData { constructor( private readonly openApiToken: OpenapiTokenApi, private readonly datasource: DataSource, + private readonly openApiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, ) { setTimeout(() => this.getFluctuationRankStocks(), 1000); @@ -29,8 +31,8 @@ export class OpenapiFluctuationData { @Cron('* 9-15 * * 1-5') @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { - await this.getDecreaseRankStocks(); - await this.getIncreaseRankStocks(); + await this.getFluctuationRankFromApi(true); + await this.getFluctuationRankFromApi(false); } async getDecreaseRankStocks(count = 5) { @@ -75,8 +77,59 @@ export class OpenapiFluctuationData { } } + private async getFluctuationRankFromApi(isRising: boolean) { + const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + const callback: (value: T) => Promise = async ( + data: Json, + ) => { + if (!Array.isArray(data.output)) return; + const save = data.output + .slice(0, 20) + .map((result: Record) => ({ + rank: Number(result.data_rank), + fluctuationRate: result.prdy_ctrt, + stock: { id: result.stck_shrn_iscd } as Stock, + isRising, + })); + await this.saveFluctuationRankStocks(save, this.datasource.manager); + + save.forEach((data) => { + const stockId = data.stock.id; + const callback: (value: T) => Promise = async ( + data: Json, + ) => { + if (Array.isArray(data.output)) return; + const stockLiveData = this.convertToStockLiveData( + data.output, + stockId, + ); + await this.saveIndividualLiveData( + stockLiveData, + this.datasource.manager, + ); + }; + this.openApiQueue.enqueue({ + url: this.liveUrl, + query: { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockId, + }, + trId: TR_IDS.LIVE_DATA, + callback, + }); + }); + }; + + this.openApiQueue.enqueue({ + url: this.fluctuationUrl, + query, + trId: TR_IDS.FLUCTUATION_DATA, + callback, + }); + } + private async saveFluctuationRankStocks( - result: FluctuationRankStock[], + result: Omit[], manager: EntityManager, ) { await manager @@ -158,4 +211,29 @@ export class OpenapiFluctuationData { stockLiveData.updatedAt = new Date(); return stockLiveData; } + + private async saveIndividualLiveData( + data: StockLiveData, + manager: EntityManager, + ) { + return await manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .orUpdate( + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updatedAt', + ], + ['stock_id'], + ) + .execute(); + } } diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts index 9fa50469..0e38b2d5 100644 --- a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -45,6 +45,7 @@ export class OpenapiRankViewApi { const callback: (value: T) => Promise = async ( liveResult: Json, ) => { + if (Array.isArray(liveResult.output)) return; const data = this.convertToStockLiveData(liveResult.output, stock.id); await this.saveIndividualLiveData(data, this.datasource.manager); }; diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index da75287f..8328de60 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -5,9 +5,9 @@ import { TR_ID } from '@/scraper/openapi/type/openapiUtil.type'; import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; -export type Json = { - output: Record; -}; +export interface Json { + output: Record | Record[]; +} export interface OpenapiQueueNodeValue { url: string; From 287b53b419af6f74e90af00bba0cb4d60a7b6dcb Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 13:53:11 +0900 Subject: [PATCH 140/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=B3=80=EB=8F=99=EB=A5=A0=20=EC=88=9C=EC=9C=84=EC=97=90=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=EB=90=9C=20=EC=A4=91=EB=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiFluctuationData.api.ts | 221 ++++-------------- .../openapi/api/openapiLiveData.api.ts | 47 ++++ 2 files changed, 88 insertions(+), 180 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 775ae52a..3e0c46c6 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -2,17 +2,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; -import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; import { DECREASE_STOCK_QUERY, INCREASE_STOCK_QUERY, } from '@/scraper/openapi/constants/query'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; -import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; @Injectable() export class OpenapiFluctuationData { @@ -20,9 +18,9 @@ export class OpenapiFluctuationData { '/uapi/domestic-stock/v1/ranking/fluctuation'; private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; constructor( - private readonly openApiToken: OpenapiTokenApi, private readonly datasource: DataSource, private readonly openApiQueue: OpenapiQueue, + private readonly openApiLive: OpenapiLiveData, @Inject('winston') private readonly logger: Logger, ) { setTimeout(() => this.getFluctuationRankStocks(), 1000); @@ -35,96 +33,55 @@ export class OpenapiFluctuationData { await this.getFluctuationRankFromApi(false); } - async getDecreaseRankStocks(count = 5) { - try { - if (count === 0) return; - const result = await this.getFluctuationRankApiStocks(false); - const liveResult = await this.getFluctuationRankApiLive(result); - await this.datasource.transaction(async (manager) => { - await this.datasource.manager.delete(FluctuationRankStock, { - isRising: false, - }); - await this.saveFluctuationRankStocks(result, manager); - await this.saveLiveData(liveResult, manager); - this.logger.info('decrease rank stocks updated'); - }); - } catch (error) { - this.logger.warn(error); - await new Promise((resolve) => - setTimeout(() => resolve(this.getDecreaseRankStocks(--count)), 2000), - ); - } - } - - async getIncreaseRankStocks(count = 5) { - try { - if (count === 0) return; - const result = await this.getFluctuationRankApiStocks(true); - const liveResult = await this.getFluctuationRankApiLive(result); - await this.datasource.transaction(async (manager) => { - await this.datasource.manager.delete(FluctuationRankStock, { - isRising: true, - }); - await this.saveFluctuationRankStocks(result, manager); - await this.saveLiveData(liveResult, manager); - this.logger.info('increase rank stocks updated'); - }); - } catch (error) { - this.logger.warn(error); - await new Promise((resolve) => - setTimeout(() => resolve(this.getIncreaseRankStocks(--count)), 3000), - ); - } + async getFluctuationRankFromApi(isRising: boolean) { + const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + this.openApiQueue.enqueue({ + url: this.fluctuationUrl, + query, + trId: TR_IDS.FLUCTUATION_DATA, + callback: this.getFluctuationRankStocksCallback(isRising), + }); } - private async getFluctuationRankFromApi(isRising: boolean) { - const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; - const callback: (value: T) => Promise = async ( - data: Json, - ) => { - if (!Array.isArray(data.output)) return; - const save = data.output - .slice(0, 20) - .map((result: Record) => ({ - rank: Number(result.data_rank), - fluctuationRate: result.prdy_ctrt, - stock: { id: result.stck_shrn_iscd } as Stock, - isRising, - })); + private getFluctuationRankStocksCallback(isRising: boolean) { + return async (data: Json) => { + const save = this.convertToFluctuationRankStock(data, isRising); await this.saveFluctuationRankStocks(save, this.datasource.manager); save.forEach((data) => { const stockId = data.stock.id; - const callback: (value: T) => Promise = async ( - data: Json, - ) => { - if (Array.isArray(data.output)) return; - const stockLiveData = this.convertToStockLiveData( - data.output, - stockId, - ); - await this.saveIndividualLiveData( - stockLiveData, - this.datasource.manager, - ); - }; - this.openApiQueue.enqueue({ - url: this.liveUrl, - query: { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockId, - }, - trId: TR_IDS.LIVE_DATA, - callback, - }); + this.insertLiveDataRequest(stockId); }); }; + } + + private convertToFluctuationRankStock(data: Json, isRising: boolean) { + if (!Array.isArray(data.output)) + return [ + { + rank: Number(data.output.data_rank), + fluctuationRate: data.output.prdy_ctrt, + stock: { id: data.output.stck_shrn_iscd } as Stock, + isRising, + }, + ]; + return data.output.slice(0, 20).map((result: Record) => ({ + rank: Number(result.data_rank), + fluctuationRate: result.prdy_ctrt, + stock: { id: result.stck_shrn_iscd } as Stock, + isRising, + })); + } + private insertLiveDataRequest(stockId: string) { this.openApiQueue.enqueue({ - url: this.fluctuationUrl, - query, - trId: TR_IDS.FLUCTUATION_DATA, - callback, + url: this.liveUrl, + query: { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockId, + }, + trId: TR_IDS.LIVE_DATA, + callback: this.openApiLive.getLiveDataSaveCallback(stockId), }); } @@ -140,100 +97,4 @@ export class OpenapiFluctuationData { .values(result) .execute(); } - - private async saveLiveData(data: StockLiveData[], manager: EntityManager) { - return await manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .orUpdate( - ['current_price', 'change_rate', 'volume', 'high', 'low', 'open'], - ['stock_id'], - ) - .execute(); - } - - private async getFluctuationRankApiStocks(isRising: boolean) { - const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; - - const result = await getOpenApi( - this.fluctuationUrl, - (await this.openApiToken.configs())[0], - query, - TR_IDS.FLUCTUATION_DATA, - ); - - return result.output.slice(0, 20).map((result: Record) => { - return { - rank: result.data_rank, - fluctuationRate: result.prdy_ctrt, - stock: { id: result.stck_shrn_iscd }, - isRising, - }; - }); - } - - private async getFluctuationRankApiLive(data: FluctuationRankStock[]) { - const result: StockLiveData[] = []; - for (let i = 0; i < 20; ++i) { - if (i >= data.length) break; - else if (i == 10) - await new Promise((resolve) => setTimeout(resolve, 1500)); - const stockId = data[i].stock.id; - const stockData = await getOpenApi( - this.liveUrl, - (await this.openApiToken.configs())[0], - { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockId, - }, - TR_IDS.LIVE_DATA, - ); - result.push(this.convertToStockLiveData(stockData.output, stockId)); - } - return result; - } - - private convertToStockLiveData( - stockData: Record, - stockId: string, - ): StockLiveData { - const stockLiveData = new StockLiveData(); - stockLiveData.stock = { id: stockId } as Stock; - stockLiveData.currentPrice = parseFloat(stockData.stck_prpr); - stockLiveData.changeRate = parseFloat(stockData.prdy_ctrt); - stockLiveData.volume = parseInt(stockData.acml_vol); - stockLiveData.high = parseFloat(stockData.stck_hgpr); - stockLiveData.low = parseFloat(stockData.stck_lwpr); - stockLiveData.open = parseFloat(stockData.stck_oprc); - stockLiveData.updatedAt = new Date(); - return stockLiveData; - } - - private async saveIndividualLiveData( - data: StockLiveData, - manager: EntityManager, - ) { - return await manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .orUpdate( - [ - 'current_price', - 'change_rate', - 'volume', - 'high', - 'low', - 'open', - 'updatedAt', - ], - ['stock_id'], - ) - .execute(); - } } diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index d65b3e32..310e1988 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -7,6 +7,7 @@ import { TR_IDS } from '../type/openapiUtil.type'; import { getOpenApi } from '../util/openapiUtil.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { Json } from '@/scraper/openapi/queue/openapi.queue'; @Injectable() export class OpenapiLiveData { @@ -92,10 +93,56 @@ export class OpenapiLiveData { } } + getLiveDataSaveCallback(stockId: string) { + return async (data: Json) => { + if (Array.isArray(data.output)) return; + const stockLiveData = this.convertToStockLiveData(data.output, stockId); + await this.saveIndividualLiveData(stockLiveData); + }; + } + private makeLiveDataQuery(stockId: string, code: 'J' = 'J') { return { fid_cond_mrkt_div_code: code, fid_input_iscd: stockId, }; } + + private convertToStockLiveData( + stockData: Record, + stockId: string, + ): StockLiveData { + const stockLiveData = new StockLiveData(); + stockLiveData.stock = { id: stockId } as Stock; + stockLiveData.currentPrice = parseFloat(stockData.stck_prpr); + stockLiveData.changeRate = parseFloat(stockData.prdy_ctrt); + stockLiveData.volume = parseInt(stockData.acml_vol); + stockLiveData.high = parseFloat(stockData.stck_hgpr); + stockLiveData.low = parseFloat(stockData.stck_lwpr); + stockLiveData.open = parseFloat(stockData.stck_oprc); + stockLiveData.updatedAt = new Date(); + return stockLiveData; + } + + private async saveIndividualLiveData(data: StockLiveData) { + return await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .orUpdate( + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updatedAt', + ], + ['stock_id'], + ) + .execute(); + } } From 0a920280fcbf9e47eb8d1598a609b0670885a9e7 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 14:34:16 +0900 Subject: [PATCH 141/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=88=9C=EC=9C=84=EC=97=90=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=EB=90=9C=20=EC=A4=91=EB=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiLiveData.api.ts | 1 - .../openapi/api/openapiRankView.api.ts | 133 ++---------------- 2 files changed, 15 insertions(+), 119 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 310e1988..d51a39b5 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -54,7 +54,6 @@ export class OpenapiLiveData { stockLiveData.low = parseFloat(data.stck_lwpr); stockLiveData.open = parseFloat(data.stck_oprc); stockLiveData.updatedAt = new Date(); - return stockLiveData; } }; diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts index 0e38b2d5..15f3a202 100644 --- a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -1,13 +1,10 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { DataSource, EntityManager } from 'typeorm'; -import { Logger } from 'winston'; -import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; -import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; +import { DataSource } from 'typeorm'; +import { OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; -import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; import { Stock } from '@/stock/domain/stock.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; @Injectable() export class OpenapiRankViewApi { @@ -15,40 +12,16 @@ export class OpenapiRankViewApi { constructor( private readonly datasource: DataSource, - private readonly openApiToken: OpenapiTokenApi, + private readonly openApiLiveData: OpenapiLiveData, private readonly openApiQueue: OpenapiQueue, - @Inject('winston') private readonly logger: Logger, ) { setTimeout(() => this.getTopViewsStockLiveData(), 6000); } @Cron('* 9-15 * * 1-5') - async getTopViewsStockLiveData(count = 5) { - try { - if (count === 0) return; - await this.getTopViewsStocks(); - // const liveResult = await this.getFluctuationRankApiLive(topViewsStocks); - } catch (error) { - this.logger.warn(error); - this.getTopViewsStockLiveData(--count); - } - } - - private async getTopViewsStocks() { - const date = await this.datasource.manager - .getRepository(Stock) - .createQueryBuilder('stock') - .orderBy('stock.views', 'DESC') - .limit(10) - .getMany(); + async getTopViewsStockLiveData() { + const date = await this.findTopViewsStocks(); date.forEach((stock) => { - const callback: (value: T) => Promise = async ( - liveResult: Json, - ) => { - if (Array.isArray(liveResult.output)) return; - const data = this.convertToStockLiveData(liveResult.output, stock.id); - await this.saveIndividualLiveData(data, this.datasource.manager); - }; this.openApiQueue.enqueue({ url: this.liveUrl, query: { @@ -56,93 +29,17 @@ export class OpenapiRankViewApi { fid_input_iscd: stock.id, }, trId: TR_IDS.LIVE_DATA, - callback, + callback: this.openApiLiveData.getLiveDataSaveCallback(stock.id), }); }); - return date.slice(0, 10); - } - - private async getViewsRankApiLive(data: Stock[]) { - const result: StockLiveData[] = []; - for (let i = 0; i < 20; ++i) { - if (i >= data.length) break; - else if (i == 10) - await new Promise((resolve) => setTimeout(resolve, 1000)); - const stockId = data[i].id; - const stockData = await getOpenApi( - this.liveUrl, - (await this.openApiToken.configs())[0], - { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockId, - }, - TR_IDS.LIVE_DATA, - ); - result.push(this.convertToStockLiveData(stockData.output, stockId)); - } - return result; } - private async saveLiveData(data: StockLiveData[], manager: EntityManager) { - return await manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .orUpdate( - [ - 'current_price', - 'change_rate', - 'volume', - 'high', - 'low', - 'open', - 'updated_at', - ], - ['stock_id'], - ) - .execute(); - } - - private async saveIndividualLiveData( - data: StockLiveData, - manager: EntityManager, - ) { - return await manager - .getRepository(StockLiveData) - .createQueryBuilder() - .insert() - .into(StockLiveData) - .values(data) - .orUpdate( - [ - 'current_price', - 'change_rate', - 'volume', - 'high', - 'low', - 'open', - 'updatedAt', - ], - ['stock_id'], - ) - .execute(); - } - - private convertToStockLiveData( - stockData: Record, - stockId: string, - ): StockLiveData { - const stockLiveData = new StockLiveData(); - stockLiveData.stock = { id: stockId } as Stock; - stockLiveData.currentPrice = parseFloat(stockData.stck_prpr); - stockLiveData.changeRate = parseFloat(stockData.prdy_ctrt); - stockLiveData.volume = parseInt(stockData.acml_vol); - stockLiveData.high = parseFloat(stockData.stck_hgpr); - stockLiveData.low = parseFloat(stockData.stck_lwpr); - stockLiveData.open = parseFloat(stockData.stck_oprc); - stockLiveData.updatedAt = new Date(); - return stockLiveData; + private async findTopViewsStocks() { + return await this.datasource.manager + .getRepository(Stock) + .createQueryBuilder('stock') + .orderBy('stock.views', 'DESC') + .limit(10) + .getMany(); } } From 6606b14dd09e02547255e613b4e8bc0d012c3424 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 14:45:46 +0900 Subject: [PATCH 142/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=81=90=EC=9D=98?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20=EC=97=AC=EB=9F=AC=20=EA=B3=84?= =?UTF-8?q?=EC=A2=8C=EC=97=90=20=EC=A0=81=EC=A0=88=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EB=B6=84=EB=B0=B0=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=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/scraper/openapi/queue/openapi.queue.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index 8328de60..5802370e 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -41,6 +41,7 @@ export class OpenapiQueue { export class OpenapiConsumer { private readonly REQUEST_COUNT_PER_SECOND = 20; private isProcessing: boolean = false; + private currentTokenIndex = 0; constructor( private readonly queue: OpenapiQueue, @@ -58,9 +59,11 @@ export class OpenapiConsumer { if (this.isProcessing) { return; } + const maxTokenIndex = (await this.openapiTokenApi.configs()).length; while (!this.queue.isEmpty()) { this.isProcessing = true; await this.processRequest(); + this.currentTokenIndex = (this.currentTokenIndex + 1) % maxTokenIndex; await new Promise((resolve) => setTimeout(resolve, 1000)); } this.isProcessing = false; @@ -75,7 +78,7 @@ export class OpenapiConsumer { try { const data = await getOpenApi( node.url, - (await this.openapiTokenApi.configs())[0], + (await this.openapiTokenApi.configs())[this.currentTokenIndex], node.query, node.trId, ); From 89f3fbcc86509cd9ea6d0ba178441586b042c61c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 15:08:56 +0900 Subject: [PATCH 143/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EA=B0=81=20?= =?UTF-8?q?=EA=B3=84=EC=A2=8C=EB=A7=88=EB=8B=A4=201=EC=B4=88=EC=94=A9=20bl?= =?UTF-8?q?ock=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/openapi/queue/openapi.queue.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index 5802370e..051995a4 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -59,17 +59,27 @@ export class OpenapiConsumer { if (this.isProcessing) { return; } - const maxTokenIndex = (await this.openapiTokenApi.configs()).length; + while (!this.queue.isEmpty()) { this.isProcessing = true; - await this.processRequest(); - this.currentTokenIndex = (this.currentTokenIndex + 1) % maxTokenIndex; + await this.processQueueRequest(); await new Promise((resolve) => setTimeout(resolve, 1000)); } this.isProcessing = false; } - private async processRequest() { + private async processQueueRequest() { + const tokenCount = (await this.openapiTokenApi.configs()).length; + for (let i = 0; i < tokenCount; i++) { + await this.processIndividualTokenRequest(this.currentTokenIndex); + if (!this.isProcessing) { + return; + } + this.currentTokenIndex = (this.currentTokenIndex + 1) % tokenCount; + } + } + + private async processIndividualTokenRequest(index: number) { for (let i = 0; i < this.REQUEST_COUNT_PER_SECOND; i++) { const node = this.queue.dequeue(); if (!node) { @@ -78,7 +88,7 @@ export class OpenapiConsumer { try { const data = await getOpenApi( node.url, - (await this.openapiTokenApi.configs())[this.currentTokenIndex], + (await this.openapiTokenApi.configs())[index], node.query, node.trId, ); From e949bfee5169859acfc53bad04537946e8a441c4 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 15:56:54 +0900 Subject: [PATCH 144/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=B3=80?= =?UTF-8?q?=EB=8F=99=EB=A5=A0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EB=88=84=EC=A0=81=EB=90=98=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiFluctuationData.api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 3e0c46c6..d6586173 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -35,6 +35,7 @@ export class OpenapiFluctuationData { async getFluctuationRankFromApi(isRising: boolean) { const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + await this.datasource.manager.delete(FluctuationRankStock, { isRising }); this.openApiQueue.enqueue({ url: this.fluctuationUrl, query, From c1ca6f9a1ca524127bd6575addc4c0b3cdf46138 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 16:08:52 +0900 Subject: [PATCH 145/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=EC=84=9C=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=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 | 11 +++++ .../backend/src/stock/stock.controller.ts | 44 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index d5a747c6..2d36f58b 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -147,9 +147,19 @@ export class StockRankResponse { example: 1, }) rank: number; + + @ApiProperty({ + description: '상승 하락 여부', + example: true, + }) + isRising: boolean; } export class StockRankResponses { + @ApiProperty({ + description: '주식 랭킹 결과', + type: [StockRankResponse], + }) result: StockRankResponse[]; constructor(stocks: Record[]) { @@ -161,6 +171,7 @@ export class StockRankResponses { marketCap: stock.marketCap, changeRate: parseFloat(stock.rank_fluctuation_rate), rank: parseInt(stock.rank_rank), + isRising: Number(stock.rank_isRising) === 1, })); } } diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index ddbe1537..08198b90 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -15,6 +15,7 @@ import { ApiOkResponse, ApiOperation, ApiParam, + ApiQuery, } from '@nestjs/swagger'; import { Request } from 'express'; import { ApiGetStocks, LimitQuery } from './decorator/stock.decorator'; @@ -36,6 +37,7 @@ import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; import { StockSearchRequest } from '@/stock/dto/stock.request'; import { + StockRankResponses, StockSearchResponse, StockViewsResponse, } from '@/stock/dto/stock.response'; @@ -61,6 +63,15 @@ const TIME_UNIT = { type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; +const FLUCTUATION_TYPE = { + INCREASE: 'increase', + DECREASE: 'decrease', + ALL: 'all', +} as const; + +type FLUCTUATION_TYPE = + (typeof FLUCTUATION_TYPE)[keyof typeof FLUCTUATION_TYPE]; + @Controller('stock') export class StockController { constructor( @@ -211,9 +222,36 @@ export class StockController { } @Get('fluctuation') - @ApiGetStocks('가격 상승률, 하락률 기반 주식 리스트 조회 API') - async getTopStocksByFluctuation() { - return await this.stockService.getTopStocksByFluctuation(); + @ApiOperation({ + summary: '등가, 등락률 기반 주식 리스트 조회 API', + description: '등가, 등락률 기반 주식 리스트를 조회합니다', + }) + @ApiQuery({ + name: 'limit', + required: false, + description: + '조회할 리스트 수(기본값: 20, 등가, 등락 모두 받으면 모든 데이터 전송)', + }) + @ApiQuery({ + name: 'type', + required: false, + description: '데이터 타입(기본값: increase, all, increase, decrease)', + enum: ['increase', 'decrease', 'all'], + }) + @ApiOkResponse({ + description: '', + type: [StockRankResponses], + }) + async getTopStocksByFluctuation( + @LimitQuery(20) limit: number, + @Query('type') type: FLUCTUATION_TYPE, + ) { + if (type === FLUCTUATION_TYPE.DECREASE) { + return await this.stockService.getTopStocksByLosers(limit); + } else if (type === FLUCTUATION_TYPE.ALL) { + return await this.stockService.getTopStocksByFluctuation(); + } + return await this.stockService.getTopStocksByGainers(limit); } @ApiOperation({ From 257cb067a104bbc8e21bc137a887b8e538532b79 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 16:15:48 +0900 Subject: [PATCH 146/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EC=97=90=20=EC=84=9C=EB=B8=8C=EB=84=A4=EC=9E=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/dto/chat.response.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index 68a81b4d..799c43af 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -31,6 +31,8 @@ export class ChatScrollResponse { type: ChatType.NORMAL, isLiked: true, createdAt: new Date(), + mentioned: false, + subName: '0001', }, ], }) @@ -46,6 +48,7 @@ export class ChatScrollResponse { liked: !!(chat.likes && chat.likes.length > 0), mentioned: chat.mentions && chat.mentions.length > 0, nickname: chat.user.nickname, + subName: chat.user.subName, })); this.hasMore = hasMore; } From dcf6933924b53bf022d1e5a9ddfd01dabddd89b5 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 16:17:20 +0900 Subject: [PATCH 147/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20subName=EB=8F=84=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/auth/auth.controller.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 56af9df9..4efcceac 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -43,8 +43,12 @@ export class AuthController { async user(@Req() request: Request) { if (request.user) { const user = request.user as User; - return { message: 'Authenticated', nickname: user.nickname }; + return { + message: 'Authenticated', + nickname: user.nickname, + subName: user.subName, + }; } - return { message: 'Not Authenticated', nickname: null }; + return { message: 'Not Authenticated', nickname: null, subName: null }; } } From bc458f2a04a0246ebf3750e2d38775ccebf4be9f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 16:42:48 +0900 Subject: [PATCH 148/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B0=9C=EB=B3=84?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=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/chat/chat.gateway.ts | 21 ++++++++----------- .../backend/src/chat/dto/chat.response.ts | 3 ++- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index db9446d4..cabd0046 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -23,21 +23,13 @@ import { ChatScrollQuery, isChatScrollQuery, } from '@/chat/dto/chat.request'; +import { ChatResponse } from '@/chat/dto/chat.response'; import { LikeResponse } from '@/chat/dto/like.response'; import { MentionService } from '@/chat/mention.service'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; import { User } from '@/user/domain/user.entity'; -interface chatResponse { - id: number; - likeCount: number; - message: string; - type: string; - mentioned: boolean; - createdAt: Date; -} - @WebSocketGateway({ namespace: '/api/chat/realtime' }) @UseFilters(WebSocketExceptionFilter) export class ChatGateway implements OnGatewayConnection { @@ -81,14 +73,16 @@ export class ChatGateway implements OnGatewayConnection { await this.mentionService.createMention(savedChat.id, mention); const mentionedSocket = this.users.get(Number(mention)); if (mentionedSocket) { - const chatResponse = this.toResponse(savedChat); + const chatResponse = this.toResponse(savedChat, client.session); this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); chatResponse.mentioned = true; this.server.to(mentionedSocket).emit('chat', chatResponse); return; } } - this.server.to(room).emit('chat', this.toResponse(savedChat)); + this.server + .to(room) + .emit('chat', this.toResponse(savedChat, client.session)); } async broadcastLike(response: LikeResponse) { @@ -148,13 +142,16 @@ export class ChatGateway implements OnGatewayConnection { }; } - private toResponse(chat: Chat): chatResponse { + private toResponse(chat: Chat, user: User): ChatResponse { return { id: chat.id, likeCount: chat.likeCount, message: chat.message, type: chat.type, mentioned: false, + nickname: user.nickname, + subName: user.subName, + liked: false, createdAt: chat.date?.createdAt || new Date(), }; } diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index 799c43af..c2b28b9e 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -2,13 +2,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { Chat } from '@/chat/domain/chat.entity'; import { ChatType } from '@/chat/domain/chatType.enum'; -interface ChatResponse { +export interface ChatResponse { id: number; likeCount: number; message: string; type: string; liked: boolean; nickname: string; + subName: string; mentioned: boolean; createdAt: Date; } From 745d04b09a4c4f0d06587cfbd9d5aa676706056b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 28 Nov 2024 17:58:42 +0900 Subject: [PATCH 149/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=B3=80?= =?UTF-8?q?=EB=8F=99=EB=A5=A0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=ED=95=B4=EC=A7=84=20=EA=B8=B8=EC=9D=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=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.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 759717d6..6f089af2 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -121,16 +121,19 @@ export class StockService { } async getTopStocksByGainers(limit: number) { - const rawData = await this.getStockRankQuery(true).take(limit).getRawMany(); + const rawData = await this.getStockRankQuery(true) + .orderBy('rank.rank', 'ASC') + .limit(limit) + .getRawMany(); return new StockRankResponses(rawData); } async getTopStocksByLosers(limit: number) { const rawData = await this.getStockRankQuery(false) - .take(limit) + .orderBy('rank.rank', 'ASC') + .limit(limit) .getRawMany(); - return new StockRankResponses(rawData); } From d242f543bb5ac90e124fef65082e6be1311174ad Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Sat, 30 Nov 2024 22:38:45 +0900 Subject: [PATCH 150/223] =?UTF-8?q?Refactor/#284=20-=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,=20livedata=20?= =?UTF-8?q?=EC=84=9C=EB=B9=99=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#2?= =?UTF-8?q?94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style: facade 철자 수정 * 💄 style: output 삭제 * 🐛 fix: 이거 안 됨 * ♻️ refactor: 다중 websocket에 대해 적용 * 💄 style: 테스트용 데이터 삭제 * 🐛 fix: live data 수집 * ♻️ refactor: tmp * 💄 style: 동영상 잘못 올라간 거 삭제 * 💄 style: cron 삭제 * 🐛 fix: typeguard 보강 * 🐛 fix: client subscribe, unsubscribe 에러 수정 * 🐛 fix: stockGateway 싱글톤 보장 및 뮤텍스 추가 * 📝 docs: 완료된 todo 제거 * 🐛 fix: module 의존성 수정 * ♻️ refactor: module 중복 import수정 * 🐛 fix: typeorm 모듈 최상단에 삽입 시 발생되는 DI 문제 해결 * 🐛 fix: 테스트용 코드 삭제, unscribe 문제 해결 --- packages/backend/package.json | 1 + packages/backend/src/app.module.ts | 11 +-- .../openapi/api/openapiFluctuationData.api.ts | 4 +- .../openapi/api/openapiLiveData.api.ts | 2 +- .../openapi/api/openapiRankView.api.ts | 4 +- .../src/scraper/openapi/liveData.service.ts | 99 +++++++++++++------ .../scraper/openapi/openapi-scraper.module.ts | 3 + .../openapi/type/openapiDetailData.type.ts | 2 + .../scraper/openapi/type/openapiIndex.type.ts | 3 + .../openapi/type/openapiMinuteData.type.ts | 2 + .../openapi/type/openapiPeriodData.type.ts | 1 + .../websocket/websocketClient.websocket.ts | 25 +++-- .../backend/src/scraper/scraper.module.ts | 1 + packages/backend/src/stock/stock.gateway.ts | 33 ++++++- packages/backend/src/stock/stock.module.ts | 10 +- .../src/stock/stockLiveData.subscriber.ts | 30 +++++- packages/backend/src/user/user.controller.ts | 2 +- yarn.lock | 9 +- 18 files changed, 174 insertions(+), 68 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index bdd206db..ae107698 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -31,6 +31,7 @@ "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.8", + "async-mutex": "^0.5.0", "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index f4735ad2..03c8b3c5 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -7,11 +7,11 @@ import { ScraperModule } from './scraper/scraper.module'; import { AuthModule } from '@/auth/auth.module'; import { SessionModule } from '@/auth/session.module'; import { ChatModule } from '@/chat/chat.module'; +import { logger } from '@/configs/logger.config'; import { typeormDevelopConfig, typeormProductConfig, } from '@/configs/typeormConfig'; -import { logger } from '@/configs/logger.config'; import { StockModule } from '@/stock/stock.module'; import { UserModule } from '@/user/user.module'; @@ -19,19 +19,18 @@ import { UserModule } from '@/user/user.module'; imports: [ ConfigModule.forRoot({ cache: true, isGlobal: true }), ScheduleModule.forRoot(), - ScraperModule, - StockModule, - UserModule, + WinstonModule.forRoot(logger), TypeOrmModule.forRoot( process.env.NODE_ENV === 'production' ? typeormProductConfig : typeormDevelopConfig, ), - WinstonModule.forRoot(logger), + ScraperModule, + StockModule, + UserModule, AuthModule, ChatModule, SessionModule, - ScraperModule, ], controllers: [], providers: [], diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index d6586173..c47b8fef 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -2,15 +2,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; import { DECREASE_STOCK_QUERY, INCREASE_STOCK_QUERY, } from '@/scraper/openapi/constants/query'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; -import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; -import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; @Injectable() export class OpenapiFluctuationData { diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index d51a39b5..2897e00b 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -5,9 +5,9 @@ import { openApiConfig } from '../config/openapi.config'; import { isOpenapiLiveData } from '../type/openapiLiveData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { getOpenApi } from '../util/openapiUtil.api'; +import { Json } from '@/scraper/openapi/queue/openapi.queue'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; -import { Json } from '@/scraper/openapi/queue/openapi.queue'; @Injectable() export class OpenapiLiveData { diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts index 15f3a202..4aea2ca1 100644 --- a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; import { OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { Stock } from '@/stock/domain/stock.entity'; -import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; @Injectable() export class OpenapiRankViewApi { @@ -18,7 +18,7 @@ export class OpenapiRankViewApi { setTimeout(() => this.getTopViewsStockLiveData(), 6000); } - @Cron('* 9-15 * * 1-5') + @Cron('*/1 9-15 * * 1-5') async getTopViewsStockLiveData() { const date = await this.findTopViewsStocks(); date.forEach((stock) => { diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 26e8ee76..7749532c 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -8,22 +8,35 @@ import { openApiConfig } from './config/openapi.config'; import { parseMessage } from './parse/openapi.parser'; import { WebsocketClient } from './websocket/websocketClient.websocket'; -type TR_IDS = '0' | '1'; +type TR_IDS = '1' | '2'; @Injectable() export class LiveData { - private readonly clientStock: Set = new Set(); - private readonly reconnectInterval = 60 * 1000 * 1000; - private readonly startTime: Date = new Date(2024, 0, 1, 9, 0, 0, 0); private readonly endTime: Date = new Date(2024, 0, 1, 15, 30, 0, 0); + + private readonly reconnectInterval = 60 * 1000; + private readonly subscribeStocks: Map = new Map(); + + private readonly SOCKET_LIMITS: number = 41; + + private websocketClient: WebsocketClient[] = []; + private configSubscribeSize: number[] = []; constructor( private readonly openApiToken: OpenapiTokenApi, - private readonly webSocketClient: WebsocketClient, private readonly openapiLiveData: OpenapiLiveData, @Inject('winston') private readonly logger: Logger, ) { - this.connect(); + this.openApiToken.configs().then((config) => { + let len = config.length; + while (len--) { + this.websocketClient.push( + WebsocketClient.websocketFactory(this.logger), + ); + this.configSubscribeSize.push(0); + } + this.connect(); + }); } private async openapiSubscribe(stockId: string) { @@ -42,39 +55,59 @@ export class LiveData { } } + isSubscribe(stockId: string) { + return Object.keys(this.subscribeStocks).some((val) => val === stockId); + } + async subscribe(stockId: string) { - if (this.isCloseTime(new Date(), this.startTime, this.endTime)) { - await this.openapiSubscribe(stockId); - } else { - // TODO : 하나의 config만 사용중. - this.clientStock.add(stockId); - const message = this.convertObjectToMessage( - (await this.openApiToken.configs())[1], - stockId, - '1', - ); - this.webSocketClient.subscribe(message); + await this.openapiSubscribe(stockId); + + if (!this.isCloseTime(new Date(), this.startTime, this.endTime)) { + for (const [idx, size] of this.configSubscribeSize.entries()) { + if (size >= this.SOCKET_LIMITS) continue; + + this.configSubscribeSize[idx]++; + this.subscribeStocks.set(stockId, idx); + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[idx], + stockId, + '1', + ); + this.websocketClient[idx].subscribe(message); + return; + } + this.logger.warn(`Websocket register oversize : ${stockId}`); } } - async discribe(stockId: string) { - if (this.clientStock.has(stockId)) { - this.clientStock.delete(stockId); + async unsubscribe(stockId: string) { + if (this.subscribeStocks.has(stockId)) { + const idx = this.subscribeStocks.get(stockId); + this.subscribeStocks.delete(stockId); + + if (idx) { + this.configSubscribeSize[idx]--; + } else { + this.logger.warn(`Websocket error : ${stockId} has invalid idx`); + return; + } + const message = this.convertObjectToMessage( - (await this.openApiToken.configs())[0], + (await this.openApiToken.configs())[idx], stockId, - '0', + '2', ); - this.webSocketClient.discribe(message); + + this.websocketClient[idx].discribe(message); } } private initOpenCallback = - (sendMessage: (message: string) => void) => async () => { + (idx: number) => (sendMessage: (message: string) => void) => async () => { this.logger.info('WebSocket connection established'); - for (const stockId of this.clientStock.keys()) { + for (const stockId of this.subscribeStocks.keys()) { const message = this.convertObjectToMessage( - (await this.openApiToken.configs())[0], + (await this.openApiToken.configs())[idx], stockId, '1', ); @@ -124,12 +157,14 @@ export class LiveData { @Cron('0 2 * * 1-5') connect() { - this.webSocketClient.connectPacade( - this.initOpenCallback, - this.initMessageCallback, - this.initCloseCallback, - this.initErrorCallback, - ); + this.websocketClient.forEach((socket, idx) => { + socket.connectFacade( + this.initOpenCallback(idx), + this.initMessageCallback, + this.initCloseCallback, + this.initErrorCallback, + ); + }); } private convertObjectToMessage( diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 607f6a85..7a6784ae 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -6,6 +6,7 @@ import { OpenapiLiveData } from './api/openapiLiveData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; +import { LiveData } from './liveData.service'; import { OpenapiScraperService } from './openapi-scraper.service'; import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; @@ -41,6 +42,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; ], controllers: [], providers: [ + LiveData, OpenapiLiveData, OpenapiTokenApi, OpenapiPeriodData, @@ -53,5 +55,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiQueue, OpenapiConsumer, ], + exports: [LiveData], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts index a8d21d02..b1dbb3f5 100644 --- a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -85,6 +85,8 @@ export type DetailData = { export function isDetailData(data: any): data is DetailData { return ( + data && + typeof data === 'object' && typeof data.iscd_stat_cls_code === 'string' && typeof data.marg_rate === 'string' && typeof data.rprs_mrkt_kor_name === 'string' && diff --git a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts index cf533114..6eecd5d5 100644 --- a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts @@ -40,6 +40,8 @@ export type ExchangeRate = { export function isExchangeRate(data: any): data is ExchangeRate { return ( + data && + typeof data === 'object' && typeof data.acml_vol === 'string' && typeof data.ovrs_nmix_prpr === 'string' && typeof data.ovrs_nmix_prdy_vrss === 'string' && @@ -101,6 +103,7 @@ export type StockIndex = { export function isStockIndex(data: any): data is StockIndex { return ( data && + typeof data === 'object' && typeof data.bstp_nmix_prpr === 'string' && typeof data.bstp_nmix_prdy_vrss === 'string' && typeof data.prdy_vrss_sign === 'string' && diff --git a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts index 5deb2d9e..72fb4bfb 100644 --- a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts @@ -21,6 +21,8 @@ export type UpdateStockQuery = { export const isMinuteData = (data: any) => { return ( + data && + typeof data === 'object' && typeof data.stck_bsop_date === 'string' && typeof data.stck_cntg_hour === 'string' && typeof data.stck_prpr === 'string' && diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts index e4066f7c..6c47bdd6 100644 --- a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts @@ -28,6 +28,7 @@ export type ItemChartPriceQuery = { export const isChartData = (data?: any) => { return ( data && + typeof data === 'object' && typeof data.stck_bsop_date === 'string' && typeof data.stck_clpr === 'string' && typeof data.stck_oprc === 'string' && diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts index 9661abde..540fb15f 100644 --- a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -5,11 +5,20 @@ import { RawData, WebSocket } from 'ws'; @Injectable() export class WebsocketClient { - private readonly url = - process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; - private client: WebSocket = new WebSocket(this.url); + static url = process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + private client: WebSocket; + //현재 factory 패턴을 이용해 할당하면 socket이 열리기 전에 message가 가는 문제가 있음. + // 소켓이 할당되기 전에(client에 소켓이 없을 때) message를 보내려 시도함. - constructor(@Inject('winston') private readonly logger: Logger) {} + constructor(@Inject('winston') private readonly logger: Logger) { + this.client = new WebSocket(WebsocketClient.url); + } + + static websocketFactory(logger: Logger) { + const websocket = new WebsocketClient(logger); + + return websocket; + } subscribe(message: string) { this.sendMessage(message); @@ -35,7 +44,7 @@ export class WebsocketClient { this.client.on('error', initErrorCallback); } - connectPacade( + connectFacade( initOpenCallback: (fn: (message: string) => void) => () => void, initMessageCallback: (client: WebSocket) => (data: RawData) => void, initCloseCallback: () => void, @@ -48,11 +57,15 @@ export class WebsocketClient { } private sendMessage(message: string) { + if (!this.client || !this.client.readyState) { + this.logger.warn('WebSocket is not open. Message not sent. '); + return; + } if (this.client.readyState === WebSocket.OPEN) { this.client.send(message); this.logger.info(`Sent message: ${message}`); } else { - this.logger.warn('WebSocket is not open. Message not sent.'); + this.logger.warn('WebSocket is not open. Message not sent. '); } } } diff --git a/packages/backend/src/scraper/scraper.module.ts b/packages/backend/src/scraper/scraper.module.ts index 96f25299..9935e7dd 100644 --- a/packages/backend/src/scraper/scraper.module.ts +++ b/packages/backend/src/scraper/scraper.module.ts @@ -6,5 +6,6 @@ import { OpenapiScraperModule } from './openapi/openapi-scraper.module'; imports: [KoreaStockInfoModule, OpenapiScraperModule], controllers: [], providers: [], + exports: [OpenapiScraperModule], }) export class ScraperModule {} diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 2674690d..506d5917 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -1,3 +1,4 @@ +import { Inject, Injectable } from '@nestjs/common'; import { ConnectedSocket, MessageBody, @@ -5,17 +6,24 @@ import { WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; +import { Mutex } from 'async-mutex'; import { Server, Socket } from 'socket.io'; +import { Logger } from 'winston'; import { LiveData } from '@/scraper/openapi/liveData.service'; @WebSocketGateway({ namespace: '/api/stock/realtime', }) +@Injectable() export class StockGateway { @WebSocketServer() server: Server; + private readonly mutex = new Mutex(); - constructor(private readonly liveData: LiveData) {} + constructor( + private readonly liveData: LiveData, + @Inject('winston') private readonly logger: Logger, + ) {} @SubscribeMessage('connectStock') async handleConnectStock( @@ -24,21 +32,36 @@ export class StockGateway { ) { client.join(stockId); - if ((await this.server.in(stockId).fetchSockets()).length === 0) { - this.liveData.subscribe(stockId); - } + await this.mutex.runExclusive(async () => { + const connectedSockets = await this.server.in(stockId).fetchSockets(); + + if (connectedSockets.length > 0 && !this.liveData.isSubscribe(stockId)) { + await this.liveData.subscribe(stockId); + this.logger.info(`${stockId} is subscribed`); + } + }); + client.emit('connectionSuccess', { message: `Successfully connected to stock room: ${stockId}`, stockId, }); } - handleDisconnectStock( + async handleDisconnectStock( @MessageBody() stockId: string, @ConnectedSocket() client: Socket, ) { client.leave(stockId); + await this.mutex.runExclusive(async () => { + const connectedSockets = await this.server.in(stockId).fetchSockets(); + + if (connectedSockets.length === 0) { + await this.liveData.unsubscribe(stockId); + this.logger.info(`${stockId} is unsubscribed`); + } + }); + client.emit('disconnectionSuccess', { message: `Successfully disconnected to stock room: ${stockId}`, stockId, diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 3d79014f..673dbc56 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -24,10 +24,7 @@ import { import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; import { StockRateIndexService } from './stockRateIndex.service'; -import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; -import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; -import { LiveData } from '@/scraper/openapi/liveData.service'; -import { WebsocketClient } from '@/scraper/openapi/websocket/websocketClient.websocket'; +import { ScraperModule } from '@/scraper/scraper.module'; @Module({ imports: [ @@ -41,14 +38,11 @@ import { WebsocketClient } from '@/scraper/openapi/websocket/websocketClient.web StockLiveData, StockDetail, ]), + ScraperModule, ], controllers: [StockController], providers: [ StockService, - WebsocketClient, - OpenapiTokenApi, - OpenapiLiveData, - LiveData, StockGateway, StockLiveDataSubscriber, StockDataService, diff --git a/packages/backend/src/stock/stockLiveData.subscriber.ts b/packages/backend/src/stock/stockLiveData.subscriber.ts index e28d55e2..f44ffe35 100644 --- a/packages/backend/src/stock/stockLiveData.subscriber.ts +++ b/packages/backend/src/stock/stockLiveData.subscriber.ts @@ -1,26 +1,48 @@ -import { Inject } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { EventSubscriber, EntitySubscriberInterface, UpdateEvent, + InsertEvent, + DataSource, } from 'typeorm'; import { Logger } from 'winston'; import { StockLiveData } from './domain/stockLiveData.entity'; import { StockGateway } from './stock.gateway'; +@Injectable() @EventSubscriber() export class StockLiveDataSubscriber implements EntitySubscriberInterface { constructor( + private readonly datasource: DataSource, private readonly stockGateway: StockGateway, @Inject('winston') private readonly logger: Logger, - ) {} + ) { + this.datasource.subscribers.push(this); + } listenTo() { return StockLiveData; } + async afterInsert(event: InsertEvent) { + try { + const entity = event.entity; + const { id: stockId } = entity.stock; + const { + currentPrice: price, + changeRate: change, + volume: volume, + } = entity; + + this.stockGateway.onUpdateStock(stockId, price, change, volume); + } catch (error) { + this.logger.warn(`Failed to handle afterInsert event : ${error}`); + } + } + async afterUpdate(event: UpdateEvent) { try { const updatedStockLiveData = @@ -35,12 +57,12 @@ export class StockLiveDataSubscriber } = updatedStockLiveData; this.stockGateway.onUpdateStock(stockId, price, change, volume); } else { - this.logger.error( + this.logger.warn( `Stock ID missing for updated data : ${updatedStockLiveData?.id}`, ); } } catch (error) { - this.logger.error(`Failed to handle afterUpdate event : ${error}`); + this.logger.warn(`Failed to handle afterUpdate event : ${error}`); } } diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 6abc5b56..a3a22bcb 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -18,9 +18,9 @@ import { ApiParam, ApiResponse, } from '@nestjs/swagger'; +import { Request } from 'express'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; import { UserService } from './user.service'; -import { Request } from 'express'; import { User } from '@/user/domain/user.entity'; import { ChangeNicknameRequest } from '@/user/dto/user.request'; diff --git a/yarn.lock b/yarn.lock index 5d71942f..8ef364a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2924,6 +2924,13 @@ ast-types@^0.16.1: dependencies: tslib "^2.0.1" +async-mutex@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.5.0.tgz#353c69a0b9e75250971a64ac203b0ebfddd75482" + integrity sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA== + dependencies: + tslib "^2.4.0" + async@^3.2.3: version "3.2.6" resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" @@ -8498,7 +8505,7 @@ tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.5.0, tslib@^2.6.2: +tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== From c4b54328e2a04904ef821928d2fdd3438ce33f34 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 30 Nov 2024 23:21:17 +0900 Subject: [PATCH 151/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20subName=EC=9D=B4?= =?UTF-8?q?=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC=EA=B0=80=20=EB=82=98?= =?UTF-8?q?=ED=83=80=EB=82=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=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/user/user.controller.ts | 12 +++++++++--- packages/backend/src/user/user.service.ts | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 6abc5b56..d5769d95 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -16,11 +16,12 @@ import { ApiOkResponse, ApiOperation, ApiParam, + ApiQuery, ApiResponse, } from '@nestjs/swagger'; +import { Request } from 'express'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; import { UserService } from './user.service'; -import { Request } from 'express'; import { User } from '@/user/domain/user.entity'; import { ChangeNicknameRequest } from '@/user/dto/user.request'; @@ -33,8 +34,13 @@ export class UserController { summary: '유저 닉네임과 서브 닉네임으로 유저 조회 API', description: '유저 닉네임과 서브 닉네임으로 유저를 조회합니다.', }) - @ApiParam({ name: 'nickname', type: 'string', description: '유저 닉네임' }) - @ApiParam({ name: 'subName', type: 'string', description: '유저 서브네임' }) + @ApiQuery({ name: 'nickname', type: 'string', description: '유저 닉네임' }) + @ApiQuery({ + name: 'subName', + type: 'string', + description: '유저 서브네임', + required: false, + }) async searchUser( @Query('nickname') nickname: string, @Query('subName') subName: string, diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index cc62193b..a70ccbbc 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -35,8 +35,11 @@ export class UserService { } async searchUserByNicknameAndSubName(nickname: string, subName?: string) { + const whereCondition = subName + ? { nickname: Like(`%${nickname}%`), subName: Like(`${subName}%`) } + : { nickname: Like(`%${nickname}%`) }; const users = await this.dataSource.manager.find(User, { - where: { nickname: Like(`%${nickname}%`), subName: Like(`${subName}%`) }, + where: whereCondition, take: 10, }); return new UserSearchResult(users); From 533c9814faf9671be5bc462c100210f70ed9e3ca Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 30 Nov 2024 23:22:13 +0900 Subject: [PATCH 152/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8D=94=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EC=93=B0=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/domain/kospiStock.entity.ts | 15 --------------- packages/backend/src/stock/domain/stock.entity.ts | 4 ---- 2 files changed, 19 deletions(-) delete mode 100644 packages/backend/src/stock/domain/kospiStock.entity.ts diff --git a/packages/backend/src/stock/domain/kospiStock.entity.ts b/packages/backend/src/stock/domain/kospiStock.entity.ts deleted file mode 100644 index 8f45a87c..00000000 --- a/packages/backend/src/stock/domain/kospiStock.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; -import { Stock } from './stock.entity'; - -@Entity() -export class KospiStock { - @PrimaryColumn({ name: 'stock_id' }) - id: string; - - @Column({ name: 'is_kospi' }) - isKospi: boolean; - - @OneToOne(() => Stock, (stock) => stock.id) - @JoinColumn({ name: 'stock_id' }) - stock: Stock; -} diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index eaa38aa7..108cc993 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -1,5 +1,4 @@ import { Column, Entity, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; -import { KospiStock } from './kospiStock.entity'; import { StockDaily, StockMinutely, @@ -57,9 +56,6 @@ export class Stock { @OneToOne(() => StockLiveData, (stockLiveData) => stockLiveData.stock) stockLive?: StockLiveData; - @OneToOne(() => KospiStock, (kospiStock) => kospiStock.stock) - kospiStock?: KospiStock; - @OneToMany( () => FluctuationRankStock, (fluctuationRankStock) => fluctuationRankStock.stock, From fa806fc87648a6a63d3ddc42fad5451392dd43ad Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 30 Nov 2024 23:24:05 +0900 Subject: [PATCH 153/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EB=8F=99?= =?UTF-8?q?=EB=A5=A0=20=EC=A3=BC=EC=8B=9D=20=EC=A4=91=EB=B3=B5=20=EC=B9=BC?= =?UTF-8?q?=EB=9F=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiFluctuationData.api.ts | 6 ++---- .../backend/src/scraper/openapi/api/openapiRankView.api.ts | 2 +- .../backend/src/stock/domain/FluctuationRankStock.entity.ts | 3 --- packages/backend/src/stock/dto/stock.response.ts | 2 +- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index d6586173..55bf9301 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -2,15 +2,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; import { DECREASE_STOCK_QUERY, INCREASE_STOCK_QUERY, } from '@/scraper/openapi/constants/query'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; -import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; -import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; @Injectable() export class OpenapiFluctuationData { @@ -61,14 +61,12 @@ export class OpenapiFluctuationData { return [ { rank: Number(data.output.data_rank), - fluctuationRate: data.output.prdy_ctrt, stock: { id: data.output.stck_shrn_iscd } as Stock, isRising, }, ]; return data.output.slice(0, 20).map((result: Record) => ({ rank: Number(result.data_rank), - fluctuationRate: result.prdy_ctrt, stock: { id: result.stck_shrn_iscd } as Stock, isRising, })); diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts index 15f3a202..41722625 100644 --- a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; import { OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; import { Stock } from '@/stock/domain/stock.entity'; -import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; @Injectable() export class OpenapiRankViewApi { diff --git a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts index 5ece2107..a3d5dbb7 100644 --- a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts +++ b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts @@ -17,9 +17,6 @@ export class FluctuationRankStock { @JoinColumn({ name: 'stock_id' }) stock: Stock; - @Column({ name: 'fluctuation_rate', type: 'decimal', precision: 5, scale: 2 }) - fluctuationRate: string; - @Column() isRising: boolean; diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index 2d36f58b..a919e1af 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -169,7 +169,7 @@ export class StockRankResponses { currentPrice: parseFloat(stock.currentPrice), volume: parseInt(stock.volume), marketCap: stock.marketCap, - changeRate: parseFloat(stock.rank_fluctuation_rate), + changeRate: parseFloat(stock.changeRate), rank: parseInt(stock.rank_rank), isRising: Number(stock.rank_isRising) === 1, })); From db8512423ce42f966be061f456b01c885335f413 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 30 Nov 2024 23:32:25 +0900 Subject: [PATCH 154/223] =?UTF-8?q?=F0=9F=9A=9A=20chore:=20ts=20mockito=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package.json | 1 + yarn.lock | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index bdd206db..51d8f195 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -73,6 +73,7 @@ "supertest": "^7.0.0", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", + "ts-mockito": "^2.6.1", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" diff --git a/yarn.lock b/yarn.lock index 5d71942f..549e13f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6383,7 +6383,7 @@ lodash.upperfirst@^4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@4.17.21, lodash@^4.17.12, lodash@^4.17.19, lodash@^4.17.21: +lodash@4.17.21, lodash@^4.17.12, lodash@^4.17.19, lodash@^4.17.21, lodash@^4.17.5: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -8441,6 +8441,13 @@ ts-loader@^9.4.3: semver "^7.3.4" source-map "^0.7.4" +ts-mockito@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/ts-mockito/-/ts-mockito-2.6.1.tgz#bc9ee2619033934e6fad1c4455aca5b5ace34e73" + integrity sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw== + dependencies: + lodash "^4.17.5" + ts-node@^10.9.1: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" From 6a83c552d3e17e7bff42ac326868de65cfcae252 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sat, 30 Nov 2024 23:33:55 +0900 Subject: [PATCH 155/223] =?UTF-8?q?=F0=9F=9A=9A=20chore:=20eslint=20spec?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=2025=20=EC=A0=9C=ED=95=9C=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index 9cd28244..574804f1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,4 +69,12 @@ module.exports = { }, ], }, + overrides: [ + { + files: ['**/*.spec.ts'], + rules: { + 'max-lines-per-function': 'off', + }, + }, + ], }; From 4298d292f50873d3abfdb578eb74b66a23a9150a Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 12:29:03 +0900 Subject: [PATCH 156/223] =?UTF-8?q?=E2=9C=85=20test:=20stock=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20mockito=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stock.service.spec.ts | 402 ++++++------------ 1 file changed, 121 insertions(+), 281 deletions(-) diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 36e4d77e..57b90374 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -1,69 +1,106 @@ -/* eslint-disable max-lines-per-function */ -import { instanceToPlain } from 'class-transformer'; -import { DataSource } from 'typeorm'; +import { plainToInstance } from 'class-transformer'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; +import { + DataSource, + EntityManager, + Repository, + SelectQueryBuilder, +} from 'typeorm'; import { Logger } from 'winston'; import { Stock } from './domain/stock.entity'; import { StockService } from './stock.service'; -import { createDataSourceMock } from '@/user/user.service.spec'; - -const logger: Logger = { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), -} as unknown as Logger; +import { UserStock } from '@/stock/domain/userStock.entity'; +import { StockRankResponses, StocksResponse } from '@/stock/dto/stock.response'; +import { User } from '@/user/domain/user.entity'; describe('StockService 테스트', () => { const stockId = 'A005930'; const userId = 1; + const result = [ + { + 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', + }, + ]; + let logger: Logger; + let mockDataSource: DataSource; + let mockManager: EntityManager; + let stockService: StockService; + let queryBuilderMock: SelectQueryBuilder; + let repositoryMock: Repository; + + beforeEach(() => { + mockDataSource = mock(DataSource); + logger = mock(Logger); + mockManager = mock(EntityManager); + queryBuilderMock = mock(SelectQueryBuilder); + stockService = new StockService(instance(mockDataSource), logger); + repositoryMock = mock(Repository); + when(mockDataSource.transaction(anything())).thenCall(async (callback) => { + return await callback(instance(mockManager)); + }); + when(queryBuilderMock.leftJoin(anything(), anything(), anything())) + .thenReturn(instance(queryBuilderMock)) + .thenReturn(instance(queryBuilderMock)); + when(queryBuilderMock.select(anything())).thenReturn( + instance(queryBuilderMock), + ); + when(queryBuilderMock.orderBy(anything(), anything())).thenReturn( + instance(queryBuilderMock), + ); + when(queryBuilderMock.limit(anything())).thenReturn( + instance(queryBuilderMock), + ); + when(repositoryMock.createQueryBuilder(anyString())).thenReturn( + instance(queryBuilderMock), + ); + when(mockDataSource.getRepository(anything())).thenReturn( + instance(repositoryMock), + ); + }); test('주식의 조회수를 증가시킨다.', async () => { - const managerMock = { - exists: jest.fn().mockResolvedValue(true), - increment: jest.fn().mockResolvedValue({ id: stockId, views: 1 }), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.exists(Stock, anything())).thenResolve(true); await stockService.increaseView(stockId); - expect(dataSource.transaction).toHaveBeenCalled(); + verify(mockManager.exists(Stock, anything())).once(); + verify(mockManager.increment(Stock, anything(), 'views', 1)).once(); }); test('존재하지 않는 주식의 조회수를 증가시키려 하면 예외가 발생한다.', async () => { - const managerMock = { - exists: jest.fn().mockResolvedValue(false), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.exists(Stock, anything())).thenResolve(false); - await expect(async () => stockService.increaseView('1')).rejects.toThrow( + await expect(() => stockService.increaseView('1')).rejects.toThrow( 'stock not found', ); }); test('유저 주식을 추가한다.', async () => { - const managerMock = { - exists: jest - .fn() - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false), - insert: jest.fn(), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.exists(Stock, anything())).thenResolve(true); + when(mockManager.exists(UserStock, anything())).thenResolve(false); await stockService.createUserStock(userId, stockId); - expect(managerMock.exists).toHaveBeenCalledTimes(2); - expect(managerMock.insert).toHaveBeenCalled(); + verify(mockManager.exists(Stock, anything())).times(1); + verify(mockManager.exists(UserStock, anything())).times(1); + verify(mockManager.insert(UserStock, anything())).once(); }); test('유저 주식을 추가할 때 존재하지 않는 주식이면 예외가 발생한다.', async () => { - const managerMock = { - exists: jest.fn().mockResolvedValue(false), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.exists(Stock, anything())).thenResolve(false); await expect(() => stockService.createUserStock(userId, 'A'), @@ -71,11 +108,8 @@ describe('StockService 테스트', () => { }); test('유저 주식을 추가할 때 이미 존재하는 유저 주식이면 예외가 발생한다.', async () => { - const managerMock = { - exists: jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(true), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.exists(Stock, anything())).thenResolve(true); + when(mockManager.exists(UserStock, anything())).thenResolve(true); await expect(async () => stockService.createUserStock(userId, stockId), @@ -83,38 +117,41 @@ describe('StockService 테스트', () => { }); test('유저 주식을 삭제한다.', async () => { - const managerMock = { - findOne: jest.fn().mockResolvedValue({ user: { id: userId } }), - delete: jest.fn(), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.findOne(UserStock, anything())).thenResolve({ + id: 1, + user: { id: userId } as User, + stock: { id: stockId } as Stock, + date: { + createdAt: new Date(), + updatedAt: new Date(), + }, + }); await stockService.deleteUserStock(userId, stockId); - expect(managerMock.findOne).toHaveBeenCalled(); - expect(managerMock.delete).toHaveBeenCalled(); + verify(mockManager.findOne(UserStock, anything())).once(); + verify(mockManager.delete(UserStock, anything())).once(); }); test('유저 주식을 삭제 시 존재하지 않는 유저 주식이면 예외가 발생한다.', async () => { - const managerMock = { - findOne: jest.fn().mockResolvedValue(null), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); - - await expect(() => stockService.deleteUserStock(userId, "13")).rejects.toThrow( - 'user stock not found', - ); + when(mockManager.findOne(UserStock, anything())).thenResolve(null); + + await expect(() => + stockService.deleteUserStock(userId, '13'), + ).rejects.toThrow('user stock not found'); }); test('유저 주식을 삭제 시 주인이 아닐 때 예외가 발생한다.', async () => { const notOwnerUserId = 2; - const managerMock = { - findOne: jest.fn().mockResolvedValue({ user: { id: userId } }), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(mockManager.findOne(UserStock, anything())).thenResolve({ + id: 1, + user: { id: userId } as User, + stock: { id: stockId } as Stock, + date: { + createdAt: new Date(), + updatedAt: new Date(), + }, + }); await expect(() => stockService.deleteUserStock(notOwnerUserId, stockId), @@ -122,22 +159,13 @@ describe('StockService 테스트', () => { }); test('소유 주식인지 확인한다.', async () => { - const managerMock = { - exists: jest.fn().mockResolvedValue(true), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); - + when(mockManager.exists(UserStock, anything())).thenResolve(true); const result = await stockService.isUserStockOwner(stockId, userId); expect(result).toBe(true); - expect(managerMock.exists).toHaveBeenCalled(); }); test('인증된 유저가 아니면 소유 주식은 항상 false를 반환한다.', async () => { - const dataSource = createDataSourceMock({}); - const stockService = new StockService(dataSource as DataSource, logger); - const result = await stockService.isUserStockOwner(stockId); expect(result).toBe(false); @@ -145,224 +173,36 @@ describe('StockService 테스트', () => { 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', + + when(queryBuilderMock.getRawMany()).thenResolve(result); + + const queryResult = await stockService.getTopStocksByViews(limit); + + expect(queryResult).toStrictEqual( + plainToInstance(StocksResponse, queryResult), ); - 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', - }, - ]); + verify(queryBuilderMock.getRawMany()).once(); }); 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', + when( + queryBuilderMock.innerJoinAndSelect(anyString(), anyString()), + ).thenReturn(instance(queryBuilderMock)); + when(queryBuilderMock.where(anyString(), anything())).thenReturn( + instance(queryBuilderMock), ); - 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', - }, - ]); - }); - - test('소유 주식인지 확인한다.', async () => { - const managerMock = { - exists: jest.fn().mockResolvedValue(true), - }; - const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource, logger); + when(queryBuilderMock.getRawMany()).thenResolve(result); + const queryResult = await stockService.getTopStocksByGainers(limit); - const result = await stockService.isUserStockOwner(stockId, userId); + verify(queryBuilderMock.getRawMany()).once(); - expect(result).toBe(true); - expect(managerMock.exists).toHaveBeenCalled(); + expect(queryResult).toEqual(new StockRankResponses(result)); }); test('인증된 유저가 아니면 소유 주식은 항상 false를 반환한다.', async () => { - const dataSource = createDataSourceMock({}); - const stockService = new StockService(dataSource as DataSource, logger); - const result = await stockService.isUserStockOwner(stockId); expect(result).toBe(false); }); - - 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: 'A051910', - name: 'LG화학', - currentPrice: '75000.0', - changeRate: '-1.2', - volume: '300000', - marketCap: '20000000000.00', - }, - { - id: 'A005930', - name: '삼성전자', - currentPrice: '100000.0', - changeRate: '2.5', - volume: '500000', - marketCap: '500000000000.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.getTopStocksByLosers(limit); - - expect(managerMock.getRepository).toHaveBeenCalledWith(Stock); - expect(queryBuilderMock.orderBy).toHaveBeenCalledWith( - 'stockLiveData.changeRate', - 'ASC', - ); - expect(queryBuilderMock.limit).toHaveBeenCalledWith(limit); - expect(queryBuilderMock.getRawMany).toHaveBeenCalled(); - - expect(instanceToPlain(result)).toEqual([ - { - id: 'A051910', - name: 'LG화학', - currentPrice: 75000.0, - changeRate: -1.2, - volume: 300000, - marketCap: '20000000000.00', - }, - { - id: 'A005930', - name: '삼성전자', - currentPrice: 100000.0, - changeRate: 2.5, - volume: 500000, - marketCap: '500000000000.00', - }, - ]); - }); }); From 19f009e3321e7f04f40463c78fdc80bec1af8439 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 13:02:07 +0900 Subject: [PATCH 157/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stockDetail=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=EC=97=90=20=EB=98=90=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/stockDetail.service.spec.ts | 101 +++++++++--------- .../backend/src/stock/stockDetail.service.ts | 4 +- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/packages/backend/src/stock/stockDetail.service.spec.ts b/packages/backend/src/stock/stockDetail.service.spec.ts index 403b8754..43d8785d 100644 --- a/packages/backend/src/stock/stockDetail.service.spec.ts +++ b/packages/backend/src/stock/stockDetail.service.spec.ts @@ -1,75 +1,80 @@ import { NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; +import { + DataSource, + EntityManager, + Repository, + SelectQueryBuilder, +} 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; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockDetailResponse } from '@/stock/dto/stockDetail.response'; describe('StockDetailService 테스트', () => { const stockId = 'A005930'; + let stockDetailService: StockDetailService; + let logger: Logger; + let dataSourceMock: DataSource; + let managerMock: EntityManager; + let repositoryMock: Repository; + let queryBuilderMock: SelectQueryBuilder; + + beforeEach(() => { + dataSourceMock = mock(DataSource); + logger = mock(Logger); + managerMock = mock(EntityManager); + repositoryMock = mock(Repository); + queryBuilderMock = mock(SelectQueryBuilder); + stockDetailService = new StockDetailService( + instance(dataSourceMock), + instance(logger), + ); + when(dataSourceMock.transaction(anything())).thenCall(async (callback) => { + return await callback(instance(managerMock)); + }); + }); test('stockId로 주식 상세 정보를 조회한다.', async () => { - const mockStockDetail = { - stock: { id: stockId }, - marketCap: 352510000000000, + const data = { + id: 1, + stock: { id: stockId } as Stock, + marketCap: String(352510000000000), eps: 4091, per: 17.51, high52w: 88000, low52w: 53000, + updatedAt: new Date(), }; - const managerMock = { - existsBy: jest.fn().mockResolvedValue(true), - findBy: jest.fn().mockResolvedValue([mockStockDetail]), - }; - const dataSource = createDataSourceMock(managerMock); - const stockDetailService = new StockDetailService( - dataSource as DataSource, - logger, + when(managerMock.existsBy(StockDetail, anything())).thenResolve(true); + when(managerMock.getRepository(StockDetail)).thenReturn( + instance(repositoryMock), + ); + when(repositoryMock.createQueryBuilder(anything())).thenReturn( + instance(queryBuilderMock), + ); + when(queryBuilderMock.where(anyString(), anything())).thenReturn( + instance(queryBuilderMock), ); + when( + queryBuilderMock.leftJoinAndSelect(anyString(), anyString()), + ).thenReturn(instance(queryBuilderMock)); + when(queryBuilderMock.getOne()).thenResolve(data); 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); + verify(managerMock.existsBy(StockDetail, anything())).once(); + verify(queryBuilderMock.getOne()).once(); + + expect(result).toEqual(new StockDetailResponse(data)); }); test('존재하지 않는 stockId로 조회 시 예외를 발생시킨다.', async () => { - const managerMock = { - existsBy: jest.fn().mockResolvedValue(false), - }; - const dataSource = createDataSourceMock(managerMock); - const stockDetailService = new StockDetailService( - dataSource as DataSource, - logger, - ); + when(managerMock.existsBy(StockDetail, anything())).thenResolve(false); 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 index 828a40ee..9f2a0ac5 100644 --- a/packages/backend/src/stock/stockDetail.service.ts +++ b/packages/backend/src/stock/stockDetail.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; import { StockDetail } from './domain/stockDetail.entity'; @@ -24,7 +24,7 @@ export class StockDetailService { ); } - const result = await this.datasource.manager + const result = await manager .getRepository(StockDetail) .createQueryBuilder('stockDetail') .leftJoinAndSelect('stockDetail.stock', 'stock') From e736514febab5e0fd85c07409b4f91a6b2b2f99c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 13:11:49 +0900 Subject: [PATCH 158/223] =?UTF-8?q?=E2=9C=85=20test:=20koreaStockInfoServi?= =?UTF-8?q?ce=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korea-stock-info/korea-stock-info.service.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts index c8197ee4..436da928 100644 --- a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts +++ b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts @@ -1,11 +1,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { KoreaStockInfoService } from './korea-stock-info.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Stock } from '@/stock/domain/stock.entity'; +import { WinstonModule } from 'nest-winston'; +import { logger } from '@/configs/logger.config'; -describe('KoreaStockInfoService', () => { +xdescribe('KoreaStockInfoService', () => { let service: KoreaStockInfoService; + // 모듈을 사용하려면 직접 DB에 연결해야함 beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forFeature([Stock]), + WinstonModule.forRoot(logger), + ], providers: [KoreaStockInfoService], }).compile(); From 46a34efe9112fbc2c3615a85aa5a2bbe947ba8af Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 13:34:55 +0900 Subject: [PATCH 159/223] =?UTF-8?q?=E2=9C=85=20test:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20mockito=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/chat/like.service.spec.ts | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/chat/like.service.spec.ts b/packages/backend/src/chat/like.service.spec.ts index 5df6fed8..e2bda605 100644 --- a/packages/backend/src/chat/like.service.spec.ts +++ b/packages/backend/src/chat/like.service.spec.ts @@ -1,18 +1,19 @@ -import { DataSource } from 'typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; import { Like } from '@/chat/domain/like.entity'; import { LikeService } from '@/chat/like.service'; -import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; -import { createDataSourceMock } from '@/user/user.service.spec'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Stock } from '@/stock/domain/stock.entity'; function createChat(): Chat { return { - stock: new Stock(), + stock: { id: '005930', name: '삼성전자' } as Stock, user: new User(), id: 1, likeCount: 1, message: '안녕하세요', + mentions: [], type: 'NORMAL', date: { createdAt: new Date(), @@ -22,27 +23,36 @@ function createChat(): Chat { } describe('LikeService 테스트', () => { - test('존재하지 않는 채팅을 좋아요를 시도하면 예외가 발생한다.', () => { - const managerMock = { - findOne: jest.fn().mockResolvedValue(null), - }; - const datasource = createDataSourceMock(managerMock); - const likeService = new LikeService(datasource as DataSource); + let likeService: LikeService; + let datasourceMock: DataSource; + let managerMock: EntityManager; + + test('존재하지 않는 채팅을 좋아요를 시도하면 예외가 발생한다.', async () => { + datasourceMock = mock(DataSource); + managerMock = mock(EntityManager); + when(managerMock.findOne(Chat, anything())).thenResolve(null); + when(datasourceMock.transaction(anything())).thenCall(async (callback) => { + return await callback(instance(managerMock)); + }); + + likeService = new LikeService(instance(datasourceMock)); - expect(likeService.toggleLike(1, 1)).rejects.toThrow('Chat not found'); + await expect(() => likeService.toggleLike(1, 1)).rejects.toThrow( + 'Chat not found', + ); }); test('특정 채팅에 좋아요를 한다.', async () => { const chat = createChat(); - const managerMock = { - findOne: jest - .fn() - .mockResolvedValueOnce(chat) - .mockResolvedValueOnce(null), - save: jest.fn(), - }; - const datasource = createDataSourceMock(managerMock); - const likeService = new LikeService(datasource as DataSource); + datasourceMock = mock(DataSource); + managerMock = mock(EntityManager); + when(managerMock.findOne(Chat, anything())) + .thenResolve(chat) + .thenResolve(null); + when(datasourceMock.transaction(anything())).thenCall(async (callback) => { + return await callback(instance(managerMock)); + }); + likeService = new LikeService(instance(datasourceMock)); const response = await likeService.toggleLike(1, 1); @@ -51,15 +61,14 @@ describe('LikeService 테스트', () => { test('특정 채팅에 좋아요를 취소한다.', async () => { const chat = createChat(); - const managerMock = { - findOne: jest - .fn() - .mockResolvedValueOnce(chat) - .mockResolvedValueOnce(new Like()), - remove: jest.fn(), - }; - const datasource = createDataSourceMock(managerMock); - const likeService = new LikeService(datasource as DataSource); + datasourceMock = mock(DataSource); + managerMock = mock(EntityManager); + when(managerMock.findOne(Chat, anything())).thenResolve(chat); + when(managerMock.findOne(Like, anything())).thenResolve(new Like()); + when(datasourceMock.transaction(anything())).thenCall(async (callback) => { + return await callback(instance(managerMock)); + }); + likeService = new LikeService(instance(datasourceMock)); const response = await likeService.toggleLike(1, 1); From 683096e9ffbb0848ae19bead849ccf21d5bc39cc Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 13:37:05 +0900 Subject: [PATCH 160/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20eslint=20import?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=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/chat/like.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/chat/like.service.spec.ts b/packages/backend/src/chat/like.service.spec.ts index e2bda605..30a76b2c 100644 --- a/packages/backend/src/chat/like.service.spec.ts +++ b/packages/backend/src/chat/like.service.spec.ts @@ -1,10 +1,10 @@ +import { anything, instance, mock, when } from 'ts-mockito'; import { DataSource, EntityManager } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; import { Like } from '@/chat/domain/like.entity'; import { LikeService } from '@/chat/like.service'; -import { User } from '@/user/domain/user.entity'; -import { anything, instance, mock, when } from 'ts-mockito'; import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; function createChat(): Chat { return { From 9441fb4d70fa7a592bd0b8bf547192f1e943d460 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 13:47:04 +0900 Subject: [PATCH 161/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B2=B0=EA=B3=BC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?id=20=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/user/dto/user.response.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/backend/src/user/dto/user.response.ts b/packages/backend/src/user/dto/user.response.ts index f0f1e20a..e348b968 100644 --- a/packages/backend/src/user/dto/user.response.ts +++ b/packages/backend/src/user/dto/user.response.ts @@ -3,7 +3,6 @@ import { User } from '@/user/domain/user.entity'; import { OauthType } from '@/user/domain/ouathType'; interface UserResponse { - id: number; nickname: string; subName: string; createdAt: Date; @@ -14,7 +13,6 @@ export class UserSearchResult { description: '유저 검색 결과', example: [ { - id: 1, nickname: 'nickname', subName: 'subName', createdAt: new Date(), @@ -25,7 +23,6 @@ export class UserSearchResult { constructor(users: User[]) { this.result = users.map((user) => ({ - id: user.id, nickname: user.nickname, subName: user.subName, createdAt: user.date.createdAt, From ef443a9f5eae1969e7a703bb607f6060337cebbd Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 14:05:36 +0900 Subject: [PATCH 162/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A9=98=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EB=8B=89=EB=84=A4=EC=9E=84=EA=B3=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=EB=84=A4=EC=9E=84=EC=9C=BC=EB=A1=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=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/chat/chat.gateway.ts | 20 +++++++++++++++---- packages/backend/src/chat/chat.module.ts | 2 ++ packages/backend/src/chat/dto/chat.request.ts | 3 ++- packages/backend/src/user/user.service.ts | 9 +++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index cabd0046..521b647c 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -29,6 +29,7 @@ import { MentionService } from '@/chat/mention.service'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; import { User } from '@/user/domain/user.entity'; +import { UserService } from '@/user/user.service'; @WebSocketGateway({ namespace: '/api/chat/realtime' }) @UseFilters(WebSocketExceptionFilter) @@ -43,6 +44,7 @@ export class ChatGateway implements OnGatewayConnection { private readonly stockService: StockService, private readonly chatService: ChatService, private readonly mentionService: MentionService, + private readonly UserService: UserService, @Inject(MEMORY_STORE) sessionStore: MemoryStore, ) { this.websocketSessionService = new WebsocketSessionService(sessionStore); @@ -54,7 +56,7 @@ export class ChatGateway implements OnGatewayConnection { @MessageBody() message: ChatMessage, @ConnectedSocket() client: SessionSocket, ) { - const { room, content, mention } = message; + const { room, content, nickname, subName } = message; if (!client.rooms.has(room)) { client.emit('error', 'You are not in the room'); this.logger.warn(`client is not in the room ${room}`); @@ -69,9 +71,19 @@ export class ChatGateway implements OnGatewayConnection { stockId: room, message: content, }); - if (mention) { - await this.mentionService.createMention(savedChat.id, mention); - const mentionedSocket = this.users.get(Number(mention)); + if (nickname && subName) { + const mentionedUser = + await this.UserService.searchOneUserByNicknameAndSubName( + nickname, + subName, + ); + if (!mentionedUser) { + const chatResponse = this.toResponse(savedChat, client.session); + this.server.to(room).emit('chat', chatResponse); + return; + } + await this.mentionService.createMention(savedChat.id, mentionedUser.id); + const mentionedSocket = this.users.get(Number(mentionedUser.id)); if (mentionedSocket) { const chatResponse = this.toResponse(savedChat, client.session); this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index b58dc484..84cb2b70 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -10,12 +10,14 @@ import { Mention } from '@/chat/domain/mention.entity'; import { LikeService } from '@/chat/like.service'; import { MentionService } from '@/chat/mention.service'; import { StockModule } from '@/stock/stock.module'; +import { UserModule } from '@/user/user.module'; @Module({ imports: [ TypeOrmModule.forFeature([Chat, Like, Mention]), StockModule, SessionModule, + UserModule, ], controllers: [ChatController], providers: [ChatGateway, ChatService, LikeService, MentionService], diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index 3d970fdd..56b18536 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -48,5 +48,6 @@ export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { export interface ChatMessage { room: string; content: string; - mention?: number; + nickname: string; + subName: string; } diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index a70ccbbc..ace5f7f7 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -45,6 +45,15 @@ export class UserService { return new UserSearchResult(users); } + async searchOneUserByNicknameAndSubName(nickname: string, subName?: string) { + return await this.dataSource.manager.findOne(User, { + where: { + nickname, + subName, + }, + }); + } + async createSubName(nickname: string) { return this.dataSource.transaction(async (manager) => { if (!(await this.existsUserByNickname(nickname, manager))) { From 40bc4615dde56d082f9a589089673cfbaa6dfc6a Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 14:12:11 +0900 Subject: [PATCH 163/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EB=90=9C=20=EC=9C=A0=EC=A0=80=EA=B0=80=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=B4=20=EB=81=8A=EA=B8=B0=EB=A9=B4,=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.gateway.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 521b647c..bf48c54a 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -3,6 +3,7 @@ import { ConnectedSocket, MessageBody, OnGatewayConnection, + OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer, @@ -33,7 +34,7 @@ import { UserService } from '@/user/user.service'; @WebSocketGateway({ namespace: '/api/chat/realtime' }) @UseFilters(WebSocketExceptionFilter) -export class ChatGateway implements OnGatewayConnection { +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() private server: Server; private websocketSessionService: WebsocketSessionService; @@ -122,6 +123,14 @@ export class ChatGateway implements OnGatewayConnection { } } + async handleDisconnect(client: Socket) { + const user = + await this.websocketSessionService.getAuthenticatedUser(client); + if (user) { + this.users.delete(user.id); + } + } + private async scrollChat( stockId: string, user: User | null, From c5240b2336da05918d3de4d1d9d18f8ee053c978 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 14:34:55 +0900 Subject: [PATCH 164/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EA=B8=B0=EB=8A=A5=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B8=B0=EB=8A=A5=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.gateway.ts | 124 +++++++++++++++------- 1 file changed, 87 insertions(+), 37 deletions(-) diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index bf48c54a..951b3365 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -53,49 +53,24 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @UseGuards(WebSocketSessionGuard) @SubscribeMessage('chat') - async handleConnectStock( + async handleConnectChat( @MessageBody() message: ChatMessage, @ConnectedSocket() client: SessionSocket, ) { const { room, content, nickname, subName } = message; - if (!client.rooms.has(room)) { - client.emit('error', 'You are not in the room'); - this.logger.warn(`client is not in the room ${room}`); - return; - } - if (!client.session || !client.session.id) { - client.emit('error', 'Invalid session'); - this.logger.warn('client session is invalid'); - return; - } - const savedChat = await this.chatService.saveChat(client.session.id, { - stockId: room, - message: content, - }); + if (!this.isClientInRoom(client, room)) return; + if (!this.isValidSession(client)) return; + const savedChat = await this.saveChat(client.session.id, room, content); if (nickname && subName) { - const mentionedUser = - await this.UserService.searchOneUserByNicknameAndSubName( - nickname, - subName, - ); - if (!mentionedUser) { - const chatResponse = this.toResponse(savedChat, client.session); - this.server.to(room).emit('chat', chatResponse); - return; - } - await this.mentionService.createMention(savedChat.id, mentionedUser.id); - const mentionedSocket = this.users.get(Number(mentionedUser.id)); - if (mentionedSocket) { - const chatResponse = this.toResponse(savedChat, client.session); - this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); - chatResponse.mentioned = true; - this.server.to(mentionedSocket).emit('chat', chatResponse); - return; - } + return await this.mentionUser( + nickname, + subName, + savedChat, + client.session, + room, + ); } - this.server - .to(room) - .emit('chat', this.toResponse(savedChat, client.session)); + this.broadcastChat(savedChat, room, client.session); } async broadcastLike(response: LikeResponse) { @@ -131,6 +106,81 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { } } + private isValidSession( + client: SessionSocket, + ): client is SessionSocket & { session: User } { + if (!client.session || !client.session.id) { + client.emit('error', 'Invalid session'); + this.logger.warn('client session is invalid'); + return false; + } + return true; + } + + private isClientInRoom(client: SessionSocket, room: string): boolean { + if (!client.rooms.has(room)) { + client.emit('error', 'You are not in the room'); + this.logger.warn(`client is not in the room ${room}`); + return false; + } + return true; + } + + private async saveChat(userId: number, room: string, content: string) { + return await this.chatService.saveChat(userId, { + stockId: room, + message: content, + }); + } + + private async broadcastMentionChat( + chat: Chat, + room: string, + mentionedSocket: string, + client: User, + ) { + const chatResponse = this.toResponse(chat, client); + this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); + chatResponse.mentioned = true; + this.server.to(mentionedSocket).emit('chat', chatResponse); + } + + private async mentionUser( + nickname: string, + subName: string, + savedChat: Chat, + client: User, + room: string, + ) { + const mentionedUser = await this.searchMentionedUser(nickname, subName); + if (!mentionedUser) { + return this.broadcastChat(savedChat, room, client); + } + await this.mentionService.createMention(savedChat.id, mentionedUser.id); + const mentionedSocket = this.users.get(Number(mentionedUser.id)); + if (mentionedSocket) { + return await this.broadcastMentionChat( + savedChat, + room, + mentionedSocket, + client, + ); + } + } + + private async searchMentionedUser(nickname: string, subName: string) { + return await this.UserService.searchOneUserByNicknameAndSubName( + nickname, + subName, + ); + } + + private broadcastChat(chat: Chat, room: string, client: User) { + const chatResponse = this.toResponse(chat, client); + this.server.to(room).emit('chat', chatResponse); + return; + } + private async scrollChat( stockId: string, user: User | null, From 0f7f58f8f8899714bd5af13402e957295cadd4fb Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 14:45:01 +0900 Subject: [PATCH 165/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=85=8C=EB=A7=88?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=BF=A0=ED=82=A4=EB=A5=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/user/dto/userTheme.response.ts | 5 +-- packages/backend/src/user/user.controller.ts | 31 +++++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/user/dto/userTheme.response.ts b/packages/backend/src/user/dto/userTheme.response.ts index 71869900..ca6e9533 100644 --- a/packages/backend/src/user/dto/userTheme.response.ts +++ b/packages/backend/src/user/dto/userTheme.response.ts @@ -1,10 +1,7 @@ import { Transform } from 'class-transformer'; -import { IsInt, IsString, IsBoolean, IsDateString } from 'class-validator'; +import { IsBoolean, IsDateString, IsString } from 'class-validator'; export class UpdateUserThemeResponse { - @IsInt() - id: number; - @IsString() nickname: string; diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index d5769d95..b0d111d1 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -5,7 +5,6 @@ import { Get, HttpCode, HttpStatus, - Param, Patch, Post, Query, @@ -13,9 +12,9 @@ import { } from '@nestjs/common'; import { ApiBody, + ApiCookieAuth, ApiOkResponse, ApiOperation, - ApiParam, ApiQuery, ApiResponse, } from '@nestjs/swagger'; @@ -85,13 +84,13 @@ export class UserController { return { message: '닉네임 변경 완료', date: new Date() }; } - @Patch(':id/theme') + @Patch('theme') @HttpCode(HttpStatus.OK) + @ApiCookieAuth() @ApiOperation({ summary: '유저 테마 변경 API', description: '유저 테마를 라이트모드인지 다크모드인지 변경합니다.', }) - @ApiParam({ name: 'id', type: Number, description: 'User ID' }) @ApiBody({ schema: { type: 'object', @@ -107,35 +106,41 @@ export class UserController { }) @ApiResponse({ status: 200, description: 'User theme updated successfully' }) @ApiResponse({ status: 400, description: 'isLight property is required' }) - @ApiResponse({ status: 404, description: 'User not found' }) + @ApiResponse({ status: 403, description: 'Forbidden access to update theme' }) async updateTheme( - @Param('id') id: number, + @Req() request: Request, @Body('isLight') isLight?: boolean, ): Promise { + if (!request.user) { + throw new ForbiddenException('Forbidden access to update theme'); + } + const id = (request.user as User).id; const updatedUser = await this.userService.updateUserTheme(id, isLight); return { - id: updatedUser.id!, - isLight: updatedUser.isLight!, - nickname: updatedUser.nickname!, - updatedAt: updatedUser.date!.updatedAt!, + isLight: updatedUser.isLight, + nickname: updatedUser.nickname, + updatedAt: updatedUser.date.updatedAt, }; } - @Get(':id/theme') + @Get('theme') @ApiOperation({ summary: 'Get user theme mode', description: 'Retrieve the current theme mode (light or dark) for a specific user', }) - @ApiParam({ name: 'id', type: Number, description: 'User ID' }) @ApiResponse({ status: 200, description: 'User theme retrieved successfully', schema: { type: 'boolean' }, }) @ApiResponse({ status: 404, description: 'User not found' }) - async getTheme(@Param('id') id: number) { + async getTheme(@Req() request: Request) { + if (!request.user) { + return { isLight: true }; + } + const id = (request.user as User).id; const isLight = await this.userService.getUserTheme(id); return { isLight }; } From b6c3a81375d8bb6f3cf36aecc3fa5eb37c7612e1 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 15:03:43 +0900 Subject: [PATCH 166/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=ED=85=8C=EB=A7=88=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20light,=20?= =?UTF-8?q?dark=EB=A1=9C=20=EB=82=98=ED=83=80=EB=82=B4=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=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/user/domain/theme.ts | 6 +++ packages/backend/src/user/dto/user.request.ts | 22 ++++++++++- .../src/user/dto/userTheme.response.ts | 20 +++++----- packages/backend/src/user/user.controller.ts | 38 +++++++++---------- 4 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 packages/backend/src/user/domain/theme.ts diff --git a/packages/backend/src/user/domain/theme.ts b/packages/backend/src/user/domain/theme.ts new file mode 100644 index 00000000..2370623f --- /dev/null +++ b/packages/backend/src/user/domain/theme.ts @@ -0,0 +1,6 @@ +export const Theme = { + light: 'light', + dark: 'dark', +}; + +export type Theme = typeof Theme[keyof typeof Theme]; diff --git a/packages/backend/src/user/dto/user.request.ts b/packages/backend/src/user/dto/user.request.ts index fde10d87..2d382496 100644 --- a/packages/backend/src/user/dto/user.request.ts +++ b/packages/backend/src/user/dto/user.request.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { Theme } from '@/user/domain/theme'; export class ChangeNicknameRequest { @ApiProperty({ @@ -10,3 +11,22 @@ export class ChangeNicknameRequest { @IsNotEmpty() nickname: string; } + +export class ChangeThemeRequest { + @ApiProperty({ + description: '변경을 원하는 테마', + example: 'light', + enum: ['light', 'dark'], + }) + @IsNotEmpty() + @IsEnum(Theme) + theme: Theme; +} + +export class UserThemeResponse { + @ApiProperty({ + description: '유저 테마', + example: 'light', + }) + theme: Theme; +} diff --git a/packages/backend/src/user/dto/userTheme.response.ts b/packages/backend/src/user/dto/userTheme.response.ts index ca6e9533..46d4d95a 100644 --- a/packages/backend/src/user/dto/userTheme.response.ts +++ b/packages/backend/src/user/dto/userTheme.response.ts @@ -1,14 +1,16 @@ -import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Theme } from '@/user/domain/theme'; export class UpdateUserThemeResponse { - @IsString() - nickname: string; + @ApiProperty({ + description: '유저 테마', + example: 'light', + }) + theme: Theme; - @IsBoolean() - isLight: boolean; - - @IsDateString() - @Transform(({ value }) => value.toISOString()) + @ApiProperty({ + description: '테마 변경 시간', + example: new Date(), + }) updatedAt: Date; } diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index b0d111d1..df42b338 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -22,7 +22,11 @@ import { Request } from 'express'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; import { UserService } from './user.service'; import { User } from '@/user/domain/user.entity'; -import { ChangeNicknameRequest } from '@/user/dto/user.request'; +import { + ChangeNicknameRequest, + ChangeThemeRequest, + UserThemeResponse, +} from '@/user/dto/user.request'; @Controller('user') export class UserController { @@ -92,34 +96,28 @@ export class UserController { description: '유저 테마를 라이트모드인지 다크모드인지 변경합니다.', }) @ApiBody({ - schema: { - type: 'object', - properties: { - isLight: { - type: 'boolean', - description: 'true: light mode, false: dark mode', - example: true, - }, - }, - required: ['isLight'], - }, + type: ChangeThemeRequest, }) - @ApiResponse({ status: 200, description: 'User theme updated successfully' }) @ApiResponse({ status: 400, description: 'isLight property is required' }) @ApiResponse({ status: 403, description: 'Forbidden access to update theme' }) + @ApiResponse({ + status: 200, + description: 'User theme updated successfully', + type: UpdateUserThemeResponse, + }) async updateTheme( @Req() request: Request, - @Body('isLight') isLight?: boolean, + @Body() changeThemeRequest: ChangeThemeRequest, ): Promise { if (!request.user) { throw new ForbiddenException('Forbidden access to update theme'); } const id = (request.user as User).id; + const isLight = changeThemeRequest.theme === 'light'; const updatedUser = await this.userService.updateUserTheme(id, isLight); return { - isLight: updatedUser.isLight, - nickname: updatedUser.nickname, + theme: updatedUser.isLight ? 'light' : 'dark', updatedAt: updatedUser.date.updatedAt, }; } @@ -133,15 +131,15 @@ export class UserController { @ApiResponse({ status: 200, description: 'User theme retrieved successfully', - schema: { type: 'boolean' }, + type: UserThemeResponse, }) @ApiResponse({ status: 404, description: 'User not found' }) - async getTheme(@Req() request: Request) { + async getTheme(@Req() request: Request): Promise { if (!request.user) { - return { isLight: true }; + return { theme: 'light' }; } const id = (request.user as User).id; const isLight = await this.userService.getUserTheme(id); - return { isLight }; + return { theme: isLight ? 'light' : 'dark' }; } } From a2c4be959934de0393e0e944d34ec54a491211dd Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Sun, 1 Dec 2024 21:35:06 +0900 Subject: [PATCH 167/223] =?UTF-8?q?Feature/#12=20-=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: 알람 엔티티 추가 및 모듈 뼈대 구현 * ✨ feat: 알람 CRUD 기능 구현 * ✨ feat: 알람 요청 DTO 추가 * ✨ feat: 유저 ID, 주식 ID 기반 알람 리스트 조회 기능 추가 * ✨ feat: subscription 엔티티 추가 * ✨ feat: 알람 매칭 서브스크라이버 추가 * ✨ feat: 웹 푸시 서비스 구현 * 🚚 chore: web-push 의존성 설치 * ✨ feat: 웹 푸시 서비스 구현 * ✨ feat: 푸시 구독 기능 구현 * ♻️ refactor: 의존성 수정 * 💄 style: discribe todo 추가 * ♻️ refactor: token의 경우 global하게 필요 없으므로 데코레이터 삭제 * ♻️ refactor: alarm service와 서비스 의존 해결 * ♻️ refactor: live data 모듈간 의존성 리팩토링 * 💄 style: 불필요한 주석 삭제 * 💄 style: 테스트 코드 삭제 * ✨ feat: push 서비스 완성 및 dto 추가 * 📝 docs: alarm request swagger 추가 * 📝 docs: 알림 swagger 추가 * 🐛 fix: 알람 모듈 수정, swagger 추가 * 🐛 fix: push 모듈 수정 및 swagger 추가 * 🐛 fix: localhost cors에러 해결. 이건... * ✨ feat: subscriber 추가 * 🐛 fix: userId가 저장되지 않는 문제 해결 * 🐛 fix: userId를 명시적으로받지 않음. swagger 보강 * 📝 docs: swagger param 애매한 거 수정 * 📝 docs: live data logger 추가 * 🐛 fix: alarm subscribe 찾지 못해 보내지 못하는 에러 수정 * 📝 docs: alarm response 추가, subscribe response 중 필요 없는 거 삭제 * 📝 docs: swagger message response 추가 * 🐛 fix: subscriber 조건 변경 --------- Co-authored-by: demian-m00n --- packages/backend/package.json | 2 + .../backend/src/alarm/alarm.controller.ts | 145 ++++++++++++++++++ packages/backend/src/alarm/alarm.module.ts | 17 ++ packages/backend/src/alarm/alarm.service.ts | 115 ++++++++++++++ .../backend/src/alarm/alarm.subscriber.ts | 62 ++++++++ .../backend/src/alarm/domain/alarm.entity.ts | 40 +++++ .../src/alarm/domain/subscription.entity.ts | 20 +++ .../backend/src/alarm/dto/alarm.request.ts | 30 ++++ .../backend/src/alarm/dto/alarm.response.ts | 56 +++++++ .../src/alarm/dto/subscribe.request.ts | 26 ++++ .../src/alarm/dto/subscribe.response.ts | 6 + packages/backend/src/alarm/push.controller.ts | 33 ++++ packages/backend/src/alarm/push.service.ts | 66 ++++++++ packages/backend/src/main.ts | 7 +- .../openapi/api/openapiFluctuationData.api.ts | 1 - .../scraper/openapi/api/openapiToken.api.ts | 3 +- .../scraper/openapi/openapi-scraper.module.ts | 3 + .../backend/src/stock/domain/stock.entity.ts | 4 + packages/backend/src/stock/stock.module.ts | 5 +- .../src/stock/stockLiveData.subscriber.ts | 9 +- .../backend/src/user/domain/user.entity.ts | 8 + yarn.lock | 105 +++++++++++-- 22 files changed, 747 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/alarm/alarm.controller.ts create mode 100644 packages/backend/src/alarm/alarm.module.ts create mode 100644 packages/backend/src/alarm/alarm.service.ts create mode 100644 packages/backend/src/alarm/alarm.subscriber.ts create mode 100644 packages/backend/src/alarm/domain/alarm.entity.ts create mode 100644 packages/backend/src/alarm/domain/subscription.entity.ts create mode 100644 packages/backend/src/alarm/dto/alarm.request.ts create mode 100644 packages/backend/src/alarm/dto/alarm.response.ts create mode 100644 packages/backend/src/alarm/dto/subscribe.request.ts create mode 100644 packages/backend/src/alarm/dto/subscribe.response.ts create mode 100644 packages/backend/src/alarm/push.controller.ts create mode 100644 packages/backend/src/alarm/push.service.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index dcc3e21e..e98a13c9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -47,6 +47,7 @@ "socket.io": "^4.8.1", "typeorm": "^0.3.20", "unzipper": "^0.12.3", + "web-push": "^3.6.7", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "ws": "^8.18.0" @@ -63,6 +64,7 @@ "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", "@types/unzipper": "^0.10.10", + "@types/web-push": "^3.6.4", "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/packages/backend/src/alarm/alarm.controller.ts b/packages/backend/src/alarm/alarm.controller.ts new file mode 100644 index 00000000..15dfc480 --- /dev/null +++ b/packages/backend/src/alarm/alarm.controller.ts @@ -0,0 +1,145 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Put, + Delete, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { AlarmService } from './alarm.service'; +import { AlarmRequest } from './dto/alarm.request'; +import { AlarmResponse, AlarmSuccessResponse } from './dto/alarm.response'; +import SessionGuard from '@/auth/session/session.guard'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { User } from '@/user/domain/user.entity'; + +@Controller('alarm') +export class AlarmController { + constructor(private readonly alarmService: AlarmService) {} + + @Post() + @ApiOperation({ + summary: '알림 생성', + description: '각 정보에 맞는 알림을 생성한다.', + }) + @ApiOkResponse({ + description: '알림 생성 완료', + type: AlarmResponse, + }) + @UseGuards(SessionGuard) + async create( + @Body() alarmRequest: AlarmRequest, + @GetUser() user: User, + ): Promise { + const userId = user.id; + + return await this.alarmService.create(alarmRequest, userId); + } + + @Get(':id') + @ApiOperation({ + summary: '등록된 알림 확인', + description: '등록된 알림을 알림 아이디를 기준으로 찾을 수 있다.', + }) + @ApiOkResponse({ + description: '알림 아이디와 동일한 알림 찾음', + type: AlarmResponse, + }) + @ApiParam({ + name: 'id', + type: Number, + description: '알림 아이디', + example: 1, + }) + @UseGuards(SessionGuard) + async findOne(@Param('id') alarmId: number): Promise { + return this.alarmService.findOne(alarmId); + } + + @Put(':id') + @ApiOperation({ + summary: '등록된 알림 업데이트', + description: '알림 아이디 기준으로 업데이트를 할 수 있다.', + }) + @ApiOkResponse({ + description: '아이디와 동일한 알림 업데이트', + type: AlarmResponse, + }) + @ApiParam({ + name: 'id', + type: Number, + description: '알림 아이디', + example: 1, + }) + @UseGuards(SessionGuard) + async update( + @Param('id') alarmId: number, + @Body() updateData: AlarmRequest, + ): Promise { + return this.alarmService.update(alarmId, updateData); + } + + @Delete(':id') + @ApiParam({ + name: 'id', + type: Number, + description: '알림 아이디', + example: 1, + }) + @ApiOperation({ + summary: '등록된 알림 삭제', + description: '알림 아이디 기준으로 삭제를 할 수 있다.', + }) + @ApiOkResponse({ + description: '아이디와 동일한 알림 삭제', + type: AlarmSuccessResponse, + }) + @UseGuards(SessionGuard) + async delete(@Param('id') alarmId: number) { + await this.alarmService.delete(alarmId); + + return new AlarmSuccessResponse('알림 삭제를 성공했습니다.'); + } + + @Get('user') + @ApiOperation({ + summary: '사용자별 알림 조회', + description: '사용자 아이디를 기준으로 모든 알림을 조회한다.', + }) + @ApiOkResponse({ + description: '사용자에게 등록되어 있는 모든 알림 조회', + type: [AlarmResponse], + }) + @UseGuards(SessionGuard) + async getByUserId(@GetUser() user: User) { + const userId = user.id; + + return await this.alarmService.findByUserId(userId); + } + + @Get('stock/:stockId') + @ApiOperation({ + summary: '주식별 알림 조회', + description: '주식 아이디를 기준으로 알림을 조회한다.', + }) + @ApiOkResponse({ + description: + '주식 아이디에 등록되어 있는 알림 중 유저에 해당하는 알림 조회', + type: [AlarmResponse], + }) + @ApiParam({ + name: 'id', + type: String, + description: '주식 아이디', + example: '005930', + }) + @UseGuards(SessionGuard) + async getByStockId(@Param('stockId') stockId: string, @GetUser() user: User) { + const userId = user.id; + + return await this.alarmService.findByStockId(stockId, userId); + } +} diff --git a/packages/backend/src/alarm/alarm.module.ts b/packages/backend/src/alarm/alarm.module.ts new file mode 100644 index 00000000..c283febc --- /dev/null +++ b/packages/backend/src/alarm/alarm.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AlarmController } from './alarm.controller'; +import { AlarmService } from './alarm.service'; +import { AlarmSubscriber } from './alarm.subscriber'; +import { Alarm } from './domain/alarm.entity'; +import { PushSubscription } from './domain/subscription.entity'; +import { PushController } from './push.controller'; +import { PushService } from './push.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Alarm, PushSubscription])], + controllers: [AlarmController, PushController], + providers: [AlarmService, PushService, AlarmSubscriber], + exports: [AlarmService], +}) +export class AlarmModule {} diff --git a/packages/backend/src/alarm/alarm.service.ts b/packages/backend/src/alarm/alarm.service.ts new file mode 100644 index 00000000..a2b214ce --- /dev/null +++ b/packages/backend/src/alarm/alarm.service.ts @@ -0,0 +1,115 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Alarm } from './domain/alarm.entity'; +import { PushSubscription } from './domain/subscription.entity'; +import { AlarmRequest } from './dto/alarm.request'; +import { AlarmResponse } from './dto/alarm.response'; +import { PushService } from './push.service'; +import { User } from '@/user/domain/user.entity'; + +@Injectable() +export class AlarmService { + constructor( + @InjectRepository(Alarm) + private readonly alarmRepository: Repository, + private readonly dataSource: DataSource, + private readonly pushService: PushService, + ) {} + + async create(alarmData: AlarmRequest, userId: number) { + return await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(Alarm); + const user = await manager.findOne(User, { where: { id: userId } }); + if (!user) { + throw new ForbiddenException('유저를 찾을 수 없습니다.'); + } + + const newAlarm = repository.create({ + ...alarmData, + user, + stock: { id: alarmData.stockId }, + }); + const result = await repository.save(newAlarm); + return new AlarmResponse(result); + }); + } + + async findByUserId(userId: number) { + const result = await this.alarmRepository.find({ + where: { user: { id: userId } }, + relations: ['user', 'stock'], + }); + return result.map((val) => new AlarmResponse(val)); + } + + async findByStockId(stockId: string, userId: number): Promise { + return await this.alarmRepository.find({ + where: { stock: { id: stockId }, user: { id: userId } }, + relations: ['user', 'stock'], + }); + } + + async findOne(id: number) { + const result = await this.alarmRepository.findOne({ + where: { id }, + relations: ['user', 'stock'], + }); + if (result) return new AlarmResponse(result); + else throw new NotFoundException('등록된 알림을 찾을 수 없습니다.'); + } + + async update(id: number, updateData: AlarmRequest) { + const alarm = await this.alarmRepository.findOne({ where: { id } }); + if (!alarm) { + throw new NotFoundException('등록된 알림을 찾을 수 없습니다.'); + } + + await this.alarmRepository.update(id, updateData); + const updatedAlarm = await this.alarmRepository.findOne({ + where: { id }, + relations: ['user', 'stock'], + }); + if (updatedAlarm) return new AlarmResponse(updatedAlarm); + else + throw new NotFoundException( + `${id} : 업데이트할 알림을 찾을 수 없습니다.`, + ); + } + + async delete(id: number) { + const alarm = await this.alarmRepository.findOne({ where: { id } }); + if (!alarm) { + throw new NotFoundException(`${id} : 삭제할 알림을 찾을 수 없습니다.`); + } + + await this.alarmRepository.delete(id); + } + + async sendPushNotification(alarm: Alarm): Promise { + const { user, stock, targetPrice, targetVolume } = alarm; + + const payload = { + title: '주식 알림', + body: `${stock.name}: ${ + targetPrice ? `가격이 ${targetPrice}에 도달했습니다.` : '' + } ${targetVolume ? `거래량이 ${targetVolume}에 도달했습니다.` : ''}`, + stockId: stock.id, + }; + + const subscriptions = await this.dataSource.manager.findBy( + PushSubscription, + { + user: { id: user.id }, + }, + ); + + for (const subscription of subscriptions) { + await this.pushService.sendPushNotification(subscription, payload); + } + } +} diff --git a/packages/backend/src/alarm/alarm.subscriber.ts b/packages/backend/src/alarm/alarm.subscriber.ts new file mode 100644 index 00000000..f5e15a6b --- /dev/null +++ b/packages/backend/src/alarm/alarm.subscriber.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + DataSource, + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, +} from 'typeorm'; +import { Logger } from 'winston'; +import { AlarmService } from './alarm.service'; +import { Alarm } from './domain/alarm.entity'; +import { StockMinutely } from '@/stock/domain/stockData.entity'; + +@Injectable() +@EventSubscriber() +export class AlarmSubscriber + implements EntitySubscriberInterface +{ + constructor( + private readonly datasource: DataSource, + private readonly alarmService: AlarmService, + @Inject('winston') private readonly logger: Logger, + ) { + this.datasource.subscribers.push(this); + } + + listenTo() { + return StockMinutely; + } + + isValidAlarm(alarm: Alarm, entity: StockMinutely) { + if (alarm.alarmDate && alarm.alarmDate > entity.createdAt) { + return false; + } else { + if (alarm.targetPrice && alarm.targetPrice >= entity.open) { + return true; + } + if (alarm.targetVolume && alarm.targetVolume >= entity.volume) { + return true; + } + return false; + } + } + + async afterInsert(event: InsertEvent) { + try { + const stockMinutely = event.entity; + const rawAlarms = await this.datasource.manager.find(Alarm, { + where: { stock: { id: stockMinutely.stock.id } }, + relations: ['user', 'stock'], + }); + + const alarms = rawAlarms.filter((val) => + this.isValidAlarm(val, stockMinutely), + ); + for (const alarm of alarms) { + await this.alarmService.sendPushNotification(alarm); + } + } catch (error) { + this.logger.warn(`Failed to handle alarm afterInsert event : ${error}`); + } + } +} diff --git a/packages/backend/src/alarm/domain/alarm.entity.ts b/packages/backend/src/alarm/domain/alarm.entity.ts new file mode 100644 index 00000000..4d33d215 --- /dev/null +++ b/packages/backend/src/alarm/domain/alarm.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; + +@Entity() +export class Alarm { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.alarms, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Stock, (stock) => stock.alarms, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @Column({ type: 'int', name: 'target_price', nullable: true }) + targetPrice?: number; + + @Column({ type: 'bigint', name: 'target_volume', nullable: true }) + targetVolume?: number; + + @Column({ type: 'timestamp', name: 'alarm_date', nullable: true }) + alarmDate?: Date; + + @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/backend/src/alarm/domain/subscription.entity.ts b/packages/backend/src/alarm/domain/subscription.entity.ts new file mode 100644 index 00000000..f3013973 --- /dev/null +++ b/packages/backend/src/alarm/domain/subscription.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '@/user/domain/user.entity'; + +@Entity() +export class PushSubscription { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.subscriptions, { onDelete: 'CASCADE' }) + user: User; + + @Column({ type: 'text' }) + endpoint: string; + + @Column({ type: 'text' }) + p256dh: string; + + @Column({ type: 'text' }) + auth: string; +} diff --git a/packages/backend/src/alarm/dto/alarm.request.ts b/packages/backend/src/alarm/dto/alarm.request.ts new file mode 100644 index 00000000..9d8e5ad0 --- /dev/null +++ b/packages/backend/src/alarm/dto/alarm.request.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AlarmRequest { + @ApiProperty({ + description: '주식 아이디', + example: '005930', + }) + stockId: string; + + @ApiProperty({ + description: '목표 가격', + example: 150.0, + required: false, + }) + targetPrice?: number; + + @ApiProperty({ + description: '목표 거래량', + example: 1000, + required: false, + }) + targetVolum?: number; + + @ApiProperty({ + description: '알림 종료 날짜', + example: '2024-12-01T00:00:00Z', + required: false, + }) + alarmDate?: Date; +} diff --git a/packages/backend/src/alarm/dto/alarm.response.ts b/packages/backend/src/alarm/dto/alarm.response.ts new file mode 100644 index 00000000..d2dc21fd --- /dev/null +++ b/packages/backend/src/alarm/dto/alarm.response.ts @@ -0,0 +1,56 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Alarm } from '../domain/alarm.entity'; + +export class AlarmResponse { + @ApiProperty({ + description: '알림 아이디', + example: 10, + }) + alarmId: number; + + @ApiProperty({ + description: '주식 코드', + example: '005930', + }) + stockId: string; + + @ApiProperty({ + description: '목표 주식 가격', + example: 50000, + nullable: true, + }) + targetPrice?: number; + + @ApiProperty({ + description: '목표 주식 거래량', + example: 10, + nullable: true, + }) + targetVolume?: number; + + @ApiProperty({ + description: '알림 만료일', + example: 10, + nullable: true, + }) + alarmDate?: Date; + + constructor(alarm: Alarm) { + this.alarmId = alarm.id; + this.stockId = alarm.stock.id; + this.targetPrice = alarm.targetPrice; + this.targetVolume = alarm.targetVolume; + this.alarmDate = alarm.alarmDate; + } +} + +export class AlarmSuccessResponse { + @ApiProperty({ + description: '성공 메시지', + example: 'success', + }) + message: string; + constructor(message: string) { + this.message = message; + } +} diff --git a/packages/backend/src/alarm/dto/subscribe.request.ts b/packages/backend/src/alarm/dto/subscribe.request.ts new file mode 100644 index 00000000..1ed453e0 --- /dev/null +++ b/packages/backend/src/alarm/dto/subscribe.request.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +//import { User } from '@/user/domain/user.entity'; + +export class SubscriptionData { + //@ApiProperty({ type: () => User, description: '유저 아이디' }) + //user: User; + + @ApiProperty({ + type: 'string', + description: '엔드 포인트 설정', + }) + endpoint: string; + + @ApiProperty({ + type: 'object', + description: 'VAPID 키', + properties: { + p256dh: { type: 'string' }, + auth: { type: 'string' }, + }, + }) + keys: { + p256dh: string; + auth: string; + }; +} diff --git a/packages/backend/src/alarm/dto/subscribe.response.ts b/packages/backend/src/alarm/dto/subscribe.response.ts new file mode 100644 index 00000000..f6a9174d --- /dev/null +++ b/packages/backend/src/alarm/dto/subscribe.response.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SubscribeResponse { + @ApiProperty({ example: 'success', description: '성공 메시지' }) + message: string; +} diff --git a/packages/backend/src/alarm/push.controller.ts b/packages/backend/src/alarm/push.controller.ts new file mode 100644 index 00000000..991e187c --- /dev/null +++ b/packages/backend/src/alarm/push.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { SubscriptionData } from './dto/subscribe.request'; +import { SubscribeResponse } from './dto/subscribe.response'; +import { PushService } from './push.service'; +import SessionGuard from '@/auth/session/session.guard'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { User } from '@/user/domain/user.entity'; + +@Controller('/push') +export class PushController { + constructor(private readonly pushService: PushService) {} + + @Post('subscribe') + @ApiOperation({ + summary: '알림 서비스 초기 설정', + description: '유저가 로그인할 때 알림을 받을 수 있게 초기설정한다.', + }) + @ApiResponse({ + status: 201, + description: '알림 초기설정', + type: SubscribeResponse, + }) + @UseGuards(SessionGuard) + async subscribe( + @Body() subscriptionData: SubscriptionData, + @GetUser() user: User, + ) { + const userId = user.id; + + return await this.pushService.createSubscription(userId, subscriptionData); + } +} diff --git a/packages/backend/src/alarm/push.service.ts b/packages/backend/src/alarm/push.service.ts new file mode 100644 index 00000000..ae61b8b5 --- /dev/null +++ b/packages/backend/src/alarm/push.service.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { DataSource } from 'typeorm'; +import * as webPush from 'web-push'; +import { Logger } from 'winston'; +import { PushSubscription } from './domain/subscription.entity'; +import { SubscriptionData } from './dto/subscribe.request'; +import { SubscribeResponse } from './dto/subscribe.response'; + +@ApiTags('Push Notifications') +@Injectable() +export class PushService { + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly dataSource: DataSource, + ) { + webPush.setVapidDetails( + 'mailto:noreply@juchum.info', + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, + ); + } + + async sendPushNotification( + subscription: PushSubscription, + payload: object, + ): Promise { + const pushPayload = JSON.stringify(payload); + + try { + await webPush.sendNotification( + { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }, + pushPayload, + ); + } catch (error) { + this.logger.warn( + `Fail to send message user id [${subscription.user.id}] : ${pushPayload}`, + error, + ); + } + } + + async createSubscription( + userId: number, + subscriptionData: SubscriptionData, + ): Promise { + const newSubscription = this.dataSource.manager.create(PushSubscription, { + user: { id: userId }, + endpoint: subscriptionData.endpoint, + p256dh: subscriptionData.keys.p256dh, + auth: subscriptionData.keys.auth, + }); + + await this.dataSource.manager.save(newSubscription); + const result: SubscribeResponse = { + message: 'Push subscription success', + }; + return result; + } +} diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index d9c69898..33b9f13a 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -15,7 +15,12 @@ const setCors = (app: INestApplication) => { 'http://localhost:5173', ], methods: '*', - allowedHeaders: '*', + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'Accept', + ], credentials: true, }); }; diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 55bf9301..cecce968 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -26,7 +26,6 @@ export class OpenapiFluctuationData { setTimeout(() => this.getFluctuationRankStocks(), 1000); } - @Cron('* 9-15 * * 1-5') @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { await this.getFluctuationRankFromApi(true); diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 437283d3..53b72d9c 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,4 +1,4 @@ -import { Global, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; @@ -7,7 +7,6 @@ import { openApiConfig } from '../config/openapi.config'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; -@Global() @Injectable() export class OpenapiTokenApi { private config: (typeof openApiConfig)[] = []; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 7a6784ae..f9bed60e 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -8,6 +8,7 @@ import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { LiveData } from './liveData.service'; import { OpenapiScraperService } from './openapi-scraper.service'; +import { WebsocketClient } from './websocket/websocketClient.websocket'; import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; import { @@ -54,6 +55,8 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiRankViewApi, OpenapiQueue, OpenapiConsumer, + WebsocketClient, + LiveData, ], exports: [LiveData], }) diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 108cc993..82af0c2e 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -7,6 +7,7 @@ import { StockYearly, } from './stockData.entity'; import { StockLiveData } from './stockLiveData.entity'; +import { Alarm } from '@/alarm/domain/alarm.entity'; import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @@ -61,4 +62,7 @@ export class Stock { (fluctuationRankStock) => fluctuationRankStock.stock, ) fluctuationRankStocks?: FluctuationRankStock[]; + + @OneToMany(() => Alarm, (alarm) => alarm.stock) + alarms?: Alarm[]; } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 673dbc56..2a374e66 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -24,8 +24,9 @@ import { import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; import { StockRateIndexService } from './stockRateIndex.service'; +import { AlarmModule } from '@/alarm/alarm.module'; +import { Alarm } from '@/alarm/domain/alarm.entity'; import { ScraperModule } from '@/scraper/scraper.module'; - @Module({ imports: [ TypeOrmModule.forFeature([ @@ -37,7 +38,9 @@ import { ScraperModule } from '@/scraper/scraper.module'; StockYearly, StockLiveData, StockDetail, + Alarm, ]), + AlarmModule, ScraperModule, ], controllers: [StockController], diff --git a/packages/backend/src/stock/stockLiveData.subscriber.ts b/packages/backend/src/stock/stockLiveData.subscriber.ts index f44ffe35..f22315be 100644 --- a/packages/backend/src/stock/stockLiveData.subscriber.ts +++ b/packages/backend/src/stock/stockLiveData.subscriber.ts @@ -39,7 +39,9 @@ export class StockLiveDataSubscriber this.stockGateway.onUpdateStock(stockId, price, change, volume); } catch (error) { - this.logger.warn(`Failed to handle afterInsert event : ${error}`); + this.logger.warn( + `Failed to handle stock live data afterInsert event : ${error}`, + ); } } @@ -55,6 +57,7 @@ export class StockLiveDataSubscriber changeRate: change, volume: volume, } = updatedStockLiveData; + this.stockGateway.onUpdateStock(stockId, price, change, volume); } else { this.logger.warn( @@ -62,7 +65,9 @@ export class StockLiveDataSubscriber ); } } catch (error) { - this.logger.warn(`Failed to handle afterUpdate event : ${error}`); + this.logger.warn( + `Failed to handle stock live data afterUpdate event : ${error}`, + ); } } diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 91a02c0b..f5d53276 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -5,6 +5,8 @@ import { OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { Alarm } from '@/alarm/domain/alarm.entity'; +import { PushSubscription } from '@/alarm/domain/subscription.entity'; import { Mention } from '@/chat/domain/mention.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; @@ -45,6 +47,12 @@ export class User { @OneToMany(() => UserStock, (userStock) => userStock.user) userStocks: UserStock[]; + @OneToMany(() => Alarm, (alarm) => alarm.user) + alarms: Alarm[]; + + @OneToMany(() => PushSubscription, (subscription) => subscription.user) + subscriptions: PushSubscription[]; + @OneToMany(() => Mention, (mention) => mention.user) mentions: Mention[]; } diff --git a/yarn.lock b/yarn.lock index f1be2c17..bd4f38fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2286,6 +2286,13 @@ resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== +"@types/web-push@^3.6.4": + version "3.6.4" + resolved "https://registry.yarnpkg.com/@types/web-push/-/web-push-3.6.4.tgz#4c6e10d3963ba51e7b4b8fff185f43612c0d1346" + integrity sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ== + dependencies: + "@types/node" "*" + "@types/ws@^8.5.13": version "8.5.13" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" @@ -2666,6 +2673,13 @@ acorn@^8.11.0, acorn@^8.14.0, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2, acorn@^8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== +agent-base@^7.0.2: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + ajv-formats@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -2912,6 +2926,16 @@ asap@^2.0.0: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1.js@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -3088,6 +3112,11 @@ bluebird@~3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bn.js@^4.0.0: + version "4.12.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.1.tgz#215741fe3c9dba2d7e12c001d0cfdbae43975ba7" + integrity sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg== + body-parser@1.20.3: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" @@ -3157,6 +3186,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -3810,6 +3844,13 @@ debug@2.6.9: dependencies: ms "2.0.0" +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -3817,13 +3858,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - dedent@0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -4008,6 +4042,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + editor@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742" @@ -5286,6 +5327,19 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http_ece@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.2.0.tgz#84d5885f052eae8c9b075eee4d2eb5105f114479" + integrity sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA== + +https-proxy-agent@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -6252,6 +6306,23 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + keyv@^4.5.3, keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -6615,6 +6686,11 @@ min-indent@^1.0.0, min-indent@^1.0.1: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -6641,7 +6717,7 @@ minimist@1.2.7: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -7716,7 +7792,7 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -8833,6 +8909,17 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-push@^3.6.7: + version "3.6.7" + resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.6.7.tgz#5f5e645951153e37ef90a6ddea5c150ea0f709e1" + integrity sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A== + dependencies: + asn1.js "^5.3.0" + http_ece "1.2.0" + https-proxy-agent "^7.0.0" + jws "^4.0.0" + minimist "^1.2.5" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From 244692da7ebc262d60ef9bf757614374a666bca9 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 22:50:33 +0900 Subject: [PATCH 168/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=20=EB=B3=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=81=90=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiPeriodData.api.ts | 73 ++++++++++++++++--- .../scraper/openapi/queue/openapi.queue.ts | 1 + 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 778f82ee..cc290472 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -15,12 +15,13 @@ import { getTodayDate, } from '../util/openapiUtil.api'; import { OpenapiTokenApi } from './openapiToken.api'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { Stock } from '@/stock/domain/stock.entity'; import { - StockData, StockDaily, - StockWeekly, + StockData, StockMonthly, + StockWeekly, StockYearly, } from '@/stock/domain/stockData.entity'; @@ -47,8 +48,11 @@ export class OpenapiPeriodData { constructor( private readonly datasource: DataSource, private readonly openApiToken: OpenapiTokenApi, + private readonly openApiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, - ) {} + ) { + // this.getItemChartPriceCheck(); + } @Cron('0 1 * * 1-5') async getItemChartPriceCheck() { @@ -58,24 +62,75 @@ export class OpenapiPeriodData { isTrading: true, }, }); - await this.getChartData(stocks, 'Y'); await this.getChartData(stocks, 'M'); await this.getChartData(stocks, 'W'); await this.getChartData(stocks, 'D'); } + /** + * 월, 년의 경우 마지막 데이터를 업데이트 하는 형식으로 변경해야됨 + */ + private getLiveDataSaveCallback( + stockId: string, + entity: typeof StockData, + period: Period, + end: string, + ) { + return async (data: Json) => { + if (!data.output2 || !Array.isArray(data.output2)) return; + // 이거 빈값들어오는 케이스 있음(빈값 필터링 안하면 요청이 매우 많아짐) + data.output2 = data.output2.filter( + (data) => Object.keys(data).length !== 0, + ); + if (data.output2.length === 0) return; + await this.saveChartData(entity, stockId, data.output2 as ChartData[]); + const { endDate, startDate } = this.updateDates(end, period); + const query = this.getItemChartPriceQuery( + stockId, + startDate, + endDate, + period, + ); + this.openApiQueue.enqueue({ + url: this.url, + query, + trId: TR_IDS.ITEM_CHART_PRICE, + callback: this.getLiveDataSaveCallback( + stockId, + entity, + period, + endDate, + ), + }); + }; + } + private async getChartData(chunk: Stock[], period: Period) { - const baseTime = INTERVALS; const entity = DATE_TO_ENTITY[period]; - - let time = 0; for (const stock of chunk) { - time += baseTime; - setTimeout(() => this.processStockData(stock, period, entity), time); + this.processStockData2(stock, period, entity); } } + private async processStockData2( + stock: Stock, + period: Period, + entity: typeof StockData, + ) { + const end = getTodayDate(); + const start = getPreviousDate(end, DATE_TO_MONTH[period]); + + const query = this.getItemChartPriceQuery(stock.id!, start, end, period); + + this.openApiQueue.enqueue({ + url: this.url, + query, + trId: TR_IDS.ITEM_CHART_PRICE, + callback: this.getLiveDataSaveCallback(stock.id!, entity, period, end), + }); + } + private async processStockData( stock: Stock, period: Period, diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index 051995a4..e46f46db 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -7,6 +7,7 @@ import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; export interface Json { output: Record | Record[]; + output2: Record[]; } export interface OpenapiQueueNodeValue { From c5132b5a10e50d4505ff2936d189eec5824d1d52 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Sun, 1 Dec 2024 23:38:19 +0900 Subject: [PATCH 169/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A7=80=EC=88=98?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=81=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/openapi/api/openapiIndex.api.ts | 181 +++++++++++------- 1 file changed, 109 insertions(+), 72 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts index d303f076..80748faa 100644 --- a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts @@ -20,6 +20,7 @@ import { import { TR_ID } from '../type/openapiUtil.type'; import { getOpenApi, getTodayDate } from '../util/openapiUtil.api'; import { OpenapiLiveData } from './openapiLiveData.api'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @@ -43,6 +44,7 @@ export class OpenapiIndex extends Openapi { protected readonly datasource: DataSource, protected readonly config: OpenapiTokenApi, private readonly openapiLiveData: OpenapiLiveData, + private readonly openapiQueue: OpenapiQueue, ) { const interval = 1000; super(datasource, config, interval); @@ -53,52 +55,8 @@ export class OpenapiIndex extends Openapi { @Cron('* 9-14 * * 1-5') @Cron('0-30 15 * * 1-5') async start() { - await this.step((await this.config.configs()).length - 1); - } - - private initKospiData() { - const name = '코스피'; - const initStockData = new Stock(); - initStockData.id = IndexRateStockId.kospi; - initStockData.groupCode = IndexRateGroupCodeStock.kospi; - initStockData.name = name; - return initStockData; - } - - private initKosdaqData() { - const name = '코스닥'; - const initStockData = new Stock(); - initStockData.id = IndexRateStockId.kosdaq; - initStockData.groupCode = IndexRateGroupCodeStock.kosdaq; - initStockData.name = name; - return initStockData; - } - - private initUsdKrwData() { - const name = '원 달러 환율'; - const initStockData = new Stock(); - initStockData.id = IndexRateStockId.usd_krw; - initStockData.groupCode = IndexRateGroupCodeStock.usd_krw; - initStockData.name = name; - return initStockData; - } - - private async initData() { - await this.saveStock(this.initKosdaqData()); - await this.saveStock(this.initKospiData()); - await this.saveStock(this.initUsdKrwData()); - } - - private async saveStock(data: Stock) { - const target = Stock; - - await this.datasource.manager - .getRepository(target) - .createQueryBuilder() - .insert() - .values(data) - .orUpdate(['is_trading'], ['id']) - .execute(); + // await this.step((await this.config.configs()).length - 1); + await this.getIndexData(); } protected async step(idx: number) { @@ -167,32 +125,6 @@ export class OpenapiIndex extends Openapi { } } - private convertResToStockIndex(res: StockIndex, stockId: string) { - const result = new StockLiveData(); - result.currentPrice = parseFloat(res.bstp_nmix_prpr); - result.changeRate = parseFloat(res.bstp_nmix_prdy_ctrt); - result.high = parseFloat(res.bstp_nmix_hgpr); - result.low = parseFloat(res.bstp_nmix_lwpr); - result.open = parseFloat(res.bstp_nmix_oprc); - result.volume = parseInt(res.acml_vol); - result.updatedAt = new Date(); - result.stock = { id: stockId } as Stock; - return result; - } - - private convertResToExchangeRate(res: ExchangeRate, stockId: string) { - const result = new StockLiveData(); - result.currentPrice = parseFloat(res.ovrs_nmix_prpr); - result.changeRate = parseFloat(res.prdy_ctrt); - result.high = parseFloat(res.ovrs_prod_hgpr); - result.low = parseFloat(res.ovrs_prod_lwpr); - result.open = parseFloat(res.ovrs_prod_oprc); - result.volume = parseInt(res.acml_vol); - result.updatedAt = new Date(); - result.stock = { id: stockId } as Stock; - return result; - } - protected convertResToEntity( res: StockIndex | ExchangeRate, stockId: string, @@ -230,4 +162,109 @@ export class OpenapiIndex extends Openapi { fid_period_div_code: period, }; } + + private async getIndexData() { + this.openapiQueue.enqueue({ + url: this.INDEX_URL, + query: this.indexQuery(this.KOSPI_ID), + trId: this.TR_ID_INDEX, + callback: this.getIndexDataCallback(this.KOSPI_ID, true), + }); + this.openapiQueue.enqueue({ + url: this.INDEX_URL, + query: this.indexQuery(this.KOSDAQ_ID), + trId: this.TR_ID_INDEX, + callback: this.getIndexDataCallback(this.KOSDAQ_ID, true), + }); + this.openapiQueue.enqueue({ + url: this.INDEX_URL, + query: this.indexQuery(this.USD_KRW_RATE), + trId: this.TR_ID_INDEX, + callback: this.getIndexDataCallback(this.USD_KRW_RATE, true), + }); + } + + private getIndexDataCallback(stockId: string, isStock: boolean) { + return async (data: Json) => { + if (!data.output) return; + if (isStock && isStockIndex(data.output)) { + const indexData = this.convertResToEntity(data.output, stockId); + await this.save(indexData); + } else if (isExchangeRate(data.output)) { + const rateData = this.convertResToEntity(data.output, stockId); + await this.save(rateData); + } + }; + } + + private initKospiData() { + const name = '코스피'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.kospi; + initStockData.groupCode = IndexRateGroupCodeStock.kospi; + initStockData.name = name; + return initStockData; + } + + private initKosdaqData() { + const name = '코스닥'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.kosdaq; + initStockData.groupCode = IndexRateGroupCodeStock.kosdaq; + initStockData.name = name; + return initStockData; + } + + private initUsdKrwData() { + const name = '원 달러 환율'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.usd_krw; + initStockData.groupCode = IndexRateGroupCodeStock.usd_krw; + initStockData.name = name; + return initStockData; + } + + private async initData() { + await this.saveStock(this.initKosdaqData()); + await this.saveStock(this.initKospiData()); + await this.saveStock(this.initUsdKrwData()); + } + + private async saveStock(data: Stock) { + const target = Stock; + + await this.datasource.manager + .getRepository(target) + .createQueryBuilder() + .insert() + .values(data) + .orUpdate(['is_trading'], ['id']) + .execute(); + } + + private convertResToStockIndex(res: StockIndex, stockId: string) { + const result = new StockLiveData(); + result.currentPrice = parseFloat(res.bstp_nmix_prpr); + result.changeRate = parseFloat(res.bstp_nmix_prdy_ctrt); + result.high = parseFloat(res.bstp_nmix_hgpr); + result.low = parseFloat(res.bstp_nmix_lwpr); + result.open = parseFloat(res.bstp_nmix_oprc); + result.volume = parseInt(res.acml_vol); + result.updatedAt = new Date(); + result.stock = { id: stockId } as Stock; + return result; + } + + private convertResToExchangeRate(res: ExchangeRate, stockId: string) { + const result = new StockLiveData(); + result.currentPrice = parseFloat(res.ovrs_nmix_prpr); + result.changeRate = parseFloat(res.prdy_ctrt); + result.high = parseFloat(res.ovrs_prod_hgpr); + result.low = parseFloat(res.ovrs_prod_lwpr); + result.open = parseFloat(res.ovrs_prod_oprc); + result.volume = parseInt(res.acml_vol); + result.updatedAt = new Date(); + result.stock = { id: stockId } as Stock; + return result; + } } From 3e2a673c1227d81b17bad23e6c23bcb929b1c49d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 2 Dec 2024 12:00:10 +0900 Subject: [PATCH 170/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EB=8B=AC?= =?UTF-8?q?=EB=9F=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20=EC=95=88?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=A7=80=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiIndex.api.ts | 12 ++++++------ .../src/scraper/openapi/queue/openapi.queue.ts | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts index 80748faa..b2c7dc9e 100644 --- a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts @@ -164,6 +164,7 @@ export class OpenapiIndex extends Openapi { } private async getIndexData() { + const date = getTodayDate(); this.openapiQueue.enqueue({ url: this.INDEX_URL, query: this.indexQuery(this.KOSPI_ID), @@ -177,21 +178,20 @@ export class OpenapiIndex extends Openapi { callback: this.getIndexDataCallback(this.KOSDAQ_ID, true), }); this.openapiQueue.enqueue({ - url: this.INDEX_URL, - query: this.indexQuery(this.USD_KRW_RATE), - trId: this.TR_ID_INDEX, + url: this.RATE_URL, + query: this.rateQuery(date, date, this.USD_KRW_RATE), + trId: this.TR_ID_RATE, callback: this.getIndexDataCallback(this.USD_KRW_RATE, true), }); } private getIndexDataCallback(stockId: string, isStock: boolean) { return async (data: Json) => { - if (!data.output) return; if (isStock && isStockIndex(data.output)) { const indexData = this.convertResToEntity(data.output, stockId); await this.save(indexData); - } else if (isExchangeRate(data.output)) { - const rateData = this.convertResToEntity(data.output, stockId); + } else if (isExchangeRate(data.output1)) { + const rateData = this.convertResToExchangeRate(data.output1, stockId); await this.save(rateData); } }; diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index e46f46db..d53f5a76 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -7,6 +7,7 @@ import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; export interface Json { output: Record | Record[]; + output1: Record[]; output2: Record[]; } From 0db1788ceff2a11646449154b48b463a2dabefbe Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 2 Dec 2024 17:19:33 +0900 Subject: [PATCH 171/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/auth/session/session.serializer.ts | 10 ++++++---- packages/backend/src/user/user.service.ts | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/auth/session/session.serializer.ts b/packages/backend/src/auth/session/session.serializer.ts index f81e5177..0fefa914 100644 --- a/packages/backend/src/auth/session/session.serializer.ts +++ b/packages/backend/src/auth/session/session.serializer.ts @@ -1,24 +1,26 @@ import { Injectable } from '@nestjs/common'; import { PassportSerializer } from '@nestjs/passport'; import { User } from '@/user/domain/user.entity'; +import { UserService } from '@/user/user.service'; @Injectable() export class SessionSerializer extends PassportSerializer { - constructor() { + constructor(private readonly userService: UserService) { super(); } async serializeUser( user: User, - done: (err: Error | null, user: User) => void, + done: (err: Error | null, userId: number) => void, ) { - done(null, user); + done(null, user.id); } async deserializeUser( - user: User, + userId: number, done: (err: Error | null, user: User | null) => void, ): Promise { + const user = await this.userService.findUserById(userId); return user ? done(null, user) : done(null, null); } } diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index ace5f7f7..d5c51eb8 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -70,6 +70,10 @@ export class UserService { }); } + async findUserById(id: number) { + return await this.dataSource.manager.findOne(User, { where: { id } }); + } + async getUserInfo(id: number) { const user = await this.dataSource.manager.findOne(User, { where: { id } }); if (!user) { From 8039fc92d7db8d94a272bd30adfc05aa1617dfc8 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 2 Dec 2024 17:20:16 +0900 Subject: [PATCH 172/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=9B=B9=EC=86=8C=EC=BC=93=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/auth/session/webSocketSession.guard.ts | 10 ++++++++-- .../src/auth/session/websocketSession.service.ts | 10 ++++++++-- packages/backend/src/chat/chat.gateway.ts | 9 ++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/auth/session/webSocketSession.guard.ts b/packages/backend/src/auth/session/webSocketSession.guard.ts index f2c766a3..bb7cd891 100644 --- a/packages/backend/src/auth/session/webSocketSession.guard.ts +++ b/packages/backend/src/auth/session/webSocketSession.guard.ts @@ -10,25 +10,31 @@ import { Socket } from 'socket.io'; import { websocketCookieParse } from '@/auth/session/cookieParser'; import { MEMORY_STORE } from '@/auth/session.module'; import { User } from '@/user/domain/user.entity'; +import { UserService } from '@/user/user.service'; export interface SessionSocket extends Socket { session?: User; } export interface PassportSession extends SessionData { - passport: { user: User }; + passport: { user: number }; } @Injectable() export class WebSocketSessionGuard implements CanActivate { constructor( @Inject(MEMORY_STORE) private readonly sessionStore: MemoryStore, + private readonly userService: UserService, ) {} async canActivate(context: ExecutionContext): Promise { const socket: SessionSocket = context.switchToHttp().getRequest(); const cookieValue = websocketCookieParse(socket); const session = await this.getSession(cookieValue); - socket.session = session.passport.user; + const user = await this.userService.findUserById(session.passport.user); + if (!user) { + return false; + } + socket.session = user; return true; } diff --git a/packages/backend/src/auth/session/websocketSession.service.ts b/packages/backend/src/auth/session/websocketSession.service.ts index c6c248cc..ebaed768 100644 --- a/packages/backend/src/auth/session/websocketSession.service.ts +++ b/packages/backend/src/auth/session/websocketSession.service.ts @@ -2,15 +2,21 @@ import { MemoryStore } from 'express-session'; import { Socket } from 'socket.io'; import { websocketCookieParse } from '@/auth/session/cookieParser'; import { PassportSession } from '@/auth/session/webSocketSession.guard'; +import { UserService } from '@/user/user.service'; export class WebsocketSessionService { - constructor(private readonly sessionStore: MemoryStore) {} + constructor( + private readonly sessionStore: MemoryStore, + private readonly userService: UserService, + ) {} async getAuthenticatedUser(socket: Socket) { try { const cookieValue = websocketCookieParse(socket); const session = await this.getSession(cookieValue); - return session ? session.passport.user : null; + return session + ? await this.userService.findUserById(session.passport.user) + : null; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { return null; diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index 951b3365..78149333 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -45,10 +45,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly stockService: StockService, private readonly chatService: ChatService, private readonly mentionService: MentionService, - private readonly UserService: UserService, + private readonly userService: UserService, @Inject(MEMORY_STORE) sessionStore: MemoryStore, ) { - this.websocketSessionService = new WebsocketSessionService(sessionStore); + this.websocketSessionService = new WebsocketSessionService( + sessionStore, + userService, + ); } @UseGuards(WebSocketSessionGuard) @@ -169,7 +172,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { } private async searchMentionedUser(nickname: string, subName: string) { - return await this.UserService.searchOneUserByNicknameAndSubName( + return await this.userService.searchOneUserByNicknameAndSubName( nickname, subName, ); From 62b499d6be9311ce0909a05450a1f745a1d727da Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 2 Dec 2024 17:27:27 +0900 Subject: [PATCH 173/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A3=BC?= =?UTF-8?q?=EA=B0=80=20=EA=B2=80=EC=83=89=EC=97=90=EC=84=9C=20'%'=EA=B0=80?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=EB=90=98=EC=96=B4=EB=8F=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.service.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 6f089af2..d38a142c 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource, EntityManager, Like } from 'typeorm'; import { Logger } from 'winston'; import { Stock } from './domain/stock.entity'; import { @@ -87,15 +87,13 @@ export class StockService { } async searchStock(stockName: string) { - const result = await this.datasource - .getRepository(Stock) - .createQueryBuilder('stock') - .where('stock.is_trading = :isTrading and stock.stock_name LIKE :name', { + const result = await this.datasource.manager.find(Stock, { + where: { isTrading: true, - name: `%${stockName}%`, - }) - .limit(10) - .getMany(); + name: Like(`%${stockName}%`), + }, + take: 10, + }); return new StockSearchResponse(result); } From 068bc2e7b31a0dd8f23c31920f37263e5675b031 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:23:09 +0900 Subject: [PATCH 174/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20gateway=20subscri?= =?UTF-8?q?be,=20unsubscribe=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 506d5917..7ba6f1dc 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -13,6 +13,8 @@ import { LiveData } from '@/scraper/openapi/liveData.service'; @WebSocketGateway({ namespace: '/api/stock/realtime', + pingInterval: 5000, + pingTimeout: 5000, }) @Injectable() export class StockGateway { @@ -33,7 +35,7 @@ export class StockGateway { client.join(stockId); await this.mutex.runExclusive(async () => { - const connectedSockets = await this.server.in(stockId).fetchSockets(); + const connectedSockets = await this.server.to(stockId).fetchSockets(); if (connectedSockets.length > 0 && !this.liveData.isSubscribe(stockId)) { await this.liveData.subscribe(stockId); @@ -41,18 +43,21 @@ export class StockGateway { } }); + client.on('disconnecting', () => { + client.rooms.delete(client.id); + const stocks = Array.from(client.rooms.values()); + for (const stock of stocks) { + this.handleDisconnectStock(stock); + } + }); + client.emit('connectionSuccess', { message: `Successfully connected to stock room: ${stockId}`, stockId, }); } - async handleDisconnectStock( - @MessageBody() stockId: string, - @ConnectedSocket() client: Socket, - ) { - client.leave(stockId); - + async handleDisconnectStock(stockId: string) { await this.mutex.runExclusive(async () => { const connectedSockets = await this.server.in(stockId).fetchSockets(); @@ -61,11 +66,6 @@ export class StockGateway { this.logger.info(`${stockId} is unsubscribed`); } }); - - client.emit('disconnectionSuccess', { - message: `Successfully disconnected to stock room: ${stockId}`, - stockId, - }); } onUpdateStock( From 2a2551d7489e602a36f0b544bfdd3a2a565e32b6 Mon Sep 17 00:00:00 2001 From: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> Date: Tue, 3 Dec 2024 04:12:58 +0900 Subject: [PATCH 175/223] =?UTF-8?q?Feature/#311=20-=20=EB=A1=9C=EC=BB=AC?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: 로컬 캐시 구현 * ✨ feat: 로컬 캐시 ttl 적용 * ✨ feat: 그래프 데이터 로컬 캐시 적용 * ✨ feat: 채팅 정렬 엔드포인트를 한 곳에 출력 --- packages/backend/src/chat/chat.controller.ts | 37 ++------ packages/backend/src/chat/dto/chat.request.ts | 11 +++ .../backend/src/common/cache/localCache.ts | 64 +++++++++++++ .../src/scraper/openapi/util/priorityQueue.ts | 4 + .../src/stock/cache/stockData.cache.ts | 16 ++++ packages/backend/src/stock/stock.module.ts | 3 + .../backend/src/stock/stockData.service.ts | 89 ++++++++----------- packages/backend/src/utils/date.ts | 4 + 8 files changed, 147 insertions(+), 81 deletions(-) create mode 100644 packages/backend/src/common/cache/localCache.ts create mode 100644 packages/backend/src/stock/cache/stockData.cache.ts create mode 100644 packages/backend/src/utils/date.ts diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index 35ce3a3b..258173cf 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -16,7 +17,7 @@ import SessionGuard from '@/auth/session/session.guard'; import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { ToggleLikeApi } from '@/chat/decorator/like.decorator'; -import { ChatScrollQuery } from '@/chat/dto/chat.request'; +import { SortedChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; import { LikeRequest } from '@/chat/dto/like.request'; import { LikeService } from '@/chat/like.service'; @@ -49,11 +50,16 @@ export class ChatController { }) @Get() async findChatList( - @Query() request: ChatScrollQuery, + @Query() request: SortedChatScrollQuery, @Req() req: Express.Request, ) { const user = req.user as User; - return await this.chatService.scrollChat(request, user?.id); + if (request.order === 'like') { + return await this.chatService.scrollChatByLike(request, user?.id); + } else if (!request.order || request.order === 'latest') { + return await this.chatService.scrollChat(request, user?.id); + } + throw new BadRequestException('invalid order'); } @UseGuards(SessionGuard) @@ -64,29 +70,4 @@ export class ChatController { this.chatGateWay.broadcastLike(result); return result; } - - @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('/like') - async findChatListByLike( - @Query() request: ChatScrollQuery, - @Req() req: Express.Request, - ) { - const user = req.user as User; - return await this.chatService.scrollChatByLike(request, user?.id); - } } diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index 56b18536..7503351f 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -45,6 +45,17 @@ export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); } +export class SortedChatScrollQuery extends ChatScrollQuery { + @ApiProperty({ + description: '정렬 기준(기본은 최신 순)', + example: 'latest', + enum: ['latest', 'like'], + required: false, + }) + @IsOptional() + order: string; +} + export interface ChatMessage { room: string; content: string; diff --git a/packages/backend/src/common/cache/localCache.ts b/packages/backend/src/common/cache/localCache.ts new file mode 100644 index 00000000..03a7a6fb --- /dev/null +++ b/packages/backend/src/common/cache/localCache.ts @@ -0,0 +1,64 @@ +import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; + +type CacheEntry = { + value: T; + expiredAt: number; +}; + +type CacheQueueEntry = { + value: T; + expiredAt: number; +}; + +export class LocalCache { + private readonly localCache: Map> = new Map(); + private readonly ttlQueue: PriorityQueue> = + new PriorityQueue(); + + constructor(private readonly interval = 500) { + setInterval(() => this.clearExpired(), interval); + } + + get(key: K) { + const entry = this.localCache.get(key); + if (!entry) { + return null; + } + if (entry.expiredAt < Date.now()) { + this.localCache.delete(key); + return null; + } + return entry.value; + } + + async set(key: K, value: V, ttl: number) { + const expiredAt = Date.now() + ttl; + this.localCache.set(key, { value, expiredAt }); + this.ttlQueue.enqueue({ value: key, expiredAt }, expiredAt); + } + + async delete(key: K) { + this.localCache.delete(key); + } + + clear() { + this.localCache.clear(); + } + + private clearExpired() { + const now = Date.now(); + while (!this.ttlQueue.isEmpty()) { + const key = this.ttlQueue.dequeue()!; + if (key.expiredAt > now) { + this.ttlQueue.enqueue(key, key.expiredAt); + break; + } + if ( + this.localCache.has(key.value) && + this.localCache.get(key.value)!.expiredAt === key.expiredAt + ) { + this.localCache.delete(key.value); + } + } + } +} diff --git a/packages/backend/src/scraper/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/openapi/util/priorityQueue.ts index c0abf838..d7efc937 100644 --- a/packages/backend/src/scraper/openapi/util/priorityQueue.ts +++ b/packages/backend/src/scraper/openapi/util/priorityQueue.ts @@ -34,6 +34,10 @@ export class PriorityQueue { return this.heap.length === 0; } + clear() { + this.heap = []; + } + private getParentIndex(index: number): number { return Math.floor((index - 1) / 2); } diff --git a/packages/backend/src/stock/cache/stockData.cache.ts b/packages/backend/src/stock/cache/stockData.cache.ts new file mode 100644 index 00000000..2a9e93c4 --- /dev/null +++ b/packages/backend/src/stock/cache/stockData.cache.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { LocalCache } from '@/common/cache/localCache'; +import { StockDataResponse } from '@/stock/dto/stockData.response'; + +@Injectable() +export class StockDataCache { + private readonly localCache = new LocalCache(); + + set(key: string, value: StockDataResponse, ttl: number = 60000) { + this.localCache.set(key, value, ttl); + } + + get(key: string): StockDataResponse | null { + return this.localCache.get(key); + } +} \ No newline at end of file diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 2a374e66..adaa18f0 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -27,6 +27,8 @@ import { StockRateIndexService } from './stockRateIndex.service'; import { AlarmModule } from '@/alarm/alarm.module'; import { Alarm } from '@/alarm/domain/alarm.entity'; import { ScraperModule } from '@/scraper/scraper.module'; +import { StockDataCache } from '@/stock/cache/stockData.cache'; + @Module({ imports: [ TypeOrmModule.forFeature([ @@ -45,6 +47,7 @@ import { ScraperModule } from '@/scraper/scraper.module'; ], controllers: [StockController], providers: [ + StockDataCache, StockService, StockGateway, StockLiveDataSubscriber, diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 56932eda..2b9e3942 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -3,8 +3,8 @@ import { plainToInstance } from 'class-transformer'; import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { - StockMinutely, StockDaily, + StockMinutely, StockMonthly, StockWeekly, StockYearly, @@ -14,6 +14,8 @@ import { StockDataResponse, VolumeDto, } from './dto/stockData.response'; +import { StockDataCache } from '@/stock/cache/stockData.cache'; +import { getFormattedDate } from '@/utils/date'; type StockData = { id: number; @@ -30,9 +32,11 @@ type StockData = { @Injectable() export class StockDataService { protected readonly PAGE_SIZE = 100; - protected readonly DEFAULT_COLOR = 'red'; - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly stockDataCache: StockDataCache, + ) {} async getPaginated( entity: new () => StockData, @@ -42,6 +46,12 @@ export class StockDataService { return await this.dataSource.manager.transaction(async (manager) => { if (!(await this.isStockExist(stock_id, manager))) throw new NotFoundException('stock not found'); + const date = lastStartTime ? new Date(lastStartTime) : new Date(); + const cacheKey = `${entity.name}_${stock_id}_${getFormattedDate(date)}`; + const cachedData = this.stockDataCache.get(cacheKey); + if (cachedData) { + return cachedData; + } const queryBuilder = manager .createQueryBuilder(entity, 'entity') @@ -60,8 +70,13 @@ export class StockDataService { if (hasMore) resultList.pop(); const priceDtoList = this.mapResultListToPriceDtoList(resultList); const volumeDtoList = this.mapResultListToVolumeDtoList(resultList); - - return this.createStockDataResponse(priceDtoList, volumeDtoList, hasMore); + const response = this.createStockDataResponse( + priceDtoList, + volumeDtoList, + hasMore, + ); + this.stockDataCache.set(cacheKey, response); + return response; }); } @@ -98,107 +113,75 @@ export class StockDataService { const priceData = plainToInstance(PriceDto, priceDtoList); const volumeData = plainToInstance(VolumeDto, volumeDtoList); - const responseDto = plainToInstance(StockDataResponse, { + return plainToInstance(StockDataResponse, { priceDtoList: priceData, volumeDtoList: volumeData, hasMore, }); - - return responseDto; } } @Injectable() export class StockDataMinutelyService extends StockDataService { - constructor(dataSource: DataSource) { - super(dataSource); + constructor(dataSource: DataSource, stockDataCache: StockDataCache) { + super(dataSource, stockDataCache); } async getStockDataMinutely( stock_id: string, lastStartTime?: string, ): Promise { - const response = await this.getPaginated( - StockMinutely, - stock_id, - lastStartTime, - ); - - return response; + return await this.getPaginated(StockMinutely, stock_id, lastStartTime); } } @Injectable() export class StockDataDailyService extends StockDataService { - constructor(dataSource: DataSource) { - super(dataSource); + constructor(dataSource: DataSource, stockDataCache: StockDataCache) { + super(dataSource, stockDataCache); } async getStockDataDaily( stock_id: string, lastStartTime?: string, ): Promise { - const response = await this.getPaginated( - StockDaily, - stock_id, - lastStartTime, - ); - - return response; + return await this.getPaginated(StockDaily, stock_id, lastStartTime); } } @Injectable() export class StockDataWeeklyService extends StockDataService { - constructor(dataSource: DataSource) { - super(dataSource); + constructor(dataSource: DataSource, sockDataCache: StockDataCache) { + super(dataSource, sockDataCache); } async getStockDataWeekly( stock_id: string, lastStartTime?: string, ): Promise { - const response = await this.getPaginated( - StockWeekly, - stock_id, - lastStartTime, - ); - - return response; + return await this.getPaginated(StockWeekly, stock_id, lastStartTime); } } @Injectable() export class StockDataMonthlyService extends StockDataService { - constructor(dataSource: DataSource) { - super(dataSource); + constructor(dataSource: DataSource, stockDataCache: StockDataCache) { + super(dataSource, stockDataCache); } async getStockDataMonthly( stock_id: string, lastStartTime?: string, ): Promise { - const response = await this.getPaginated( - StockMonthly, - stock_id, - lastStartTime, - ); - - return response; + return await this.getPaginated(StockMonthly, stock_id, lastStartTime); } } @Injectable() export class StockDataYearlyService extends StockDataService { - constructor(dataSource: DataSource) { - super(dataSource); + constructor(dataSource: DataSource, stockDataCache: StockDataCache) { + super(dataSource, stockDataCache); } async getStockDataYearly( stock_id: string, lastStartTime?: string, ): Promise { - const response = await this.getPaginated( - StockYearly, - stock_id, - lastStartTime, - ); - - return response; + return await this.getPaginated(StockYearly, stock_id, lastStartTime); } } diff --git a/packages/backend/src/utils/date.ts b/packages/backend/src/utils/date.ts new file mode 100644 index 00000000..4d3987b9 --- /dev/null +++ b/packages/backend/src/utils/date.ts @@ -0,0 +1,4 @@ +export function getFormattedDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1) + .padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} \ No newline at end of file From e1bb6b83a53edbd25e302b1c08423b9a98b9ec89 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Tue, 3 Dec 2024 04:15:33 +0900 Subject: [PATCH 176/223] =?UTF-8?q?Bug/#315=20period=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AC=B8=EC=A0=9C,=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EA=B3=84=EC=A2=8C=20priority=20queue=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: 한번만 callback 함수를 부르는 방식으로 변경, 안 쓰이는 함수 삭제 * 💄 style: processStockData 이름 변경 * ✨ feat: new Date 추가 * 🐛 fix: new date에서 date없이도 가능 * 📝 docs: korea.mst * ✨ feat: newDate 타입가드, isSameMonth, week 추가 * 🐛 fix: 데이터 받아오는 단위 수정 * 🐛 fix: dequeue 수정, 테스트 코드 제외 * 🐛 fix: newDate 오류 수정 * 🐛 fix: period 데이터 받지 못하는 문제 해결 * 💄 style: 테스트 코드 삭제 --- .../openapi/api/openapiPeriodData.api.ts | 116 +++++++++--------- .../scraper/openapi/queue/openapi.queue.ts | 30 +++-- .../src/scraper/openapi/util/newDate.util.ts | 79 ++++++++++++ 3 files changed, 153 insertions(+), 72 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/util/newDate.util.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index cc290472..0e5f964e 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -9,6 +9,7 @@ import { Period, } from '../type/openapiPeriodData.type'; import { TR_IDS } from '../type/openapiUtil.type'; +import { NewDate } from '../util/newDate.util'; import { getOpenApi, getPreviousDate, @@ -30,14 +31,14 @@ const DATE_TO_ENTITY = { W: StockWeekly, M: StockMonthly, Y: StockYearly, -}; +} as const; const DATE_TO_MONTH = { D: 1, W: 6, M: 24, Y: 120, -}; +} as const; const INTERVALS = 10000; @@ -51,7 +52,7 @@ export class OpenapiPeriodData { private readonly openApiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, ) { - // this.getItemChartPriceCheck(); + //this.getItemChartPriceCheck(); } @Cron('0 1 * * 1-5') @@ -71,12 +72,25 @@ export class OpenapiPeriodData { /** * 월, 년의 경우 마지막 데이터를 업데이트 하는 형식으로 변경해야됨 */ - private getLiveDataSaveCallback( + private getLiveDataSaveCallback(stockId: string, period: Period) { + return async (data: Json) => { + if (!data.output2 || !Array.isArray(data.output2)) return; + // 이거 빈값들어오는 케이스 있음(빈값 필터링 안하면 요청이 매우 많아짐) + data.output2 = data.output2.filter( + (data) => Object.keys(data).length !== 0, + ); + if (data.output2.length === 0) return; + await this.saveChartData(period, stockId, data.output2 as ChartData[]); + }; + } + + /* eslint-disable-next-line max-lines-per-function */ + private getLiveDataSaveUntilEndCallback( stockId: string, - entity: typeof StockData, period: Period, end: string, ) { + /* eslint-disable-next-line max-lines-per-function */ return async (data: Json) => { if (!data.output2 || !Array.isArray(data.output2)) return; // 이거 빈값들어오는 케이스 있음(빈값 필터링 안하면 요청이 매우 많아짐) @@ -84,7 +98,7 @@ export class OpenapiPeriodData { (data) => Object.keys(data).length !== 0, ); if (data.output2.length === 0) return; - await this.saveChartData(entity, stockId, data.output2 as ChartData[]); + await this.saveChartData(period, stockId, data.output2 as ChartData[]); const { endDate, startDate } = this.updateDates(end, period); const query = this.getItemChartPriceQuery( stockId, @@ -96,9 +110,8 @@ export class OpenapiPeriodData { url: this.url, query, trId: TR_IDS.ITEM_CHART_PRICE, - callback: this.getLiveDataSaveCallback( + callback: this.getLiveDataSaveUntilEndCallback( stockId, - entity, period, endDate, ), @@ -107,17 +120,12 @@ export class OpenapiPeriodData { } private async getChartData(chunk: Stock[], period: Period) { - const entity = DATE_TO_ENTITY[period]; for (const stock of chunk) { - this.processStockData2(stock, period, entity); + await this.processStockData(stock, period); } } - private async processStockData2( - stock: Stock, - period: Period, - entity: typeof StockData, - ) { + private async processStockData(stock: Stock, period: Period) { const end = getTodayDate(); const start = getPreviousDate(end, DATE_TO_MONTH[period]); @@ -127,50 +135,10 @@ export class OpenapiPeriodData { url: this.url, query, trId: TR_IDS.ITEM_CHART_PRICE, - callback: this.getLiveDataSaveCallback(stock.id!, entity, period, end), + callback: this.getLiveDataSaveCallback(stock.id!, period), }); } - private async processStockData( - stock: Stock, - period: Period, - entity: typeof StockData, - ) { - const stockPeriod = new StockData(); - let configIdx = 0; - let end = getTodayDate(); - let start = getPreviousDate(end, DATE_TO_MONTH[period]); - let isFail = false; - - while (!isFail) { - await new Promise((resolve) => setTimeout(resolve, INTERVALS / 10)); - configIdx = (configIdx + 1) % (await this.openApiToken.configs()).length; - this.setStockPeriod(stockPeriod, stock.id!, end); - - const query = this.getItemChartPriceQuery(stock.id!, start, end, period); - - const output = await this.fetchChartData(query, configIdx); - - if (output) { - await this.saveChartData(entity, stock.id!, output); - ({ endDate: end, startDate: start } = this.updateDates(end, period)); - } else isFail = true; - } - } - - private setStockPeriod( - stockPeriod: StockData, - stockId: string, - endDate: string, - ): void { - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(endDate.slice(0, 4)), - parseInt(endDate.slice(4, 6)) - 1, - parseInt(endDate.slice(6, 8)), - ); - } - private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { try { const response = await getOpenApi( @@ -205,8 +173,38 @@ export class OpenapiPeriodData { }); } - private async insertChartData(stock: StockData, entity: typeof StockData) { + private isSamePeriod(stock: StockData, period: Period, date: Date) { + this.logger.info(date); + this.logger.info(stock.startTime); + return ( + (period === 'W' && new NewDate(stock.startTime).isSameWeek(date)) || + (period === 'M' && new NewDate(stock.startTime).isSameMonth(date)) || + (period === 'Y' && new NewDate(stock.startTime).isSameYear(date)) + ); + } + + private async insertChartData(stock: StockData, period: Period) { + const entity = DATE_TO_ENTITY[period]; const manager = this.datasource.manager; + + // db 쿼리 전 최근 데이터인지 먼저 확인 + if (this.isSamePeriod(stock, period, new Date())) { + const pastOneData = await manager + .createQueryBuilder() + .from(entity, 'stock') + .where({ stock: stock.stock.id }) + .limit(1) + .orderBy('stock.start_time', 'DESC') + .execute(); + if ( + pastOneData.length !== 0 && + this.isSamePeriod(stock, period, pastOneData[0].start_time) + ) { + await manager.delete(entity, pastOneData[0].id); + await manager.insert(entity, stock); + return; + } + } if (!(await this.existsChartData(stock, entity))) { await manager.save(entity, stock); } @@ -230,7 +228,7 @@ export class OpenapiPeriodData { } private async saveChartData( - entity: typeof StockData, + period: Period, stockId: string, data: ChartData[], ) { @@ -239,7 +237,7 @@ export class OpenapiPeriodData { continue; } const stockPeriod = this.convertObjectToStockData(item, stockId); - await this.insertChartData(stockPeriod, entity); + await this.insertChartData(stockPeriod, period); } } diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index d53f5a76..7a03fb05 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -85,20 +85,24 @@ export class OpenapiConsumer { for (let i = 0; i < this.REQUEST_COUNT_PER_SECOND; i++) { const node = this.queue.dequeue(); if (!node) { - return (this.isProcessing = false); - } - try { - const data = await getOpenApi( - node.url, - (await this.openapiTokenApi.configs())[index], - node.query, - node.trId, - ); - await node.callback(data); - } catch (error) { - this.logger.warn(error); - this.queue.enqueue(node, 1); + return; } + this.processRequest(node, index); + } + } + + private async processRequest(node: OpenapiQueueNodeValue, index: number) { + try { + const data = await getOpenApi( + node.url, + (await this.openapiTokenApi.configs())[index], + node.query, + node.trId, + ); + await node.callback(data); + } catch (error) { + this.logger.warn(error); + this.queue.enqueue(node, 1); } } } diff --git a/packages/backend/src/scraper/openapi/util/newDate.util.ts b/packages/backend/src/scraper/openapi/util/newDate.util.ts new file mode 100644 index 00000000..8c25439f --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/newDate.util.ts @@ -0,0 +1,79 @@ +export class NewDate extends Date { + constructor(date?: Date) { + if (date) super(date); + else super(); + } + + //eslint-disable-next-line @typescript-eslint/no-explicit-any + static isNewDate(date: any): date is NewDate { + return ( + date && + typeof date.dateToNewDate === 'function' && + typeof date.isSameWeek === 'function' && + typeof date.isSameMonth === 'function' && + typeof date.isSameYear === 'function' + ); + } + + private resetTime(): NewDate { + this.setHours(0, 0, 0, 0); + return this; + } + + private getWeek(): number { + const date = this.resetTime(); + const firstOne = new Date(date.getFullYear(), 0, 1); + return Math.ceil( + ((date.getTime() - firstOne.getTime()) / 86400000 + + firstOne.getDay() + + 1) / + 7, + ); + } + + private isLastWeekOfTheYear(): boolean { + return this.getWeek() === 53; + } + + private isFirstWeekOfTheYear(): boolean { + return this.getWeek() === 1; + } + + dateToNewDate(date: Date): NewDate { + return new NewDate(date); + } + + isSameWeek(dateToCompare: NewDate | Date): boolean { + if (!(dateToCompare instanceof NewDate)) + dateToCompare = new NewDate(dateToCompare); + const toNewDate = (date: Date | NewDate): date is NewDate => { + return ( + date instanceof NewDate || + ((date = this.dateToNewDate(date)), NewDate.isNewDate(date)) + ); + }; + + if (toNewDate(dateToCompare)) { + let sameWeek = + this.getWeek() === dateToCompare.getWeek() && + this.getFullYear() === dateToCompare.getFullYear(); + + if ( + !sameWeek && + this.isLastWeekOfTheYear() && + dateToCompare.isFirstWeekOfTheYear() + ) { + sameWeek = this.getDay() < dateToCompare.getDay(); + } + } + return false; + } + + isSameMonth(dateToCompare: NewDate | Date): boolean { + return this.getMonth() === dateToCompare.getMonth(); + } + + isSameYear(dateToCompare: NewDate | Date): boolean { + return this.getFullYear() === dateToCompare.getFullYear(); + } +} From 5344b2653127030e31fe6367faa58abb4fd63fb5 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:17:00 +0900 Subject: [PATCH 177/223] =?UTF-8?q?Bug/#315=20unsubscribe,=20subscribe?= =?UTF-8?q?=EC=97=90=20queue=20=20=EC=A0=81=EC=9A=A9=20(#337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: 한번만 callback 함수를 부르는 방식으로 변경, 안 쓰이는 함수 삭제 * 💄 style: processStockData 이름 변경 * ✨ feat: new Date 추가 * 🐛 fix: new date에서 date없이도 가능 * 📝 docs: korea.mst * ✨ feat: newDate 타입가드, isSameMonth, week 추가 * 🐛 fix: 데이터 받아오는 단위 수정 * 🐛 fix: dequeue 수정, 테스트 코드 제외 * 🐛 fix: newDate 오류 수정 * 🐛 fix: period 데이터 받지 못하는 문제 해결 * 💄 style: 테스트 코드 삭제 * 🐛 fix: stock.gateway 수정 * 🐛 fix: unsubscribe 기능 추가 * 🐛 fix: livedata service 수정 * 🐛 fix: null 일때 에러 리턴 * 📝 docs: 불필요한 info 삭제 * ♻️ refactor: 안쓰이는 service 삭제 --- .../scraper/openapi/api/openapiToken.api.ts | 4 +- .../src/scraper/openapi/liveData.service.ts | 13 ++-- .../scraper/openapi/openapi-scraper.module.ts | 2 - .../openapi/openapi-scraper.service.ts | 15 ----- .../websocket/websocketClient.websocket.ts | 30 +++++---- packages/backend/src/stock/stock.gateway.ts | 63 ++++++++++--------- 6 files changed, 62 insertions(+), 65 deletions(-) delete mode 100644 packages/backend/src/scraper/openapi/openapi-scraper.service.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 53b72d9c..af1b6cb6 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -68,8 +68,8 @@ export class OpenapiTokenApi { private isTokenExpired(startDate?: Date) { if (!startDate) return true; const now = new Date(); - //실제 만료 시간은 24시간이지만, 문제가 발생할 여지를 줄이기 위해 12시간으로 설정 - const baseTimeToMilliSec = 12 * 60 * 60 * 1000; + //실제 만료 시간은 24시간이지만, 문제가 발생할 여지를 줄이기 위해 6시간으로 설정 + const baseTimeToMilliSec = 6 * 60 * 60 * 1000; const timeDiff = now.getTime() - startDate.getTime(); return timeDiff >= baseTimeToMilliSec; diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 7749532c..104a9d08 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -48,7 +48,7 @@ export class LiveData { stockId, ); if (stockLiveData) { - this.openapiLiveData.saveLiveData(stockLiveData); + await this.openapiLiveData.saveLiveData(stockLiveData); } } catch (error) { this.logger.warn(`Subscribe error in open api : ${error}`); @@ -60,6 +60,9 @@ export class LiveData { } async subscribe(stockId: string) { + if (stockId === null || stockId === undefined) { + return; + } await this.openapiSubscribe(stockId); if (!this.isCloseTime(new Date(), this.startTime, this.endTime)) { @@ -85,7 +88,7 @@ export class LiveData { const idx = this.subscribeStocks.get(stockId); this.subscribeStocks.delete(stockId); - if (idx) { + if (idx !== undefined) { this.configSubscribeSize[idx]--; } else { this.logger.warn(`Websocket error : ${stockId} has invalid idx`); @@ -98,7 +101,7 @@ export class LiveData { '2', ); - this.websocketClient[idx].discribe(message); + this.websocketClient[idx].unsubscribe(message); } } @@ -122,11 +125,13 @@ export class LiveData { if (message.header) { if (message.header.tr_id === 'PINGPONG') { client.pong(data); + } else { + this.logger.info(JSON.stringify(message)); } return; } const liveData = this.openapiLiveData.convertLiveData(message); - await this.openapiLiveData.saveLiveData(liveData[0]); + await this.openapiLiveData.saveLiveData(liveData[0]) } catch (error) { this.logger.warn(error); } diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index f9bed60e..34317e19 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -7,7 +7,6 @@ import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { LiveData } from './liveData.service'; -import { OpenapiScraperService } from './openapi-scraper.service'; import { WebsocketClient } from './websocket/websocketClient.websocket'; import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; @@ -49,7 +48,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiPeriodData, OpenapiMinuteData, OpenapiDetailData, - OpenapiScraperService, OpenapiFluctuationData, OpenapiIndex, OpenapiRankViewApi, diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts deleted file mode 100644 index 52c90179..00000000 --- a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { OpenapiDetailData } from './api/openapiDetailData.api'; -import { OpenapiMinuteData } from './api/openapiMinuteData.api'; -import { OpenapiPeriodData } from './api/openapiPeriodData.api'; - -@Injectable() -export class OpenapiScraperService { - public constructor( - private datasource: DataSource, - private readonly openapiPeriodData: OpenapiPeriodData, - private readonly openapiMinuteData: OpenapiMinuteData, - private readonly openapiDetailData: OpenapiDetailData, - ) {} -} diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts index 540fb15f..d8704787 100644 --- a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Inject, Injectable } from '@nestjs/common'; import { Logger } from 'winston'; import { RawData, WebSocket } from 'ws'; @@ -7,24 +6,23 @@ import { RawData, WebSocket } from 'ws'; export class WebsocketClient { static url = process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; private client: WebSocket; - //현재 factory 패턴을 이용해 할당하면 socket이 열리기 전에 message가 가는 문제가 있음. - // 소켓이 할당되기 전에(client에 소켓이 없을 때) message를 보내려 시도함. + private messageQueue: string[] = []; constructor(@Inject('winston') private readonly logger: Logger) { this.client = new WebSocket(WebsocketClient.url); + this.initOpen(() => this.flushQueue()); + this.initError((error) => this.logger.error('WebSocket error', error)); } static websocketFactory(logger: Logger) { - const websocket = new WebsocketClient(logger); - - return websocket; + return new WebsocketClient(logger); } subscribe(message: string) { this.sendMessage(message); } - discribe(message: string) { + unsubscribe(message: string) { this.sendMessage(message); } @@ -50,22 +48,28 @@ export class WebsocketClient { initCloseCallback: () => void, initErrorCallback: (error: unknown) => void, ) { - this.initOpen(initOpenCallback(this.sendMessage)); + this.initOpen(initOpenCallback(this.sendMessage.bind(this))); this.initMessage(initMessageCallback(this.client)); this.initDisconnect(initCloseCallback); this.initError(initErrorCallback); } private sendMessage(message: string) { - if (!this.client || !this.client.readyState) { - this.logger.warn('WebSocket is not open. Message not sent. '); - return; - } if (this.client.readyState === WebSocket.OPEN) { this.client.send(message); this.logger.info(`Sent message: ${message}`); } else { - this.logger.warn('WebSocket is not open. Message not sent. '); + this.logger.warn('WebSocket not open. Queueing message.'); + this.messageQueue.push(message); // 큐에 메시지를 추가 + } + } + + private flushQueue() { + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (message) { + this.sendMessage(message); + } } } } diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 7ba6f1dc..618163d1 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ConnectedSocket, MessageBody, + OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer, @@ -17,11 +18,13 @@ import { LiveData } from '@/scraper/openapi/liveData.service'; pingTimeout: 5000, }) @Injectable() -export class StockGateway { +export class StockGateway implements OnGatewayDisconnect { @WebSocketServer() server: Server; private readonly mutex = new Mutex(); + private readonly users: Map = new Map(); + constructor( private readonly liveData: LiveData, @Inject('winston') private readonly logger: Logger, @@ -32,40 +35,42 @@ export class StockGateway { @MessageBody() stockId: string, @ConnectedSocket() client: Socket, ) { - client.join(stockId); - - await this.mutex.runExclusive(async () => { - const connectedSockets = await this.server.to(stockId).fetchSockets(); + try { + client.join(stockId); + this.users.set(client.id, stockId); - if (connectedSockets.length > 0 && !this.liveData.isSubscribe(stockId)) { - await this.liveData.subscribe(stockId); - this.logger.info(`${stockId} is subscribed`); - } - }); + await this.mutex.runExclusive(async () => { + const connectedSockets = await this.server.to(stockId).fetchSockets(); - client.on('disconnecting', () => { - client.rooms.delete(client.id); - const stocks = Array.from(client.rooms.values()); - for (const stock of stocks) { - this.handleDisconnectStock(stock); - } - }); + if ( + connectedSockets.length > 0 && + !this.liveData.isSubscribe(stockId) + ) { + await this.liveData.subscribe(stockId); + this.logger.info(`${stockId} is subscribed`); + } + }); - client.emit('connectionSuccess', { - message: `Successfully connected to stock room: ${stockId}`, - stockId, - }); + client.emit('connectionSuccess', { + message: `Successfully connected to stock room: ${stockId}`, + stockId, + }); + } catch (e) { + const error = e as Error; + this.logger.warn(error.message); + client.emit('error', error.message); + client.disconnect(); + } } - async handleDisconnectStock(stockId: string) { - await this.mutex.runExclusive(async () => { - const connectedSockets = await this.server.in(stockId).fetchSockets(); - - if (connectedSockets.length === 0) { + async handleDisconnect(client: Socket) { + const stockId = this.users.get(client.id); + if (stockId) { + await this.mutex.runExclusive(async () => { await this.liveData.unsubscribe(stockId); - this.logger.info(`${stockId} is unsubscribed`); - } - }); + this.users.delete(client.id); + }); + } } onUpdateStock( From d98bf6c12da4dd1ba6363fa82a75d4afc1787789 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:35:51 +0900 Subject: [PATCH 178/223] =?UTF-8?q?Bug/#315=20period=20=EC=A4=91=20?= =?UTF-8?q?=EC=A3=BC=EB=8B=A8=EC=9C=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EB=9F=89=EC=9D=84=20=EB=88=84=EC=A0=81?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EB=9F=89=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#340)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: 한번만 callback 함수를 부르는 방식으로 변경, 안 쓰이는 함수 삭제 * 💄 style: processStockData 이름 변경 * ✨ feat: new Date 추가 * 🐛 fix: new date에서 date없이도 가능 * 📝 docs: korea.mst * ✨ feat: newDate 타입가드, isSameMonth, week 추가 * 🐛 fix: 데이터 받아오는 단위 수정 * 🐛 fix: dequeue 수정, 테스트 코드 제외 * 🐛 fix: newDate 오류 수정 * 🐛 fix: period 데이터 받지 못하는 문제 해결 * 💄 style: 테스트 코드 삭제 * 🐛 fix: stock.gateway 수정 * 🐛 fix: unsubscribe 기능 추가 * 🐛 fix: livedata service 수정 * 🐛 fix: null 일때 에러 리턴 * 📝 docs: 불필요한 info 삭제 * ♻️ refactor: 안쓰이는 service 삭제 * 🐛 fix: 웹소켓 누적 거래량으로 수정 * 🐛 fix: checkWeek 오류 해결, openapiPeriodData 함수 위치 수정 * 💄 style: livedata 테스트용 데이터 삭제 * 💄 style: openapiPeriodData 입력 수정 * 💄 style: period data로컬 상에서 테스트 완료 * 💄 style: tODO 삭제 * 💄 style: gitignore에 zip, mst 형식 추가 --- .gitignore | 4 + .../openapi/api/openapiLiveData.api.ts | 2 +- .../openapi/api/openapiPeriodData.api.ts | 107 +++++++++--------- .../src/scraper/openapi/liveData.service.ts | 1 - .../src/scraper/openapi/util/newDate.util.ts | 1 + 5 files changed, 59 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index bdc0ede0..7f606fde 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # remote .remote + +# .zip, .mst +*.zip +*.mst diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index 2897e00b..f7288829 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -65,7 +65,7 @@ export class OpenapiLiveData { stockLiveData.stock = { id: message.STOCK_ID } as Stock; stockLiveData.currentPrice = parseFloat(message.STCK_PRPR); stockLiveData.changeRate = parseFloat(message.PRDY_CTRT); - stockLiveData.volume = parseInt(message.CNTG_VOL); + stockLiveData.volume = parseInt(message.ACML_VOL); stockLiveData.high = parseFloat(message.STCK_HGPR); stockLiveData.low = parseFloat(message.STCK_LWPR); stockLiveData.open = parseFloat(message.STCK_OPRC); diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 0e5f964e..9ce6b4ed 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -84,41 +84,6 @@ export class OpenapiPeriodData { }; } - /* eslint-disable-next-line max-lines-per-function */ - private getLiveDataSaveUntilEndCallback( - stockId: string, - period: Period, - end: string, - ) { - /* eslint-disable-next-line max-lines-per-function */ - return async (data: Json) => { - if (!data.output2 || !Array.isArray(data.output2)) return; - // 이거 빈값들어오는 케이스 있음(빈값 필터링 안하면 요청이 매우 많아짐) - data.output2 = data.output2.filter( - (data) => Object.keys(data).length !== 0, - ); - if (data.output2.length === 0) return; - await this.saveChartData(period, stockId, data.output2 as ChartData[]); - const { endDate, startDate } = this.updateDates(end, period); - const query = this.getItemChartPriceQuery( - stockId, - startDate, - endDate, - period, - ); - this.openApiQueue.enqueue({ - url: this.url, - query, - trId: TR_IDS.ITEM_CHART_PRICE, - callback: this.getLiveDataSaveUntilEndCallback( - stockId, - period, - endDate, - ), - }); - }; - } - private async getChartData(chunk: Stock[], period: Period) { for (const stock of chunk) { await this.processStockData(stock, period); @@ -164,8 +129,7 @@ export class OpenapiPeriodData { } private async existsChartData(stock: StockData, entity: typeof StockData) { - const manager = this.datasource.manager; - return await manager.findOne(entity, { + return this.datasource.manager.exists(entity, { where: { stock: { id: stock.stock.id }, startTime: stock.startTime, @@ -174,11 +138,11 @@ export class OpenapiPeriodData { } private isSamePeriod(stock: StockData, period: Period, date: Date) { - this.logger.info(date); - this.logger.info(stock.startTime); return ( (period === 'W' && new NewDate(stock.startTime).isSameWeek(date)) || - (period === 'M' && new NewDate(stock.startTime).isSameMonth(date)) || + (period === 'M' && + new NewDate(stock.startTime).isSameMonth(date) && + new NewDate(stock.startTime).isSameYear(date)) || (period === 'Y' && new NewDate(stock.startTime).isSameYear(date)) ); } @@ -210,6 +174,55 @@ export class OpenapiPeriodData { } } + private async saveChartData( + period: Period, + stockId: string, + data: ChartData[], + ) { + for (const item of data) { + if (!isChartData(item)) { + continue; + } + const stockPeriod = this.convertObjectToStockData(item, stockId); + await this.insertChartData(stockPeriod, period); + } + } + + /* eslint-disable-next-line max-lines-per-function */ + private getLiveDataSaveUntilEndCallback( + stockId: string, + period: Period, + end: string, + ) { + /* eslint-disable-next-line max-lines-per-function */ + return async (data: Json) => { + if (!data.output2 || !Array.isArray(data.output2)) return; + // 이거 빈값들어오는 케이스 있음(빈값 필터링 안하면 요청이 매우 많아짐) + data.output2 = data.output2.filter( + (data) => Object.keys(data).length !== 0, + ); + if (data.output2.length === 0) return; + await this.saveChartData(period, stockId, data.output2 as ChartData[]); + const { endDate, startDate } = this.updateDates(end, period); + const query = this.getItemChartPriceQuery( + stockId, + startDate, + endDate, + period, + ); + this.openApiQueue.enqueue({ + url: this.url, + query, + trId: TR_IDS.ITEM_CHART_PRICE, + callback: this.getLiveDataSaveUntilEndCallback( + stockId, + period, + endDate, + ), + }); + }; + } + private convertObjectToStockData(item: ChartData, stockId: string) { const stockPeriod = new StockData(); stockPeriod.stock = { id: stockId } as Stock; @@ -227,20 +240,6 @@ export class OpenapiPeriodData { return stockPeriod; } - private async saveChartData( - period: Period, - stockId: string, - data: ChartData[], - ) { - for (const item of data) { - if (!isChartData(item)) { - continue; - } - const stockPeriod = this.convertObjectToStockData(item, stockId); - await this.insertChartData(stockPeriod, period); - } - } - private getItemChartPriceQuery( stockId: string, startDate: string, diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 104a9d08..37d757ae 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -64,7 +64,6 @@ export class LiveData { return; } await this.openapiSubscribe(stockId); - if (!this.isCloseTime(new Date(), this.startTime, this.endTime)) { for (const [idx, size] of this.configSubscribeSize.entries()) { if (size >= this.SOCKET_LIMITS) continue; diff --git a/packages/backend/src/scraper/openapi/util/newDate.util.ts b/packages/backend/src/scraper/openapi/util/newDate.util.ts index 8c25439f..f12e762a 100644 --- a/packages/backend/src/scraper/openapi/util/newDate.util.ts +++ b/packages/backend/src/scraper/openapi/util/newDate.util.ts @@ -65,6 +65,7 @@ export class NewDate extends Date { ) { sameWeek = this.getDay() < dateToCompare.getDay(); } + return sameWeek; } return false; } From 930d537d16e419600817b35674c0ee36832db50f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Tue, 3 Dec 2024 17:59:01 +0900 Subject: [PATCH 179/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20plainT?= =?UTF-8?q?oInstance=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/dto/stockData.response.ts | 24 ++++ .../backend/src/stock/stockData.service.ts | 113 +++++++++--------- 2 files changed, 82 insertions(+), 55 deletions(-) diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts index 91133715..0f183d66 100644 --- a/packages/backend/src/stock/dto/stockData.response.ts +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; +import { StockData } from '@/stock/domain/stockData.entity'; export class PriceDto { @ApiProperty({ @@ -33,6 +34,14 @@ export class PriceDto { example: '123.45', }) close: number; + + constructor(stockData: StockData) { + this.startTime = stockData.startTime; + this.open = stockData.open; + this.high = stockData.high; + this.low = stockData.low; + this.close = stockData.close; + } } export class VolumeDto { @@ -49,6 +58,11 @@ export class VolumeDto { example: 1000, }) volume: number; + + constructor(stockData: StockData) { + this.startTime = stockData.startTime; + this.volume = stockData.volume; + } } export class StockDataResponse { @@ -71,4 +85,14 @@ export class StockDataResponse { example: true, }) hasMore: boolean; + + constructor( + priceDtoList: PriceDto[], + volumeDtoList: VolumeDto[], + hasMore: boolean, + ) { + this.priceDtoList = priceDtoList; + this.volumeDtoList = volumeDtoList; + this.hasMore = hasMore; + } } diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 2b9e3942..1247a319 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -1,5 +1,4 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { plainToInstance } from 'class-transformer'; import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { @@ -40,41 +39,25 @@ export class StockDataService { async getPaginated( entity: new () => StockData, - stock_id: string, + stockId: string, lastStartTime?: string, ): Promise { return await this.dataSource.manager.transaction(async (manager) => { - if (!(await this.isStockExist(stock_id, manager))) + if (!(await this.isStockExist(stockId, manager))) throw new NotFoundException('stock not found'); - const date = lastStartTime ? new Date(lastStartTime) : new Date(); - const cacheKey = `${entity.name}_${stock_id}_${getFormattedDate(date)}`; + const cacheKey = this.createCacheKey(entity, stockId, lastStartTime); const cachedData = this.stockDataCache.get(cacheKey); if (cachedData) { return cachedData; } - - const queryBuilder = manager - .createQueryBuilder(entity, 'entity') - .where('entity.stock_id = :stockId', { stockId: stock_id }) - .orderBy('entity.startTime', 'DESC') - .take(this.PAGE_SIZE + 1); - - if (lastStartTime) - queryBuilder.andWhere('entity.startTime < :lastStartTime', { - lastStartTime: lastStartTime, - }); - - const resultList = await queryBuilder.getMany(); - - const hasMore = resultList.length > this.PAGE_SIZE; - if (hasMore) resultList.pop(); - const priceDtoList = this.mapResultListToPriceDtoList(resultList); - const volumeDtoList = this.mapResultListToVolumeDtoList(resultList); - const response = this.createStockDataResponse( - priceDtoList, - volumeDtoList, - hasMore, + const queryBuilder = this.createQueryBuilder( + entity, + stockId, + manager, + lastStartTime, ); + const results = await queryBuilder.getMany(); + const response = this.convertResultsToResponse(results); this.stockDataCache.set(cacheKey, response); return response; }); @@ -84,40 +67,60 @@ export class StockDataService { return await manager.exists(Stock, { where: { id: stockId } }); } - mapResultListToPriceDtoList(resultList: StockData[]): PriceDto[] { - return resultList - .map((data: StockData) => ({ - startTime: data.startTime, - open: data.open, - close: data.close, - high: data.high, - low: data.low, - })) - .reverse(); + private createCacheKey( + entity: new () => StockData, + stockId: string, + lastStartTime?: string, + ) { + const date = lastStartTime ? new Date(lastStartTime) : new Date(); + return `${entity.name}_${stockId}_${getFormattedDate(date)}`; + } + + private convertResultsToResponse(results: StockData[]) { + const hasMore = results.length > this.PAGE_SIZE; + if (hasMore) results.pop(); + const prices = this.convertResultsToPriceDtoList(results); + const volumes = this.convertResultsToVolumeDtoList(results); + return new StockDataResponse(prices, volumes, hasMore); } - mapResultListToVolumeDtoList(resultList: StockData[]): VolumeDto[] { + private createQueryBuilder( + entity: new () => StockData, + stock_id: string, + manager: EntityManager, + lastStartTime?: string, + ) { + const queryBuilder = manager + .createQueryBuilder(entity, 'entity') + .where('entity.stock_id = :stockId', { stockId: stock_id }) + .orderBy('entity.startTime', 'DESC') + .take(this.PAGE_SIZE + 1); + + if (lastStartTime) + queryBuilder.andWhere('entity.startTime < :lastStartTime', { + lastStartTime: lastStartTime, + }); + return queryBuilder; + } + + private convertResultsToPriceDtoList(resultList: StockData[]): PriceDto[] { return resultList - .map((data) => ({ - startTime: data.startTime, - volume: data.volume, - })) + .reduce((acc: PriceDto[], stockData) => { + if (!stockData) return acc; + acc.push(new PriceDto(stockData)); + return acc; + }, []) .reverse(); } - createStockDataResponse( - priceDtoList: PriceDto[], - volumeDtoList: VolumeDto[], - hasMore: boolean, - ): StockDataResponse { - const priceData = plainToInstance(PriceDto, priceDtoList); - const volumeData = plainToInstance(VolumeDto, volumeDtoList); - - return plainToInstance(StockDataResponse, { - priceDtoList: priceData, - volumeDtoList: volumeData, - hasMore, - }); + private convertResultsToVolumeDtoList(resultList: StockData[]): VolumeDto[] { + return resultList + .reduce((acc: VolumeDto[], stockData) => { + if (!stockData) return acc; + acc.push(new VolumeDto(stockData)); + return acc; + }, []) + .reverse(); } } From bbb9b62e0af4e221761ef944fa3fd2a13a67ccaa Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 18:30:07 +0900 Subject: [PATCH 180/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=B6=84=EB=B4=89=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20callback=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EB=A1=9C=20=EB=B0=94=EA=BF=94=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=EC=88=9C=EC=9C=84=20=ED=81=90=20=EC=A0=81=EC=9A=A9,?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiMinuteData.api.ts | 158 ++++++++---------- 1 file changed, 70 insertions(+), 88 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 608d39cc..36577e44 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,49 +1,93 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; -import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; - +import { + Json, + OpenapiQueue, + OpenapiQueueNodeValue, +} from '../queue/openapi.queue'; import { isMinuteData, MinuteData, UpdateStockQuery, } from '../type/openapiMinuteData.type'; import { TR_IDS } from '../type/openapiUtil.type'; -import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; -import { OpenapiTokenApi } from './openapiToken.api'; +import { getCurrentTime } from '../util/openapiUtil.api'; +import { Alarm } from '@/alarm/domain/alarm.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; -const STOCK_CUT = 4; - @Injectable() export class OpenapiMinuteData { - private stock: Stock[][] = []; private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - private readonly intervals: number = 130; - private flip: number = 0; constructor( private readonly datasource: DataSource, - private readonly openApiToken: OpenapiTokenApi, - @Inject('winston') private readonly logger: Logger, + private readonly openapiQueue: OpenapiQueue, ) { - //this.getStockData(); + this.getStockMinuteData(); } - async getStockData() { + @Cron(`* 9-15 * * 1-5`) + async getStockMinuteData() { if (process.env.NODE_ENV !== 'production') return; - const stock = await this.datasource.manager.findBy(Stock, { - isTrading: true, - }); - const stockSize = Math.ceil(stock.length / STOCK_CUT); - let i = 0; - this.stock = []; - while (i < STOCK_CUT) { - this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); - i++; + const alarms = await this.datasource.manager + .getRepository(Alarm) + .createQueryBuilder('alarm') + .leftJoin('alarm.stock', 'stock') + .select('stock.id', 'stockId') + .addSelect('COUNT(alarm.id)', 'alarmCount') + .groupBy('stock.id') + .orderBy('alarmCount', 'DESC') + .execute(); + console.log(alarms); + for (const alarm of alarms) { + const time = getCurrentTime(); + const query = this.getUpdateStockQuery(alarm.stockId, time); + const node: OpenapiQueueNodeValue = { + url: this.url, + query, + trId: TR_IDS.MINUTE_DATA, + callback: this.getStockMinuteDataCallback(alarm.stockId, time), + }; + this.openapiQueue.enqueue(node); + } + } + + getStockMinuteDataCallback(stockId: string, time: string) { + return async (data: Json) => { + let output; + if (data.output2) output = data.output2; + if (output && output[0] && isMinuteData(output[0])) { + console.log(output); + this.saveMinuteData(stockId, output as MinuteData[], time); + } + }; + } + + private async saveMinuteData( + stockId: string, + item: MinuteData[], + time: string, + ) { + if (!this.isMarketOpenTime(time)) return; + const stockPeriod = item.map((val) => + this.convertResToMinuteData(stockId, val, time), + ); + for (const stock of stockPeriod) { + this.datasource.manager + .createQueryBuilder() + .insert() + .into(this.entity) + .values(stock) + .orUpdate( + ['id', 'close', 'low', 'high', 'open', 'volume', 'created_at'], + ['stock_id', 'start_time'], + ) + .execute(); } + //this.datasource.manager.save(this.entity, stockPeriod); } private convertResToMinuteData( @@ -71,70 +115,8 @@ export class OpenapiMinuteData { private isMarketOpenTime(time: string) { const numberTime = parseInt(time); - return numberTime >= 90000 && numberTime <= 153000; - } - - private async saveMinuteData( - stockId: string, - item: MinuteData[], - time: string, - ) { - const manager = this.datasource.manager; - if (!this.isMarketOpenTime(time)) return; - const stockPeriod = item.map((val) => - this.convertResToMinuteData(stockId, val, time), - ); - manager.save(this.entity, stockPeriod); - } - - private async getMinuteDataInterval( - stockId: string, - time: string, - config: typeof openApiConfig, - ) { - const query = this.getUpdateStockQuery(stockId, time); - try { - const response = await getOpenApi( - this.url, - config, - query, - TR_IDS.MINUTE_DATA, - ); - let output; - if (response.output2) output = response.output2; - if (output && output[0] && isMinuteData(output[0])) { - this.saveMinuteData(stockId, output, time); - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getMinuteDataChunk( - chunk: Stock[], - config: typeof openApiConfig, - ) { - const time = getCurrentTime(); - let interval = 0; - for await (const stock of chunk) { - setTimeout( - () => this.getMinuteDataInterval(stock.id!, time, config), - interval, - ); - interval += this.intervals; - } - } - - async getMinuteData() { - if (process.env.NODE_ENV !== 'production') return; - const configCount = (await this.openApiToken.configs()).length; - const stock = this.stock[this.flip % STOCK_CUT]; - this.flip++; - const chunkSize = Math.ceil(stock.length / configCount); - for (let i = 0; i < configCount; i++) { - const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, (await this.openApiToken.configs())[i]); - } + // 이거 바꿔놓음 + return numberTime >= 90000 && numberTime <= 183000; } private getUpdateStockQuery( From fc145a95beb8b552617208f95dae4348b39ef6a3 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 19:26:50 +0900 Subject: [PATCH 181/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=B6=84=EB=8B=A8=EC=9C=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20stock=20limit=20200,=20=EC=BD=9C=EB=B0=B1?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiMinuteData.api.ts | 46 ++++++++++---- .../src/scraper/openapi/liveData.service.ts | 2 +- .../openapi/type/openapiMinuteData.type.ts | 60 +++++++++++++++---- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 36577e44..e985a539 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,14 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; import { Json, OpenapiQueue, OpenapiQueueNodeValue, } from '../queue/openapi.queue'; import { - isMinuteData, + isMinuteDataOutput1, + isMinuteDataOutput2, MinuteData, + MinuteDataOutput1, + MinuteDataOutput2, UpdateStockQuery, } from '../type/openapiMinuteData.type'; import { TR_IDS } from '../type/openapiUtil.type'; @@ -22,9 +26,11 @@ export class OpenapiMinuteData { private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; + private readonly STOCK_LIMITS: number = 200; constructor( private readonly datasource: DataSource, private readonly openapiQueue: OpenapiQueue, + @Inject('winston') private readonly logger: Logger, ) { this.getStockMinuteData(); } @@ -40,8 +46,8 @@ export class OpenapiMinuteData { .addSelect('COUNT(alarm.id)', 'alarmCount') .groupBy('stock.id') .orderBy('alarmCount', 'DESC') + .limit(this.STOCK_LIMITS) .execute(); - console.log(alarms); for (const alarm of alarms) { const time = getCurrentTime(); const query = this.getUpdateStockQuery(alarm.stockId, time); @@ -57,12 +63,27 @@ export class OpenapiMinuteData { getStockMinuteDataCallback(stockId: string, time: string) { return async (data: Json) => { - let output; - if (data.output2) output = data.output2; - if (output && output[0] && isMinuteData(output[0])) { - console.log(output); - this.saveMinuteData(stockId, output as MinuteData[], time); + let output1: MinuteDataOutput1, output2: MinuteDataOutput2[]; + if (data.output1 && isMinuteDataOutput1(data.output1)) { + output1 = data.output1; + } else { + this.logger.info(`${stockId} has invalid minute data`); + return; } + if ( + data.output2 && + data.output2[0] && + isMinuteDataOutput2(data.output2[0]) + ) { + output2 = data.output2 as MinuteDataOutput2[]; + } else { + this.logger.info(`${stockId} has invalid minute data`); + return; + } + const minuteDatas: MinuteData[] = output2.map((val): MinuteData => { + return { acml_vol: output1.acml_vol, ...val }; + }); + await this.saveMinuteData(stockId, minuteDatas, time); }; } @@ -75,19 +96,18 @@ export class OpenapiMinuteData { const stockPeriod = item.map((val) => this.convertResToMinuteData(stockId, val, time), ); - for (const stock of stockPeriod) { + if (stockPeriod[0]) { this.datasource.manager .createQueryBuilder() .insert() .into(this.entity) - .values(stock) + .values(stockPeriod[0]) .orUpdate( ['id', 'close', 'low', 'high', 'open', 'volume', 'created_at'], ['stock_id', 'start_time'], ) .execute(); } - //this.datasource.manager.save(this.entity, stockPeriod); } private convertResToMinuteData( @@ -108,7 +128,7 @@ export class OpenapiMinuteData { stockPeriod.open = parseInt(item.stck_oprc); stockPeriod.high = parseInt(item.stck_hgpr); stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.cntg_vol); + stockPeriod.volume = parseInt(item.acml_vol); stockPeriod.createdAt = new Date(); return stockPeriod; } @@ -116,7 +136,7 @@ export class OpenapiMinuteData { private isMarketOpenTime(time: string) { const numberTime = parseInt(time); // 이거 바꿔놓음 - return numberTime >= 90000 && numberTime <= 183000; + return numberTime >= 90000 && numberTime <= 203000; } private getUpdateStockQuery( diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 37d757ae..cc483d43 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -130,7 +130,7 @@ export class LiveData { return; } const liveData = this.openapiLiveData.convertLiveData(message); - await this.openapiLiveData.saveLiveData(liveData[0]) + await this.openapiLiveData.saveLiveData(liveData[0]); } catch (error) { this.logger.warn(error); } diff --git a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts index 72fb4bfb..9a1088ce 100644 --- a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts @@ -1,35 +1,73 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -export type MinuteData = { +export type MinuteDataOutput1 = { + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + stck_prdy_clpr: string; + acml_vol: string; + acml_tr_pbmn: string; + hts_kor_isnm: string; + stck_prpr: string; +}; + +export type MinuteDataOutput2 = { stck_bsop_date: string; stck_cntg_hour: string; + acml_tr_pbmn: string; stck_prpr: string; stck_oprc: string; stck_hgpr: string; stck_lwpr: string; cntg_vol: string; +}; + +export type MinuteData = { + stck_bsop_date: string; + stck_cntg_hour: string; acml_tr_pbmn: string; + acml_vol: string; + stck_prpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + cntg_vol: string; }; -export type UpdateStockQuery = { - fid_etc_cls_code: string; - fid_cond_mrkt_div_code: 'J' | 'W'; - fid_input_iscd: string; - fid_input_hour_1: string; - fid_pw_data_incu_yn: 'Y' | 'N'; +export const isMinuteDataOutput1 = (data: any): data is MinuteDataOutput1 => { + return ( + data !== null && + typeof data === 'object' && + typeof data.prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.stck_prdy_clpr === 'string' && + typeof data.acml_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.hts_kor_isnm === 'string' && + typeof data.stck_prpr === 'string' + ); }; -export const isMinuteData = (data: any) => { +export const isMinuteDataOutput2 = (data: any): data is MinuteDataOutput2 => { return ( - data && + data !== null && typeof data === 'object' && typeof data.stck_bsop_date === 'string' && typeof data.stck_cntg_hour === 'string' && + typeof data.acml_tr_pbmn === 'string' && typeof data.stck_prpr === 'string' && typeof data.stck_oprc === 'string' && typeof data.stck_hgpr === 'string' && typeof data.stck_lwpr === 'string' && - typeof data.cntg_vol === 'string' && - typeof data.acml_tr_pbmn === 'string' + typeof data.cntg_vol === 'string' ); }; + +export type UpdateStockQuery = { + fid_etc_cls_code: string; + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_hour_1: string; + fid_pw_data_incu_yn: 'Y' | 'N'; +}; From fc840c985fd2014291395e2444ab6d8b2d795af9 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 19:33:24 +0900 Subject: [PATCH 182/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20afterUpdate=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EC=9C=84=ED=95=9C=20upsert=EA=B5=AC?= =?UTF-8?q?=EB=AC=B8=EC=9C=BC=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 --- .../scraper/openapi/api/openapiMinuteData.api.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index e985a539..69a5f865 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -97,16 +97,10 @@ export class OpenapiMinuteData { this.convertResToMinuteData(stockId, val, time), ); if (stockPeriod[0]) { - this.datasource.manager - .createQueryBuilder() - .insert() - .into(this.entity) - .values(stockPeriod[0]) - .orUpdate( - ['id', 'close', 'low', 'high', 'open', 'volume', 'created_at'], - ['stock_id', 'start_time'], - ) - .execute(); + this.datasource.manager.upsert(this.entity, stockPeriod[0], [ + 'stock.id', + 'startTime', + ]); } } From 9ae9f3a36fdaf9ce050e186c1d5d32d89fa5387f Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 22:18:30 +0900 Subject: [PATCH 183/223] =?UTF-8?q?=F0=9F=92=84=20style:=20=EB=B6=84?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=A1=B0=EA=B1=B4=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/api/openapiMinuteData.api.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 69a5f865..a36fd213 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -31,9 +31,7 @@ export class OpenapiMinuteData { private readonly datasource: DataSource, private readonly openapiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, - ) { - this.getStockMinuteData(); - } + ) {} @Cron(`* 9-15 * * 1-5`) async getStockMinuteData() { @@ -129,8 +127,7 @@ export class OpenapiMinuteData { private isMarketOpenTime(time: string) { const numberTime = parseInt(time); - // 이거 바꿔놓음 - return numberTime >= 90000 && numberTime <= 203000; + return numberTime >= 90000 && numberTime <= 153000; } private getUpdateStockQuery( From 3ae677ceee20c14cb595aa1ffc0dd1eb94485dde Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 22:19:00 +0900 Subject: [PATCH 184/223] =?UTF-8?q?=F0=9F=92=84=20style:=20dto=20=EC=95=88?= =?UTF-8?q?=20=EC=93=B0=EC=9D=B4=EB=8A=94=20=EC=86=8D=EC=84=B1=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/alarm/dto/subscribe.request.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/backend/src/alarm/dto/subscribe.request.ts b/packages/backend/src/alarm/dto/subscribe.request.ts index 1ed453e0..a049184c 100644 --- a/packages/backend/src/alarm/dto/subscribe.request.ts +++ b/packages/backend/src/alarm/dto/subscribe.request.ts @@ -1,10 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -//import { User } from '@/user/domain/user.entity'; export class SubscriptionData { - //@ApiProperty({ type: () => User, description: '유저 아이디' }) - //user: User; - @ApiProperty({ type: 'string', description: '엔드 포인트 설정', From 31d2ef70e6c91e2015a7281bf049daeaa79123b6 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 22:19:40 +0900 Subject: [PATCH 185/223] =?UTF-8?q?=F0=9F=92=84=20style:=20console.log=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/alarm/alarm.subscriber.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/alarm/alarm.subscriber.ts b/packages/backend/src/alarm/alarm.subscriber.ts index f5e15a6b..3f320b73 100644 --- a/packages/backend/src/alarm/alarm.subscriber.ts +++ b/packages/backend/src/alarm/alarm.subscriber.ts @@ -28,13 +28,13 @@ export class AlarmSubscriber } isValidAlarm(alarm: Alarm, entity: StockMinutely) { - if (alarm.alarmDate && alarm.alarmDate > entity.createdAt) { + if (alarm.alarmDate && alarm.alarmDate >= entity.createdAt) { return false; } else { - if (alarm.targetPrice && alarm.targetPrice >= entity.open) { + if (alarm.targetPrice && alarm.targetPrice <= entity.open) { return true; } - if (alarm.targetVolume && alarm.targetVolume >= entity.volume) { + if (alarm.targetVolume && alarm.targetVolume <= entity.volume) { return true; } return false; @@ -48,7 +48,6 @@ export class AlarmSubscriber where: { stock: { id: stockMinutely.stock.id } }, relations: ['user', 'stock'], }); - const alarms = rawAlarms.filter((val) => this.isValidAlarm(val, stockMinutely), ); From 72edf0dff3d63b1df18596bb6b4638250054abd4 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 22:23:46 +0900 Subject: [PATCH 186/223] =?UTF-8?q?=F0=9F=93=9D=20docs:=20liveData?= =?UTF-8?q?=EC=97=90=20unsubscribe,=20subscribe=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20info=EB=A1=9C=20=EC=B6=9C=EB=A0=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/scraper/openapi/liveData.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index cc483d43..83b0fb91 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -75,6 +75,7 @@ export class LiveData { stockId, '1', ); + this.logger.info(`${idx} : ${message}`); this.websocketClient[idx].subscribe(message); return; } @@ -99,7 +100,7 @@ export class LiveData { stockId, '2', ); - + this.logger.info(`${idx} : ${message}`); this.websocketClient[idx].unsubscribe(message); } } From 65ecaaaddf1a50a65b404d217cb7d2ae402cf242 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Tue, 3 Dec 2024 22:28:36 +0900 Subject: [PATCH 187/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=EC=9D=84=20=ED=95=9C=EB=B2=88=EB=A7=8C=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B3=A0=20=EC=82=AD=EC=A0=9C=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=A7=8C=EB=93=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/alarm/alarm.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/alarm/alarm.service.ts b/packages/backend/src/alarm/alarm.service.ts index a2b214ce..d8e6343d 100644 --- a/packages/backend/src/alarm/alarm.service.ts +++ b/packages/backend/src/alarm/alarm.service.ts @@ -110,6 +110,8 @@ export class AlarmService { for (const subscription of subscriptions) { await this.pushService.sendPushNotification(subscription, payload); + //한번만 보내고 삭제하게 처리. + this.alarmRepository.delete(alarm.id); } } } From c9d9ff44144a14cdaae149e97e8ba74107a118d4 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 00:22:30 +0900 Subject: [PATCH 188/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=ED=81=90=EC=97=90=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiPeriodData.api.ts | 67 ++++++++++++------- .../openapi/type/openapiPeriodData.type.ts | 2 +- .../src/scraper/openapi/util/newDate.util.ts | 57 +++++++++------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 0e5f964e..c047bb04 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -10,11 +10,7 @@ import { } from '../type/openapiPeriodData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { NewDate } from '../util/newDate.util'; -import { - getOpenApi, - getPreviousDate, - getTodayDate, -} from '../util/openapiUtil.api'; +import { getPreviousDate, getTodayDate } from '../util/openapiUtil.api'; import { OpenapiTokenApi } from './openapiToken.api'; import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { Stock } from '@/stock/domain/stock.entity'; @@ -33,15 +29,13 @@ const DATE_TO_ENTITY = { Y: StockYearly, } as const; -const DATE_TO_MONTH = { +export const DATE_TO_MONTH = { D: 1, W: 6, M: 24, Y: 120, } as const; -const INTERVALS = 10000; - @Injectable() export class OpenapiPeriodData { private readonly url: string = @@ -69,6 +63,42 @@ export class OpenapiPeriodData { await this.getChartData(stocks, 'D'); } + getInsertCartDataRequestCallback( + resolve: (value: StockData[]) => void, + stockId: string, + period: Period, + ) { + return async (data: Json) => { + if (!data.output2 || !Array.isArray(data.output2)) return resolve([]); + const result = data.output2 + .reduce((acc: StockData[], item: Record) => { + if (!isChartData(item)) return acc; + const stockData = this.convertObjectToStockData(item, stockId); + acc.push(stockData); + this.insertChartData(stockData, period); + return acc; + }, []) + .reverse(); + resolve(result); + }; + } + + insertCartDataRequest( + resolve: (value: StockData[]) => void, + stockId: string, + period: Period, + ): void { + const end = getTodayDate(); + const start = getPreviousDate(end, DATE_TO_MONTH[period]); + const query = this.getItemChartPriceQuery(stockId, start, end, period); + this.openApiQueue.enqueue({ + url: this.url, + query, + trId: TR_IDS.ITEM_CHART_PRICE, + callback: this.getInsertCartDataRequestCallback(resolve, stockId, period), + }); + } + /** * 월, 년의 경우 마지막 데이터를 업데이트 하는 형식으로 변경해야됨 */ @@ -139,21 +169,6 @@ export class OpenapiPeriodData { }); } - private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { - try { - const response = await getOpenApi( - this.url, - (await this.openApiToken.configs())[configIdx], - query, - TR_IDS.ITEM_CHART_PRICE, - ); - return response.output2 as ChartData[]; - } catch (error) { - this.logger.warn(error); - setTimeout(() => this.fetchChartData(query, configIdx), INTERVALS / 10); - } - } - private updateDates( endDate: string, period: Period, @@ -174,11 +189,11 @@ export class OpenapiPeriodData { } private isSamePeriod(stock: StockData, period: Period, date: Date) { - this.logger.info(date); - this.logger.info(stock.startTime); return ( (period === 'W' && new NewDate(stock.startTime).isSameWeek(date)) || - (period === 'M' && new NewDate(stock.startTime).isSameMonth(date)) || + (period === 'M' && + new NewDate(stock.startTime).isSameMonth(date) && + new NewDate(stock.startTime).isSameYear(date)) || (period === 'Y' && new NewDate(stock.startTime).isSameYear(date)) ); } diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts index 6c47bdd6..ae79b46c 100644 --- a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts @@ -25,7 +25,7 @@ export type ItemChartPriceQuery = { fid_org_adj_prc: number; }; -export const isChartData = (data?: any) => { +export const isChartData = (data?: any): data is ChartData => { return ( data && typeof data === 'object' && diff --git a/packages/backend/src/scraper/openapi/util/newDate.util.ts b/packages/backend/src/scraper/openapi/util/newDate.util.ts index 8c25439f..e043eef8 100644 --- a/packages/backend/src/scraper/openapi/util/newDate.util.ts +++ b/packages/backend/src/scraper/openapi/util/newDate.util.ts @@ -15,30 +15,6 @@ export class NewDate extends Date { ); } - private resetTime(): NewDate { - this.setHours(0, 0, 0, 0); - return this; - } - - private getWeek(): number { - const date = this.resetTime(); - const firstOne = new Date(date.getFullYear(), 0, 1); - return Math.ceil( - ((date.getTime() - firstOne.getTime()) / 86400000 + - firstOne.getDay() + - 1) / - 7, - ); - } - - private isLastWeekOfTheYear(): boolean { - return this.getWeek() === 53; - } - - private isFirstWeekOfTheYear(): boolean { - return this.getWeek() === 1; - } - dateToNewDate(date: Date): NewDate { return new NewDate(date); } @@ -65,6 +41,7 @@ export class NewDate extends Date { ) { sameWeek = this.getDay() < dateToCompare.getDay(); } + return sameWeek; } return false; } @@ -76,4 +53,36 @@ export class NewDate extends Date { isSameYear(dateToCompare: NewDate | Date): boolean { return this.getFullYear() === dateToCompare.getFullYear(); } + + isSameDate(dateToCompare: NewDate | Date): boolean { + return ( + this.getFullYear() === dateToCompare.getFullYear() && + this.getMonth() === dateToCompare.getMonth() && + this.getDate() === dateToCompare.getDate() + ); + } + + private resetTime(): NewDate { + this.setHours(0, 0, 0, 0); + return this; + } + + private getWeek(): number { + const date = this.resetTime(); + const firstOne = new Date(date.getFullYear(), 0, 1); + return Math.ceil( + ((date.getTime() - firstOne.getTime()) / 86400000 + + firstOne.getDay() + + 1) / + 7, + ); + } + + private isLastWeekOfTheYear(): boolean { + return this.getWeek() === 53; + } + + private isFirstWeekOfTheYear(): boolean { + return this.getWeek() === 1; + } } From ec61238867b94f07af7f958b0fbd0192ec97ad5e Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 00:36:44 +0900 Subject: [PATCH 189/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=20=EC=B0=A8=ED=8A=B8=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20api=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=99=80?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/openapi/openapi-scraper.module.ts | 3 +- packages/backend/src/stock/stock.module.ts | 2 + .../backend/src/stock/stockData.service.ts | 99 ++++++++++++++++--- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index f9bed60e..32d975f8 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -43,7 +43,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; ], controllers: [], providers: [ - LiveData, OpenapiLiveData, OpenapiTokenApi, OpenapiPeriodData, @@ -58,6 +57,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; WebsocketClient, LiveData, ], - exports: [LiveData], + exports: [LiveData, OpenapiPeriodData], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index adaa18f0..23921a3c 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -26,6 +26,7 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; import { StockRateIndexService } from './stockRateIndex.service'; import { AlarmModule } from '@/alarm/alarm.module'; import { Alarm } from '@/alarm/domain/alarm.entity'; +import { OpenapiScraperModule } from '@/scraper/openapi/openapi-scraper.module'; import { ScraperModule } from '@/scraper/scraper.module'; import { StockDataCache } from '@/stock/cache/stockData.cache'; @@ -44,6 +45,7 @@ import { StockDataCache } from '@/stock/cache/stockData.cache'; ]), AlarmModule, ScraperModule, + OpenapiScraperModule, ], controllers: [StockController], providers: [ diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 1247a319..38638943 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { @@ -13,6 +17,9 @@ import { StockDataResponse, VolumeDto, } from './dto/stockData.response'; +import { OpenapiPeriodData } from '@/scraper/openapi/api/openapiPeriodData.api'; +import { Period } from '@/scraper/openapi/type/openapiPeriodData.type'; +import { NewDate } from '@/scraper/openapi/util/newDate.util'; import { StockDataCache } from '@/stock/cache/stockData.cache'; import { getFormattedDate } from '@/utils/date'; @@ -35,6 +42,7 @@ export class StockDataService { constructor( private readonly dataSource: DataSource, private readonly stockDataCache: StockDataCache, + private readonly openapiPeriodData: OpenapiPeriodData, ) {} async getPaginated( @@ -57,6 +65,29 @@ export class StockDataService { lastStartTime, ); const results = await queryBuilder.getMany(); + const lastData = results[0]; + const periodType = this.getPeriodType(entity); + if (!periodType) throw new BadRequestException('period type not found'); + if ( + !lastStartTime && + (!lastData || !this.isLastDate(lastData, periodType)) + ) { + return new Promise((resolve) => { + this.openapiPeriodData.insertCartDataRequest( + (value) => { + const index = this.findExistDataIndex(value, lastData); + const response = this.convertResultsToResponse([ + ...value.slice(index + 1).reverse(), + ...results, + ]); + this.stockDataCache.set(cacheKey, response); + resolve(response); + }, + stockId, + periodType, + ); + }); + } const response = this.convertResultsToResponse(results); this.stockDataCache.set(cacheKey, response); return response; @@ -67,6 +98,32 @@ export class StockDataService { return await manager.exists(Stock, { where: { id: stockId } }); } + private isLastDate(lastData: StockData, period: Period) { + const lastDate = new NewDate(lastData.startTime); + const current = new Date(); + if (period === 'D') return lastDate.isSameDate(current); + if (period === 'M') { + return lastDate.isSameWeek(current) && lastDate.isSameYear(current); + } + if (period === 'Y') return lastDate.isSameYear(current); + return lastDate.isSameWeek(current); + } + + private findExistDataIndex(responseData: StockData[], lastData: StockData) { + if (!lastData) return -1; + const lastDate = new NewDate(lastData.startTime); + return responseData.findIndex((data) => + lastDate.isSameDate(data.startTime), + ); + } + + private getPeriodType(entity: new () => StockData) { + if (entity === StockDaily) return 'D'; + if (entity === StockWeekly) return 'W'; + if (entity === StockMonthly) return 'M'; + if (entity === StockYearly) return 'Y'; + } + private createCacheKey( entity: new () => StockData, stockId: string, @@ -126,8 +183,12 @@ export class StockDataService { @Injectable() export class StockDataMinutelyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); + constructor( + dataSource: DataSource, + stockDataCache: StockDataCache, + openapiPeriodData: OpenapiPeriodData, + ) { + super(dataSource, stockDataCache, openapiPeriodData); } async getStockDataMinutely( stock_id: string, @@ -139,8 +200,12 @@ export class StockDataMinutelyService extends StockDataService { @Injectable() export class StockDataDailyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); + constructor( + dataSource: DataSource, + stockDataCache: StockDataCache, + openapiPeriodData: OpenapiPeriodData, + ) { + super(dataSource, stockDataCache, openapiPeriodData); } async getStockDataDaily( stock_id: string, @@ -152,8 +217,12 @@ export class StockDataDailyService extends StockDataService { @Injectable() export class StockDataWeeklyService extends StockDataService { - constructor(dataSource: DataSource, sockDataCache: StockDataCache) { - super(dataSource, sockDataCache); + constructor( + dataSource: DataSource, + stockDataCache: StockDataCache, + openapiPeriodData: OpenapiPeriodData, + ) { + super(dataSource, stockDataCache, openapiPeriodData); } async getStockDataWeekly( stock_id: string, @@ -165,8 +234,12 @@ export class StockDataWeeklyService extends StockDataService { @Injectable() export class StockDataMonthlyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); + constructor( + dataSource: DataSource, + stockDataCache: StockDataCache, + openapiPeriodData: OpenapiPeriodData, + ) { + super(dataSource, stockDataCache, openapiPeriodData); } async getStockDataMonthly( stock_id: string, @@ -178,8 +251,12 @@ export class StockDataMonthlyService extends StockDataService { @Injectable() export class StockDataYearlyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); + constructor( + dataSource: DataSource, + stockDataCache: StockDataCache, + openapiPeriodData: OpenapiPeriodData, + ) { + super(dataSource, stockDataCache, openapiPeriodData); } async getStockDataYearly( stock_id: string, From 798f977d8568bd8b33131bb9c6673c26142abbc0 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 00:57:21 +0900 Subject: [PATCH 190/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A4=91=20?= =?UTF-8?q?=ED=95=9C=EB=B2=88=EC=97=90=20=EB=A7=8E=EC=9D=80=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=ED=82=A4=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/openapi/api/openapiPeriodData.api.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index c047bb04..c041a826 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { DataSource, QueryFailedError } from 'typeorm'; import { Logger } from 'winston'; import { ChartData, @@ -75,7 +75,18 @@ export class OpenapiPeriodData { if (!isChartData(item)) return acc; const stockData = this.convertObjectToStockData(item, stockId); acc.push(stockData); - this.insertChartData(stockData, period); + this.insertChartData(stockData, period).catch((e) => { + if ( + e instanceof QueryFailedError && + e.driverError.message.includes('duplicate') + ) { + this.logger.warn( + `when insert missing chart data, duplicate error :${stockId}`, + ); + return; + } + this.logger.error(e); + }); return acc; }, []) .reverse(); From 333c81224f7b182e112aeb00bb3d6a7ab5560da4 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 01:11:38 +0900 Subject: [PATCH 191/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=EB=90=9C=20=EC=B0=A8=ED=8A=B8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/constants/timeunit.ts | 9 ++ .../backend/src/stock/stock.controller.ts | 53 ++++------- packages/backend/src/stock/stock.module.ts | 14 +-- .../backend/src/stock/stockData.service.ts | 88 +------------------ 4 files changed, 30 insertions(+), 134 deletions(-) create mode 100644 packages/backend/src/stock/constants/timeunit.ts diff --git a/packages/backend/src/stock/constants/timeunit.ts b/packages/backend/src/stock/constants/timeunit.ts new file mode 100644 index 00000000..e0123aae --- /dev/null +++ b/packages/backend/src/stock/constants/timeunit.ts @@ -0,0 +1,9 @@ +export const TIME_UNIT = { + MINUTE: 'minute', + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', +} as const; + +export type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 08198b90..495365eb 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -23,18 +23,12 @@ import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; import { StockIndexRateResponse } from './dto/stockIndexRate.response'; import { StockService } from './stock.service'; -import { - StockDataDailyService, - StockDataMinutelyService, - StockDataMonthlyService, - StockDataWeeklyService, - StockDataYearlyService, -} from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockRateIndexService } from './stockRateIndex.service'; import SessionGuard from '@/auth/session/session.guard'; import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; +import { TIME_UNIT } from '@/stock/constants/timeunit'; import { StockSearchRequest } from '@/stock/dto/stock.request'; import { StockRankResponses, @@ -52,16 +46,13 @@ import { UserStocksResponse, } from '@/stock/dto/userStock.response'; import { User } from '@/user/domain/user.entity'; - -const TIME_UNIT = { - MINUTE: 'minute', - DAY: 'day', - WEEK: 'week', - MONTH: 'month', - YEAR: 'year', -} as const; - -type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; +import { StockDataService } from '@/stock/stockData.service'; +import { + StockDaily, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; const FLUCTUATION_TYPE = { INCREASE: 'increase', @@ -76,13 +67,9 @@ type FLUCTUATION_TYPE = export class StockController { constructor( private readonly stockService: StockService, - private readonly stockDataMinutelyService: StockDataMinutelyService, - private readonly stockDataDailyService: StockDataDailyService, - private readonly stockDataWeeklyService: StockDataWeeklyService, - private readonly stockDataMonthlyService: StockDataMonthlyService, - private readonly stockDataYearlyService: StockDataYearlyService, private readonly stockDetailService: StockDetailService, private readonly stockRateIndexService: StockRateIndexService, + private readonly stockDataService: StockDataService, ) {} @HttpCode(200) @@ -289,11 +276,9 @@ export class StockController { async getStockDataDaily( @Param('stockId') stockId: string, @Query('lastStartTime') lastStartTime?: string, - @Query('timeunit') timeunit: TIME_UNIT = TIME_UNIT.MINUTE, + @Query('timeunit') timeunit: TIME_UNIT = TIME_UNIT.DAY, ) { switch (timeunit) { - case TIME_UNIT.MINUTE: - return this.getMinutelyData(stockId, lastStartTime); case TIME_UNIT.DAY: return this.getDailyData(stockId, lastStartTime); case TIME_UNIT.MONTH: @@ -309,7 +294,8 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataYearlyService.getStockDataYearly( + return this.stockDataService.getPaginated( + StockYearly, stockId, lastStartTime, ); @@ -319,7 +305,8 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataWeeklyService.getStockDataWeekly( + return this.stockDataService.getPaginated( + StockWeekly, stockId, lastStartTime, ); @@ -329,20 +316,18 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataMonthlyService.getStockDataMonthly( + return this.stockDataService.getPaginated( + StockMonthly, stockId, lastStartTime, ); } - private getMinutelyData(stockId: string, lastStartTime?: string) { - return this.stockDataMinutelyService.getStockDataMinutely( + private getDailyData(stockId: string, lastStartTime?: string) { + return this.stockDataService.getPaginated( + StockDaily, stockId, lastStartTime, ); } - - private getDailyData(stockId: string, lastStartTime?: string) { - return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); - } } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 23921a3c..3a218e5a 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -13,14 +13,6 @@ import { StockLiveData } from './domain/stockLiveData.entity'; import { StockController } from './stock.controller'; import { StockGateway } from './stock.gateway'; import { StockService } from './stock.service'; -import { - StockDataDailyService, - StockDataMinutelyService, - StockDataMonthlyService, - StockDataService, - StockDataWeeklyService, - StockDataYearlyService, -} from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; import { StockRateIndexService } from './stockRateIndex.service'; @@ -29,6 +21,7 @@ import { Alarm } from '@/alarm/domain/alarm.entity'; import { OpenapiScraperModule } from '@/scraper/openapi/openapi-scraper.module'; import { ScraperModule } from '@/scraper/scraper.module'; import { StockDataCache } from '@/stock/cache/stockData.cache'; +import { StockDataService } from '@/stock/stockData.service'; @Module({ imports: [ @@ -54,11 +47,6 @@ import { StockDataCache } from '@/stock/cache/stockData.cache'; StockGateway, StockLiveDataSubscriber, StockDataService, - StockDataDailyService, - StockDataMinutelyService, - StockDataWeeklyService, - StockDataYearlyService, - StockDataMonthlyService, StockDetailService, StockRateIndexService, ], diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 38638943..0bccece3 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -7,7 +7,6 @@ import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { StockDaily, - StockMinutely, StockMonthly, StockWeekly, StockYearly, @@ -179,89 +178,4 @@ export class StockDataService { }, []) .reverse(); } -} - -@Injectable() -export class StockDataMinutelyService extends StockDataService { - constructor( - dataSource: DataSource, - stockDataCache: StockDataCache, - openapiPeriodData: OpenapiPeriodData, - ) { - super(dataSource, stockDataCache, openapiPeriodData); - } - async getStockDataMinutely( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMinutely, stock_id, lastStartTime); - } -} - -@Injectable() -export class StockDataDailyService extends StockDataService { - constructor( - dataSource: DataSource, - stockDataCache: StockDataCache, - openapiPeriodData: OpenapiPeriodData, - ) { - super(dataSource, stockDataCache, openapiPeriodData); - } - async getStockDataDaily( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockDaily, stock_id, lastStartTime); - } -} - -@Injectable() -export class StockDataWeeklyService extends StockDataService { - constructor( - dataSource: DataSource, - stockDataCache: StockDataCache, - openapiPeriodData: OpenapiPeriodData, - ) { - super(dataSource, stockDataCache, openapiPeriodData); - } - async getStockDataWeekly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockWeekly, stock_id, lastStartTime); - } -} - -@Injectable() -export class StockDataMonthlyService extends StockDataService { - constructor( - dataSource: DataSource, - stockDataCache: StockDataCache, - openapiPeriodData: OpenapiPeriodData, - ) { - super(dataSource, stockDataCache, openapiPeriodData); - } - async getStockDataMonthly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMonthly, stock_id, lastStartTime); - } -} - -@Injectable() -export class StockDataYearlyService extends StockDataService { - constructor( - dataSource: DataSource, - stockDataCache: StockDataCache, - openapiPeriodData: OpenapiPeriodData, - ) { - super(dataSource, stockDataCache, openapiPeriodData); - } - async getStockDataYearly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockYearly, stock_id, lastStartTime); - } -} +} \ No newline at end of file From c74e18349db6aaf2fbf8e8691e73374933d63c4f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 01:12:28 +0900 Subject: [PATCH 192/223] =?UTF-8?q?=E2=9C=85=20test:=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/stockData.service.spec.ts | 495 ------------------ 1 file changed, 495 deletions(-) delete mode 100644 packages/backend/src/stock/stockData.service.spec.ts diff --git a/packages/backend/src/stock/stockData.service.spec.ts b/packages/backend/src/stock/stockData.service.spec.ts deleted file mode 100644 index 1858a036..00000000 --- a/packages/backend/src/stock/stockData.service.spec.ts +++ /dev/null @@ -1,495 +0,0 @@ -/* eslint-disable max-lines-per-function */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { plainToInstance } from 'class-transformer'; -import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; -import { Stock } from './domain/stock.entity'; -import { - StockMinutely, - StockDaily, - StockWeekly, - StockMonthly, - StockYearly, -} from './domain/stockData.entity'; -import { - StockDataResponse, - PriceDto, - VolumeDto, -} from './dto/stockData.response'; -import { StockDataService } from './stockData.service'; - -// Mock DataSource와 EntityManager 생성 함수 -export function createDataSourceMock( - managerMock: Partial, -): Partial { - return { - manager: { - ...managerMock, - transaction: jest.fn().mockImplementation(async (work) => { - return work(managerMock as EntityManager); - }), - } as any, // TypeScript 오류를 피하기 위해 any로 캐스팅 - }; -} - -// QueryBuilder 모킹을 위한 헬퍼 함수 -const createQueryBuilderMock = ( - getManyResult: any[] = [], - throwError: boolean = false, -): Partial> => { - return { - leftJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getMany: jest.fn().mockImplementation(() => { - if (throwError) { - return Promise.reject(new Error('Query error')); - } - return Promise.resolve(getManyResult); - }), - }; -}; - -describe('StockDataService', () => { - const stockId = 'A005930'; - let dataSource: Partial; - let stockDataService: StockDataService; - let managerMock: any; - - beforeEach(() => { - managerMock = { - createQueryBuilder: jest.fn(), - exists: jest.fn().mockResolvedValue(true), - }; - dataSource = createDataSourceMock(managerMock); - stockDataService = new StockDataService(dataSource as DataSource); - }); - - describe('getPaginated', () => { - const PAGE_SIZE = 100; - - it('주식이 존재하지 않을 경우 NotFoundException을 던집니다.', async () => { - managerMock.exists.mockResolvedValue(false); - - await expect( - stockDataService.getPaginated(StockMinutely, stockId), - ).rejects.toThrow('stock not found'); - }); - - it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=true', async () => { - const mockData: any[] = Array.from({ length: PAGE_SIZE + 1 }, (_, i) => ({ - id: i, - close: 100 + i, - low: 90 + i, - high: 110 + i, - open: 95 + i, - volume: 1000 + i * 10, - startTime: new Date( - `2023-11-${(10 + (i % 20)).toString().padStart(2, '0')}`, - ), - stock: { id: stockId } as Stock, - createdAt: new Date(), - })); - - const queryBuilderMock = createQueryBuilderMock(mockData); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - const response: StockDataResponse = await stockDataService.getPaginated( - StockMinutely, - stockId, - ); - - expect(managerMock.createQueryBuilder).toHaveBeenCalledWith( - StockMinutely, - 'entity', - ); - expect(queryBuilderMock.where).toHaveBeenCalledWith( - 'entity.stock_id = :stockId', - { stockId }, - ); - expect(queryBuilderMock.orderBy).toHaveBeenCalledWith( - 'entity.startTime', - 'DESC', - ); - expect(queryBuilderMock.take).toHaveBeenCalledWith(PAGE_SIZE + 1); - expect(response.hasMore).toBe(true); - expect(response.priceDtoList).toHaveLength(PAGE_SIZE); - expect(response.volumeDtoList).toHaveLength(PAGE_SIZE); - }); - - it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=false', async () => { - const mockData: any[] = Array.from({ length: PAGE_SIZE }, (_, i) => ({ - id: i, - close: 100 + i, - low: 90 + i, - high: 110 + i, - open: 95 + i, - volume: 1000 + i * 10, - startTime: new Date( - `2023-11-${(10 + (i % 20)).toString().padStart(2, '0')}`, - ), - stock: { id: stockId } as Stock, - createdAt: new Date(), - })); - - const queryBuilderMock = createQueryBuilderMock(mockData); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - const response: StockDataResponse = await stockDataService.getPaginated( - StockMinutely, - stockId, - ); - - expect(response.hasMore).toBe(false); - expect(response.priceDtoList).toHaveLength(PAGE_SIZE); - expect(response.volumeDtoList).toHaveLength(PAGE_SIZE); - }); - - it('lastStartTime을 사용해 이전 데이터까지 페이지네이션 가져오기', async () => { - const lastStartTime = '2023-11-15'; - const mockData: any[] = Array.from({ length: 50 }, (_, i) => ({ - id: i, - close: 100 + i, - low: 90 + i, - high: 110 + i, - open: 95 + i, - volume: 1000 + i * 10, - startTime: new Date( - `2023-11-${(15 - (i % 15)).toString().padStart(2, '0')}`, - ), - stock: { id: stockId } as Stock, - createdAt: new Date(), - })); - - const queryBuilderMock = createQueryBuilderMock(mockData); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - const response: StockDataResponse = await stockDataService.getPaginated( - StockMinutely, - stockId, - lastStartTime, - ); - - expect(queryBuilderMock.andWhere).toHaveBeenCalledWith( - 'entity.startTime < :lastStartTime', - { lastStartTime }, - ); - expect(response.hasMore).toBe(false); - expect(response.priceDtoList).toHaveLength(50); - expect(response.volumeDtoList).toHaveLength(50); - }); - - it('쿼리에서 예외가 발생하면 예외를 던집니다.', async () => { - const queryBuilderMock = createQueryBuilderMock([], true); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - await expect( - stockDataService.getPaginated(StockMinutely, stockId), - ).rejects.toThrow('Query error'); - }); - }); - - describe('mapResultListToPriceDtoList', () => { - it('StockData 목록을 PriceDto 목록으로 매핑합니다.', () => { - const resultList = [ - { - id: 2, - startTime: new Date('2023-11-11T00:00:00Z'), - open: 105, - close: 115, - high: 120, - low: 100, - volume: 600, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - { - id: 1, - startTime: new Date('2023-11-10T00:00:00Z'), - open: 100, - close: 110, - high: 115, - low: 95, - volume: 500, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - ]; - - const priceDtoList: PriceDto[] = - stockDataService.mapResultListToPriceDtoList(resultList); - - expect(priceDtoList).toEqual([ - { - startTime: new Date('2023-11-10T00:00:00Z'), - open: 100, - close: 110, - high: 115, - low: 95, - }, - { - startTime: new Date('2023-11-11T00:00:00Z'), - open: 105, - close: 115, - high: 120, - low: 100, - }, - ]); - }); - }); - - describe('mapResultListToVolumeDtoList', () => { - it('StockData 목록을 VolumeDto 목록으로 매핑합니다.', () => { - const resultList = [ - { - id: 3, - startTime: new Date('2023-11-12T00:00:00Z'), - close: 110, - low: 95, - high: 125, - open: 115, - volume: 550, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - { - id: 2, - startTime: new Date('2023-11-11T00:00:00Z'), - close: 105, - low: 100, - high: 120, - open: 115, - volume: 600, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - { - id: 1, - startTime: new Date('2023-11-10T00:00:00Z'), - close: 100, - low: 90, - high: 110, - open: 95, - volume: 500, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - ]; - - const volumeDtoList: VolumeDto[] = - stockDataService.mapResultListToVolumeDtoList(resultList); - - expect(volumeDtoList).toEqual([ - { - startTime: new Date('2023-11-10T00:00:00Z'), - volume: 500, - }, - - { - startTime: new Date('2023-11-11T00:00:00Z'), - volume: 600, - }, - { - startTime: new Date('2023-11-12T00:00:00Z'), - volume: 550, - }, - ]); - }); - }); - - describe('createStockDataResponse', () => { - it('PriceDto와 VolumeDto 목록을 포함한 StockDataResponse 객체를 생성합니다.', () => { - const priceDtoList: PriceDto[] = [ - { - startTime: new Date('2023-11-11T00:00:00Z'), - open: 105, - close: 115, - high: 120, - low: 100, - }, - { - startTime: new Date('2023-11-10T00:00:00Z'), - open: 100, - close: 110, - high: 115, - low: 95, - }, - ]; - - const volumeDtoList: VolumeDto[] = [ - { - startTime: new Date('2023-11-12T00:00:00Z'), - volume: 550, - }, - { - startTime: new Date('2023-11-11T00:00:00Z'), - volume: 600, - }, - { - startTime: new Date('2023-11-10T00:00:00Z'), - volume: 500, - }, - ]; - - const response: StockDataResponse = - stockDataService.createStockDataResponse( - priceDtoList, - volumeDtoList, - true, - ); - - expect(response).toHaveProperty('priceDtoList'); - expect(response).toHaveProperty('volumeDtoList'); - expect(response.hasMore).toBe(true); - expect(response.priceDtoList).toEqual( - plainToInstance(PriceDto, priceDtoList), - ); - expect(response.volumeDtoList).toEqual( - plainToInstance(VolumeDto, volumeDtoList), - ); - }); - }); -}); - -class StockDataMinutelyService extends StockDataService { - async getStockDataMinutely( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMinutely, stock_id, lastStartTime); - } -} - -class StockDataDailyService extends StockDataService { - async getStockDataDaily( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockDaily, stock_id, lastStartTime); - } -} - -class StockDataWeeklyService extends StockDataService { - async getStockDataWeekly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockWeekly, stock_id, lastStartTime); - } -} - -class StockDataMonthlyService extends StockDataService { - async getStockDataMonthly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMonthly, stock_id, lastStartTime); - } -} - -class StockDataYearlyService extends StockDataService { - async getStockDataYearly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockYearly, stock_id, lastStartTime); - } -} - -describe('StockDataService 파생 클래스 테스트', () => { - const stockId = 'A005930'; - let dataSource: Partial; - let managerMock: any; - - beforeEach(() => { - managerMock = { - createQueryBuilder: jest.fn(), - // 필요한 다른 메서드들도 여기에 추가할 수 있습니다. - }; - dataSource = createDataSourceMock(managerMock); - }); - - const testDerivedService = ( - ServiceClass: any, - EntityClass: any, - methodName: string, - ) => { - describe(`${ServiceClass.name}`, () => { - let service: any; - - beforeEach(() => { - service = new ServiceClass(dataSource as DataSource); - }); - - it(`${methodName} 메서드가 getPaginated를 호출하고 올바른 엔티티를 전달합니다.`, async () => { - const mockResponse: StockDataResponse = { - priceDtoList: [], - volumeDtoList: [], - hasMore: false, - }; - - const getPaginatedSpy = jest - .spyOn(StockDataService.prototype, 'getPaginated') - .mockResolvedValue(mockResponse); - - const response = await service[methodName](stockId); - - expect(getPaginatedSpy).toHaveBeenCalledWith( - EntityClass, - stockId, - undefined, - ); - expect(response).toBe(mockResponse); - - getPaginatedSpy.mockRestore(); - }); - - it(`${methodName} 메서드에 lastStartTime을 전달합니다.`, async () => { - const lastStartTime = '2023-11-15'; - const mockResponse: StockDataResponse = { - priceDtoList: [], - volumeDtoList: [], - hasMore: false, - }; - - const getPaginatedSpy = jest - .spyOn(StockDataService.prototype, 'getPaginated') - .mockResolvedValue(mockResponse); - - const response = await service[methodName](stockId, lastStartTime); - - expect(getPaginatedSpy).toHaveBeenCalledWith( - EntityClass, - stockId, - lastStartTime, - ); - expect(response).toBe(mockResponse); - - getPaginatedSpy.mockRestore(); - }); - }); - }; - - testDerivedService( - StockDataMinutelyService, - StockMinutely, - 'getStockDataMinutely', - ); - testDerivedService(StockDataDailyService, StockDaily, 'getStockDataDaily'); - testDerivedService(StockDataWeeklyService, StockWeekly, 'getStockDataWeekly'); - testDerivedService( - StockDataMonthlyService, - StockMonthly, - 'getStockDataMonthly', - ); - testDerivedService(StockDataYearlyService, StockYearly, 'getStockDataYearly'); -}); From ee68e308cbde53a674302b3d944ab899f1558d20 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 02:28:48 +0900 Subject: [PATCH 193/223] =?UTF-8?q?=E2=9C=A8=20feat:=209=EC=8B=9C=EC=97=90?= =?UTF-8?q?=20=ED=95=9C=EB=B2=88=20=EB=8D=94=20=EC=A2=85=EB=AA=A9=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korea-stock-info.service.ts | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts index 430b9f6a..598d08e9 100644 --- a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts +++ b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts @@ -24,6 +24,58 @@ export class KoreaStockInfoService { this.initKoreaStockInfo(); } + @Cron('0 9 * * 1-5') + @Cron('0 0 * * 1-5') + public async initKoreaStockInfo(): Promise { + await this.downloadMaster({ baseDir: './', target: 'kosdaq_code' }); + await this.getKosdaqMasterData({ + baseDir: './', + target: 'kosdaq_code', + }); + + await this.downloadMaster({ baseDir: './', target: 'kospi_code' }); + await this.getKospiMasterData({ + baseDir: './', + target: 'kospi_code', + }); + } + + public async downloadMaster(downloadDto: MasterDownloadDto): Promise { + const { baseDir, target } = downloadDto; + const downloadZipFile = target + '.mst.zip'; + const outputFile = target + '.mst'; + const url = process.env.MST_URL + target + '.mst.zip'; + this.logger.info(`Downloading file from ${url} to ${downloadZipFile}`); + const downloadZipFilePath = path.join(baseDir, downloadZipFile); + const extractedFile = path.join(baseDir, outputFile); + + try { + await this.downloadFile(url, downloadZipFilePath); + await this.extractZip(downloadZipFilePath, baseDir); + fs.unlink(downloadZipFilePath, (err) => { + if (err) throw err; + }); + } catch (error) { + this.logger.error('Error during download or extraction:', error); + } + + return extractedFile; + } + + public async getKospiMasterData( + downloadDto: MasterDownloadDto, + ): Promise { + await this.getMasterData(downloadDto, 228); + this.logger.info('Kospi master data processing done.'); + } + + public async getKosdaqMasterData( + downloadDto: MasterDownloadDto, + ): Promise { + await this.getMasterData(downloadDto, 222); + this.logger.info('Kosdaq master data processing done.'); + } + private async existsStockInfo(stockId: string, manager: EntityManager) { return await manager.exists(Stock, { where: { @@ -40,21 +92,6 @@ export class KoreaStockInfoService { } } - @Cron('0 0 * * 1-5') - public async initKoreaStockInfo(): Promise { - await this.downloadMaster({ baseDir: './', target: 'kosdaq_code' }); - await this.getKosdaqMasterData({ - baseDir: './', - target: 'kosdaq_code', - }); - - await this.downloadMaster({ baseDir: './', target: 'kospi_code' }); - await this.getKospiMasterData({ - baseDir: './', - target: 'kospi_code', - }); - } - private async downloadFile(url: string, filePath: string): Promise { this.logger.info(`Starting download from ${url}`); const file = fs.createWriteStream(filePath); @@ -95,38 +132,15 @@ export class KoreaStockInfoService { return row.slice(start, end).trim(); } - public async downloadMaster(downloadDto: MasterDownloadDto): Promise { - const { baseDir, target } = downloadDto; - const downloadZipFile = target + '.mst.zip'; - const outputFile = target + '.mst'; - const url = process.env.MST_URL + target + '.mst.zip'; - this.logger.info(`Downloading file from ${url} to ${downloadZipFile}`); - const downloadZipFilePath = path.join(baseDir, downloadZipFile); - const extractedFile = path.join(baseDir, outputFile); - - try { - await this.downloadFile(url, downloadZipFilePath); - await this.extractZip(downloadZipFilePath, baseDir); - fs.unlink(downloadZipFilePath, (err) => { - if (err) throw err; - }); - } catch (error) { - this.logger.error('Error during download or extraction:', error); - } - - return extractedFile; - } - private beforeMasterData(downloadDto: MasterDownloadDto): readline.Interface { const targetFileName = downloadDto.target + '.mst'; const fileName = path.join(downloadDto.baseDir, targetFileName); const encoding = 'cp949'; - const rl = readline.createInterface({ + return readline.createInterface({ input: fs.createReadStream(fileName).pipe(iconv.decodeStream(encoding)), crlfDelay: Infinity, }); - return rl; } private handleUnlinkFile( @@ -172,18 +186,4 @@ export class KoreaStockInfoService { this.handleUnlinkFile(targetFileName); } - - public async getKospiMasterData( - downloadDto: MasterDownloadDto, - ): Promise { - await this.getMasterData(downloadDto, 228); - this.logger.info('Kospi master data processing done.'); - } - - public async getKosdaqMasterData( - downloadDto: MasterDownloadDto, - ): Promise { - await this.getMasterData(downloadDto, 222); - this.logger.info('Kosdaq master data processing done.'); - } } From a4cff5cabc4848e46ab064693c91a30ad5d64bf3 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 02:35:14 +0900 Subject: [PATCH 194/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EC=B0=A8=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=A2=80=20=EB=8D=94=20=EB=86=92=EC=9D=80=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=EB=A1=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiPeriodData.api.ts | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index ee19eea5..3ea81243 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -75,18 +75,9 @@ export class OpenapiPeriodData { if (!isChartData(item)) return acc; const stockData = this.convertObjectToStockData(item, stockId); acc.push(stockData); - this.insertChartData(stockData, period).catch((e) => { - if ( - e instanceof QueryFailedError && - e.driverError.message.includes('duplicate') - ) { - this.logger.warn( - `when insert missing chart data, duplicate error :${stockId}`, - ); - return; - } - this.logger.error(e); - }); + this.insertChartData(stockData, period).catch((e) => + this.catchAndLogError(e, stockId), + ); return acc; }, []) .reverse(); @@ -102,12 +93,32 @@ export class OpenapiPeriodData { const end = getTodayDate(); const start = getPreviousDate(end, DATE_TO_MONTH[period]); const query = this.getItemChartPriceQuery(stockId, start, end, period); - this.openApiQueue.enqueue({ - url: this.url, - query, - trId: TR_IDS.ITEM_CHART_PRICE, - callback: this.getInsertCartDataRequestCallback(resolve, stockId, period), - }); + this.openApiQueue.enqueue( + { + url: this.url, + query, + trId: TR_IDS.ITEM_CHART_PRICE, + callback: this.getInsertCartDataRequestCallback( + resolve, + stockId, + period, + ), + }, + 1, + ); + } + + private catchAndLogError(e: Error, stockId: string) { + if ( + e instanceof QueryFailedError && + e.driverError.message.includes('duplicate') + ) { + this.logger.warn( + `when insert missing chart data, duplicate error :${stockId}`, + ); + return; + } + this.logger.error(e); } /** From 922b34be78be92b34dabe05ebd64b80b9993c3f7 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 03:22:13 +0900 Subject: [PATCH 195/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=8B=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EB=B0=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index a0a0df68..3fac9bae 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -39,12 +39,14 @@ export class ChatService { async scrollChat(chatScrollQuery: ChatScrollQuery, userId?: number) { this.validatePageSize(chatScrollQuery); + await this.validateLastedChatId(chatScrollQuery); const result = await this.findChatScroll(chatScrollQuery, userId); return await this.toScrollResponse(result, chatScrollQuery.pageSize); } async scrollChatByLike(chatScrollQuery: ChatScrollQuery, userId?: number) { this.validatePageSize(chatScrollQuery); + await this.validateLastedChatId(chatScrollQuery); const result = await this.findChatScrollOrderByLike( chatScrollQuery, userId, @@ -64,6 +66,17 @@ export class ChatService { return queryBuilder.getMany(); } + private async validateLastedChatId(chatScrollQuery: ChatScrollQuery) { + const { latestChatId, stockId } = chatScrollQuery; + const lastChat = await this.dataSource.manager.findOne(Chat, { + where: { id: latestChatId }, + relations: ['stock'], + }); + if (!lastChat || stockId !== lastChat.stock.id) { + throw new BadRequestException('lasted chat not in this room'); + } + } + private hasStock(userId: number, stockId: string, manager: EntityManager) { return manager.exists(UserStock, { where: { user: { id: userId }, stock: { id: stockId } }, @@ -152,8 +165,8 @@ export class ChatService { }); if (chat) { queryBuilder.andWhere( - 'chat.likeCount < :likeCount or' + - ' (chat.likeCount = :likeCount and chat.id < :latestChatId)', + '(chat.likeCount < :likeCount or' + + ' (chat.likeCount = :likeCount and chat.id < :latestChatId))', { likeCount: chat.likeCount, latestChatId, From 3af586caec20c4dbd830311fe7fbd3e1c196f931 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 03:34:16 +0900 Subject: [PATCH 196/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=B2=98?= =?UTF-8?q?=EC=9D=8C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EC=98=88=EC=99=B8=EA=B0=80=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=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/chat/chat.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 3fac9bae..7648f165 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -68,6 +68,7 @@ export class ChatService { private async validateLastedChatId(chatScrollQuery: ChatScrollQuery) { const { latestChatId, stockId } = chatScrollQuery; + if (!latestChatId) return; const lastChat = await this.dataSource.manager.findOne(Chat, { where: { id: latestChatId }, relations: ['stock'], From 6b4fb8b75e0b53ae0a12bf5e4c36a5f2e9befec1 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 03:59:07 +0900 Subject: [PATCH 197/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=ED=81=90=EC=97=90?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20=ED=9A=9F?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/queue/openapi.queue.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts index 7a03fb05..1818a91f 100644 --- a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -16,6 +16,7 @@ export interface OpenapiQueueNodeValue { query: object; trId: TR_ID; callback: (value: T) => Promise; + count?: number; } @Injectable() @@ -24,9 +25,8 @@ export class OpenapiQueue { constructor() {} enqueue(value: OpenapiQueueNodeValue, priority?: number) { - if (!priority) { - priority = 2; - } + if (!priority) priority = 2; + if (value.count === undefined) value.count = 5; this.queue.enqueue(value, priority); } @@ -101,7 +101,12 @@ export class OpenapiConsumer { ); await node.callback(data); } catch (error) { + if (node.count === undefined || node.count! <= 0) { + this.logger.error(error); + return; + } this.logger.warn(error); + node.count -= 1; this.queue.enqueue(node, 1); } } From bde9d7217c9094f95d5c3ae85bf872141d379f57 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Wed, 4 Dec 2024 05:07:18 +0900 Subject: [PATCH 198/223] =?UTF-8?q?Feature/#341=20=EB=B6=84=EB=B4=89=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8C=80=EC=97=90=20=EB=B6=84=EB=B4=89=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=98=A4=EA=B8=B0,=20alarm=20=ED=95=9C?= =?UTF-8?q?=EB=B2=88=EB=A7=8C=20=EC=9E=91=EB=8F=99=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: 분봉 데이터 callback 형태로 바꿔 우선순위 큐 적용, 리팩토링 * ♻️ refactor: 분단위 데이터 수집 stock limit 200, 콜백함수로 리팩토링 * 🐛 fix: afterUpdate 적용 위한 upsert구문으로 변경 * 💄 style: 분단위 테스트 이후 테스트 코드 삭제 및 조건 원복 * 💄 style: dto 안 쓰이는 속성 삭제 * 💄 style: console.log 삭제 * 📝 docs: liveData에 unsubscribe, subscribe 메시지 info로 출력 추가 * 🐛 fix: 알람을 한번만 보내고 삭제처리하게 만듦 --- packages/backend/src/alarm/alarm.service.ts | 2 + .../backend/src/alarm/alarm.subscriber.ts | 7 +- .../src/alarm/dto/subscribe.request.ts | 4 - .../openapi/api/openapiMinuteData.api.ts | 169 +++++++++--------- .../src/scraper/openapi/liveData.service.ts | 5 +- .../openapi/type/openapiMinuteData.type.ts | 60 +++++-- 6 files changed, 138 insertions(+), 109 deletions(-) diff --git a/packages/backend/src/alarm/alarm.service.ts b/packages/backend/src/alarm/alarm.service.ts index a2b214ce..d8e6343d 100644 --- a/packages/backend/src/alarm/alarm.service.ts +++ b/packages/backend/src/alarm/alarm.service.ts @@ -110,6 +110,8 @@ export class AlarmService { for (const subscription of subscriptions) { await this.pushService.sendPushNotification(subscription, payload); + //한번만 보내고 삭제하게 처리. + this.alarmRepository.delete(alarm.id); } } } diff --git a/packages/backend/src/alarm/alarm.subscriber.ts b/packages/backend/src/alarm/alarm.subscriber.ts index f5e15a6b..3f320b73 100644 --- a/packages/backend/src/alarm/alarm.subscriber.ts +++ b/packages/backend/src/alarm/alarm.subscriber.ts @@ -28,13 +28,13 @@ export class AlarmSubscriber } isValidAlarm(alarm: Alarm, entity: StockMinutely) { - if (alarm.alarmDate && alarm.alarmDate > entity.createdAt) { + if (alarm.alarmDate && alarm.alarmDate >= entity.createdAt) { return false; } else { - if (alarm.targetPrice && alarm.targetPrice >= entity.open) { + if (alarm.targetPrice && alarm.targetPrice <= entity.open) { return true; } - if (alarm.targetVolume && alarm.targetVolume >= entity.volume) { + if (alarm.targetVolume && alarm.targetVolume <= entity.volume) { return true; } return false; @@ -48,7 +48,6 @@ export class AlarmSubscriber where: { stock: { id: stockMinutely.stock.id } }, relations: ['user', 'stock'], }); - const alarms = rawAlarms.filter((val) => this.isValidAlarm(val, stockMinutely), ); diff --git a/packages/backend/src/alarm/dto/subscribe.request.ts b/packages/backend/src/alarm/dto/subscribe.request.ts index 1ed453e0..a049184c 100644 --- a/packages/backend/src/alarm/dto/subscribe.request.ts +++ b/packages/backend/src/alarm/dto/subscribe.request.ts @@ -1,10 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -//import { User } from '@/user/domain/user.entity'; export class SubscriptionData { - //@ApiProperty({ type: () => User, description: '유저 아이디' }) - //user: User; - @ApiProperty({ type: 'string', description: '엔드 포인트 설정', diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index 608d39cc..a36fd213 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,48 +1,104 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; -import { openApiConfig } from '../config/openapi.config'; - import { - isMinuteData, + Json, + OpenapiQueue, + OpenapiQueueNodeValue, +} from '../queue/openapi.queue'; +import { + isMinuteDataOutput1, + isMinuteDataOutput2, MinuteData, + MinuteDataOutput1, + MinuteDataOutput2, UpdateStockQuery, } from '../type/openapiMinuteData.type'; import { TR_IDS } from '../type/openapiUtil.type'; -import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; -import { OpenapiTokenApi } from './openapiToken.api'; +import { getCurrentTime } from '../util/openapiUtil.api'; +import { Alarm } from '@/alarm/domain/alarm.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; -const STOCK_CUT = 4; - @Injectable() export class OpenapiMinuteData { - private stock: Stock[][] = []; private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - private readonly intervals: number = 130; - private flip: number = 0; + private readonly STOCK_LIMITS: number = 200; constructor( private readonly datasource: DataSource, - private readonly openApiToken: OpenapiTokenApi, + private readonly openapiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, - ) { - //this.getStockData(); - } + ) {} - async getStockData() { + @Cron(`* 9-15 * * 1-5`) + async getStockMinuteData() { if (process.env.NODE_ENV !== 'production') return; - const stock = await this.datasource.manager.findBy(Stock, { - isTrading: true, - }); - const stockSize = Math.ceil(stock.length / STOCK_CUT); - let i = 0; - this.stock = []; - while (i < STOCK_CUT) { - this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); - i++; + const alarms = await this.datasource.manager + .getRepository(Alarm) + .createQueryBuilder('alarm') + .leftJoin('alarm.stock', 'stock') + .select('stock.id', 'stockId') + .addSelect('COUNT(alarm.id)', 'alarmCount') + .groupBy('stock.id') + .orderBy('alarmCount', 'DESC') + .limit(this.STOCK_LIMITS) + .execute(); + for (const alarm of alarms) { + const time = getCurrentTime(); + const query = this.getUpdateStockQuery(alarm.stockId, time); + const node: OpenapiQueueNodeValue = { + url: this.url, + query, + trId: TR_IDS.MINUTE_DATA, + callback: this.getStockMinuteDataCallback(alarm.stockId, time), + }; + this.openapiQueue.enqueue(node); + } + } + + getStockMinuteDataCallback(stockId: string, time: string) { + return async (data: Json) => { + let output1: MinuteDataOutput1, output2: MinuteDataOutput2[]; + if (data.output1 && isMinuteDataOutput1(data.output1)) { + output1 = data.output1; + } else { + this.logger.info(`${stockId} has invalid minute data`); + return; + } + if ( + data.output2 && + data.output2[0] && + isMinuteDataOutput2(data.output2[0]) + ) { + output2 = data.output2 as MinuteDataOutput2[]; + } else { + this.logger.info(`${stockId} has invalid minute data`); + return; + } + const minuteDatas: MinuteData[] = output2.map((val): MinuteData => { + return { acml_vol: output1.acml_vol, ...val }; + }); + await this.saveMinuteData(stockId, minuteDatas, time); + }; + } + + private async saveMinuteData( + stockId: string, + item: MinuteData[], + time: string, + ) { + if (!this.isMarketOpenTime(time)) return; + const stockPeriod = item.map((val) => + this.convertResToMinuteData(stockId, val, time), + ); + if (stockPeriod[0]) { + this.datasource.manager.upsert(this.entity, stockPeriod[0], [ + 'stock.id', + 'startTime', + ]); } } @@ -64,7 +120,7 @@ export class OpenapiMinuteData { stockPeriod.open = parseInt(item.stck_oprc); stockPeriod.high = parseInt(item.stck_hgpr); stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.cntg_vol); + stockPeriod.volume = parseInt(item.acml_vol); stockPeriod.createdAt = new Date(); return stockPeriod; } @@ -74,69 +130,6 @@ export class OpenapiMinuteData { return numberTime >= 90000 && numberTime <= 153000; } - private async saveMinuteData( - stockId: string, - item: MinuteData[], - time: string, - ) { - const manager = this.datasource.manager; - if (!this.isMarketOpenTime(time)) return; - const stockPeriod = item.map((val) => - this.convertResToMinuteData(stockId, val, time), - ); - manager.save(this.entity, stockPeriod); - } - - private async getMinuteDataInterval( - stockId: string, - time: string, - config: typeof openApiConfig, - ) { - const query = this.getUpdateStockQuery(stockId, time); - try { - const response = await getOpenApi( - this.url, - config, - query, - TR_IDS.MINUTE_DATA, - ); - let output; - if (response.output2) output = response.output2; - if (output && output[0] && isMinuteData(output[0])) { - this.saveMinuteData(stockId, output, time); - } - } catch (error) { - this.logger.warn(error); - } - } - - private async getMinuteDataChunk( - chunk: Stock[], - config: typeof openApiConfig, - ) { - const time = getCurrentTime(); - let interval = 0; - for await (const stock of chunk) { - setTimeout( - () => this.getMinuteDataInterval(stock.id!, time, config), - interval, - ); - interval += this.intervals; - } - } - - async getMinuteData() { - if (process.env.NODE_ENV !== 'production') return; - const configCount = (await this.openApiToken.configs()).length; - const stock = this.stock[this.flip % STOCK_CUT]; - this.flip++; - const chunkSize = Math.ceil(stock.length / configCount); - for (let i = 0; i < configCount; i++) { - const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, (await this.openApiToken.configs())[i]); - } - } - private getUpdateStockQuery( stockId: string, time: string, diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 37d757ae..83b0fb91 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -75,6 +75,7 @@ export class LiveData { stockId, '1', ); + this.logger.info(`${idx} : ${message}`); this.websocketClient[idx].subscribe(message); return; } @@ -99,7 +100,7 @@ export class LiveData { stockId, '2', ); - + this.logger.info(`${idx} : ${message}`); this.websocketClient[idx].unsubscribe(message); } } @@ -130,7 +131,7 @@ export class LiveData { return; } const liveData = this.openapiLiveData.convertLiveData(message); - await this.openapiLiveData.saveLiveData(liveData[0]) + await this.openapiLiveData.saveLiveData(liveData[0]); } catch (error) { this.logger.warn(error); } diff --git a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts index 72fb4bfb..9a1088ce 100644 --- a/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiMinuteData.type.ts @@ -1,35 +1,73 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -export type MinuteData = { +export type MinuteDataOutput1 = { + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + stck_prdy_clpr: string; + acml_vol: string; + acml_tr_pbmn: string; + hts_kor_isnm: string; + stck_prpr: string; +}; + +export type MinuteDataOutput2 = { stck_bsop_date: string; stck_cntg_hour: string; + acml_tr_pbmn: string; stck_prpr: string; stck_oprc: string; stck_hgpr: string; stck_lwpr: string; cntg_vol: string; +}; + +export type MinuteData = { + stck_bsop_date: string; + stck_cntg_hour: string; acml_tr_pbmn: string; + acml_vol: string; + stck_prpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + cntg_vol: string; }; -export type UpdateStockQuery = { - fid_etc_cls_code: string; - fid_cond_mrkt_div_code: 'J' | 'W'; - fid_input_iscd: string; - fid_input_hour_1: string; - fid_pw_data_incu_yn: 'Y' | 'N'; +export const isMinuteDataOutput1 = (data: any): data is MinuteDataOutput1 => { + return ( + data !== null && + typeof data === 'object' && + typeof data.prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.stck_prdy_clpr === 'string' && + typeof data.acml_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.hts_kor_isnm === 'string' && + typeof data.stck_prpr === 'string' + ); }; -export const isMinuteData = (data: any) => { +export const isMinuteDataOutput2 = (data: any): data is MinuteDataOutput2 => { return ( - data && + data !== null && typeof data === 'object' && typeof data.stck_bsop_date === 'string' && typeof data.stck_cntg_hour === 'string' && + typeof data.acml_tr_pbmn === 'string' && typeof data.stck_prpr === 'string' && typeof data.stck_oprc === 'string' && typeof data.stck_hgpr === 'string' && typeof data.stck_lwpr === 'string' && - typeof data.cntg_vol === 'string' && - typeof data.acml_tr_pbmn === 'string' + typeof data.cntg_vol === 'string' ); }; + +export type UpdateStockQuery = { + fid_etc_cls_code: string; + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_hour_1: string; + fid_pw_data_incu_yn: 'Y' | 'N'; +}; From d8b4a8d48d1e7ffe7ce692a072a778d244b59c62 Mon Sep 17 00:00:00 2001 From: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> Date: Wed, 4 Dec 2024 05:15:14 +0900 Subject: [PATCH 199/223] =?UTF-8?q?Feature/#332=20-=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EC=B5=9C=EC=8B=A0=20=EC=B0=A8=ED=8A=B8=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EA=B0=80=20=EC=A1=B4=EC=9E=AC=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20API=20=EC=9A=94=EC=B2=AD=20=ED=9B=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=EB=8B=AC=20(#3?= =?UTF-8?q?44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: plainToInstance 제거 * ✨ feat: 누락된 그래프 데이터를 큐에 요청할 수 있는 메서드 구현 * ✨ feat: 최신 차트 누락된 데이터 api 데이터와 조합 * 🐛 fix: 차트 업데이트 중 한번에 많은 요청으로 인한 중복키 에러 수정 * ♻️ refactor: 중복된 차트 데이터 클래스 제거 * ✅ test: 차트데이터 테스트 코드 삭제 --- .../openapi/api/openapiPeriodData.api.ts | 74 ++- .../scraper/openapi/openapi-scraper.module.ts | 3 +- .../openapi/type/openapiPeriodData.type.ts | 2 +- .../src/scraper/openapi/util/newDate.util.ts | 56 +- .../backend/src/stock/constants/timeunit.ts | 9 + .../src/stock/dto/stockData.response.ts | 24 + .../backend/src/stock/stock.controller.ts | 53 +- packages/backend/src/stock/stock.module.ts | 16 +- .../src/stock/stockData.service.spec.ts | 495 ------------------ .../backend/src/stock/stockData.service.ts | 218 ++++---- 10 files changed, 245 insertions(+), 705 deletions(-) create mode 100644 packages/backend/src/stock/constants/timeunit.ts delete mode 100644 packages/backend/src/stock/stockData.service.spec.ts diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 9ce6b4ed..ee19eea5 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { DataSource, QueryFailedError } from 'typeorm'; import { Logger } from 'winston'; import { ChartData, @@ -10,11 +10,7 @@ import { } from '../type/openapiPeriodData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { NewDate } from '../util/newDate.util'; -import { - getOpenApi, - getPreviousDate, - getTodayDate, -} from '../util/openapiUtil.api'; +import { getPreviousDate, getTodayDate } from '../util/openapiUtil.api'; import { OpenapiTokenApi } from './openapiToken.api'; import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { Stock } from '@/stock/domain/stock.entity'; @@ -33,15 +29,13 @@ const DATE_TO_ENTITY = { Y: StockYearly, } as const; -const DATE_TO_MONTH = { +export const DATE_TO_MONTH = { D: 1, W: 6, M: 24, Y: 120, } as const; -const INTERVALS = 10000; - @Injectable() export class OpenapiPeriodData { private readonly url: string = @@ -69,6 +63,53 @@ export class OpenapiPeriodData { await this.getChartData(stocks, 'D'); } + getInsertCartDataRequestCallback( + resolve: (value: StockData[]) => void, + stockId: string, + period: Period, + ) { + return async (data: Json) => { + if (!data.output2 || !Array.isArray(data.output2)) return resolve([]); + const result = data.output2 + .reduce((acc: StockData[], item: Record) => { + if (!isChartData(item)) return acc; + const stockData = this.convertObjectToStockData(item, stockId); + acc.push(stockData); + this.insertChartData(stockData, period).catch((e) => { + if ( + e instanceof QueryFailedError && + e.driverError.message.includes('duplicate') + ) { + this.logger.warn( + `when insert missing chart data, duplicate error :${stockId}`, + ); + return; + } + this.logger.error(e); + }); + return acc; + }, []) + .reverse(); + resolve(result); + }; + } + + insertCartDataRequest( + resolve: (value: StockData[]) => void, + stockId: string, + period: Period, + ): void { + const end = getTodayDate(); + const start = getPreviousDate(end, DATE_TO_MONTH[period]); + const query = this.getItemChartPriceQuery(stockId, start, end, period); + this.openApiQueue.enqueue({ + url: this.url, + query, + trId: TR_IDS.ITEM_CHART_PRICE, + callback: this.getInsertCartDataRequestCallback(resolve, stockId, period), + }); + } + /** * 월, 년의 경우 마지막 데이터를 업데이트 하는 형식으로 변경해야됨 */ @@ -104,21 +145,6 @@ export class OpenapiPeriodData { }); } - private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { - try { - const response = await getOpenApi( - this.url, - (await this.openApiToken.configs())[configIdx], - query, - TR_IDS.ITEM_CHART_PRICE, - ); - return response.output2 as ChartData[]; - } catch (error) { - this.logger.warn(error); - setTimeout(() => this.fetchChartData(query, configIdx), INTERVALS / 10); - } - } - private updateDates( endDate: string, period: Period, diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 34317e19..66ed255c 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -42,7 +42,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; ], controllers: [], providers: [ - LiveData, OpenapiLiveData, OpenapiTokenApi, OpenapiPeriodData, @@ -56,6 +55,6 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; WebsocketClient, LiveData, ], - exports: [LiveData], + exports: [LiveData, OpenapiPeriodData], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts index 6c47bdd6..ae79b46c 100644 --- a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts @@ -25,7 +25,7 @@ export type ItemChartPriceQuery = { fid_org_adj_prc: number; }; -export const isChartData = (data?: any) => { +export const isChartData = (data?: any): data is ChartData => { return ( data && typeof data === 'object' && diff --git a/packages/backend/src/scraper/openapi/util/newDate.util.ts b/packages/backend/src/scraper/openapi/util/newDate.util.ts index f12e762a..e043eef8 100644 --- a/packages/backend/src/scraper/openapi/util/newDate.util.ts +++ b/packages/backend/src/scraper/openapi/util/newDate.util.ts @@ -15,30 +15,6 @@ export class NewDate extends Date { ); } - private resetTime(): NewDate { - this.setHours(0, 0, 0, 0); - return this; - } - - private getWeek(): number { - const date = this.resetTime(); - const firstOne = new Date(date.getFullYear(), 0, 1); - return Math.ceil( - ((date.getTime() - firstOne.getTime()) / 86400000 + - firstOne.getDay() + - 1) / - 7, - ); - } - - private isLastWeekOfTheYear(): boolean { - return this.getWeek() === 53; - } - - private isFirstWeekOfTheYear(): boolean { - return this.getWeek() === 1; - } - dateToNewDate(date: Date): NewDate { return new NewDate(date); } @@ -77,4 +53,36 @@ export class NewDate extends Date { isSameYear(dateToCompare: NewDate | Date): boolean { return this.getFullYear() === dateToCompare.getFullYear(); } + + isSameDate(dateToCompare: NewDate | Date): boolean { + return ( + this.getFullYear() === dateToCompare.getFullYear() && + this.getMonth() === dateToCompare.getMonth() && + this.getDate() === dateToCompare.getDate() + ); + } + + private resetTime(): NewDate { + this.setHours(0, 0, 0, 0); + return this; + } + + private getWeek(): number { + const date = this.resetTime(); + const firstOne = new Date(date.getFullYear(), 0, 1); + return Math.ceil( + ((date.getTime() - firstOne.getTime()) / 86400000 + + firstOne.getDay() + + 1) / + 7, + ); + } + + private isLastWeekOfTheYear(): boolean { + return this.getWeek() === 53; + } + + private isFirstWeekOfTheYear(): boolean { + return this.getWeek() === 1; + } } diff --git a/packages/backend/src/stock/constants/timeunit.ts b/packages/backend/src/stock/constants/timeunit.ts new file mode 100644 index 00000000..e0123aae --- /dev/null +++ b/packages/backend/src/stock/constants/timeunit.ts @@ -0,0 +1,9 @@ +export const TIME_UNIT = { + MINUTE: 'minute', + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', +} as const; + +export type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts index 91133715..0f183d66 100644 --- a/packages/backend/src/stock/dto/stockData.response.ts +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; +import { StockData } from '@/stock/domain/stockData.entity'; export class PriceDto { @ApiProperty({ @@ -33,6 +34,14 @@ export class PriceDto { example: '123.45', }) close: number; + + constructor(stockData: StockData) { + this.startTime = stockData.startTime; + this.open = stockData.open; + this.high = stockData.high; + this.low = stockData.low; + this.close = stockData.close; + } } export class VolumeDto { @@ -49,6 +58,11 @@ export class VolumeDto { example: 1000, }) volume: number; + + constructor(stockData: StockData) { + this.startTime = stockData.startTime; + this.volume = stockData.volume; + } } export class StockDataResponse { @@ -71,4 +85,14 @@ export class StockDataResponse { example: true, }) hasMore: boolean; + + constructor( + priceDtoList: PriceDto[], + volumeDtoList: VolumeDto[], + hasMore: boolean, + ) { + this.priceDtoList = priceDtoList; + this.volumeDtoList = volumeDtoList; + this.hasMore = hasMore; + } } diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 08198b90..495365eb 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -23,18 +23,12 @@ import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; import { StockIndexRateResponse } from './dto/stockIndexRate.response'; import { StockService } from './stock.service'; -import { - StockDataDailyService, - StockDataMinutelyService, - StockDataMonthlyService, - StockDataWeeklyService, - StockDataYearlyService, -} from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockRateIndexService } from './stockRateIndex.service'; import SessionGuard from '@/auth/session/session.guard'; import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; +import { TIME_UNIT } from '@/stock/constants/timeunit'; import { StockSearchRequest } from '@/stock/dto/stock.request'; import { StockRankResponses, @@ -52,16 +46,13 @@ import { UserStocksResponse, } from '@/stock/dto/userStock.response'; import { User } from '@/user/domain/user.entity'; - -const TIME_UNIT = { - MINUTE: 'minute', - DAY: 'day', - WEEK: 'week', - MONTH: 'month', - YEAR: 'year', -} as const; - -type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; +import { StockDataService } from '@/stock/stockData.service'; +import { + StockDaily, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; const FLUCTUATION_TYPE = { INCREASE: 'increase', @@ -76,13 +67,9 @@ type FLUCTUATION_TYPE = export class StockController { constructor( private readonly stockService: StockService, - private readonly stockDataMinutelyService: StockDataMinutelyService, - private readonly stockDataDailyService: StockDataDailyService, - private readonly stockDataWeeklyService: StockDataWeeklyService, - private readonly stockDataMonthlyService: StockDataMonthlyService, - private readonly stockDataYearlyService: StockDataYearlyService, private readonly stockDetailService: StockDetailService, private readonly stockRateIndexService: StockRateIndexService, + private readonly stockDataService: StockDataService, ) {} @HttpCode(200) @@ -289,11 +276,9 @@ export class StockController { async getStockDataDaily( @Param('stockId') stockId: string, @Query('lastStartTime') lastStartTime?: string, - @Query('timeunit') timeunit: TIME_UNIT = TIME_UNIT.MINUTE, + @Query('timeunit') timeunit: TIME_UNIT = TIME_UNIT.DAY, ) { switch (timeunit) { - case TIME_UNIT.MINUTE: - return this.getMinutelyData(stockId, lastStartTime); case TIME_UNIT.DAY: return this.getDailyData(stockId, lastStartTime); case TIME_UNIT.MONTH: @@ -309,7 +294,8 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataYearlyService.getStockDataYearly( + return this.stockDataService.getPaginated( + StockYearly, stockId, lastStartTime, ); @@ -319,7 +305,8 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataWeeklyService.getStockDataWeekly( + return this.stockDataService.getPaginated( + StockWeekly, stockId, lastStartTime, ); @@ -329,20 +316,18 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataMonthlyService.getStockDataMonthly( + return this.stockDataService.getPaginated( + StockMonthly, stockId, lastStartTime, ); } - private getMinutelyData(stockId: string, lastStartTime?: string) { - return this.stockDataMinutelyService.getStockDataMinutely( + private getDailyData(stockId: string, lastStartTime?: string) { + return this.stockDataService.getPaginated( + StockDaily, stockId, lastStartTime, ); } - - private getDailyData(stockId: string, lastStartTime?: string) { - return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); - } } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index adaa18f0..3a218e5a 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -13,21 +13,15 @@ import { StockLiveData } from './domain/stockLiveData.entity'; import { StockController } from './stock.controller'; import { StockGateway } from './stock.gateway'; import { StockService } from './stock.service'; -import { - StockDataDailyService, - StockDataMinutelyService, - StockDataMonthlyService, - StockDataService, - StockDataWeeklyService, - StockDataYearlyService, -} from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; import { StockRateIndexService } from './stockRateIndex.service'; import { AlarmModule } from '@/alarm/alarm.module'; import { Alarm } from '@/alarm/domain/alarm.entity'; +import { OpenapiScraperModule } from '@/scraper/openapi/openapi-scraper.module'; import { ScraperModule } from '@/scraper/scraper.module'; import { StockDataCache } from '@/stock/cache/stockData.cache'; +import { StockDataService } from '@/stock/stockData.service'; @Module({ imports: [ @@ -44,6 +38,7 @@ import { StockDataCache } from '@/stock/cache/stockData.cache'; ]), AlarmModule, ScraperModule, + OpenapiScraperModule, ], controllers: [StockController], providers: [ @@ -52,11 +47,6 @@ import { StockDataCache } from '@/stock/cache/stockData.cache'; StockGateway, StockLiveDataSubscriber, StockDataService, - StockDataDailyService, - StockDataMinutelyService, - StockDataWeeklyService, - StockDataYearlyService, - StockDataMonthlyService, StockDetailService, StockRateIndexService, ], diff --git a/packages/backend/src/stock/stockData.service.spec.ts b/packages/backend/src/stock/stockData.service.spec.ts deleted file mode 100644 index 1858a036..00000000 --- a/packages/backend/src/stock/stockData.service.spec.ts +++ /dev/null @@ -1,495 +0,0 @@ -/* eslint-disable max-lines-per-function */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { plainToInstance } from 'class-transformer'; -import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; -import { Stock } from './domain/stock.entity'; -import { - StockMinutely, - StockDaily, - StockWeekly, - StockMonthly, - StockYearly, -} from './domain/stockData.entity'; -import { - StockDataResponse, - PriceDto, - VolumeDto, -} from './dto/stockData.response'; -import { StockDataService } from './stockData.service'; - -// Mock DataSource와 EntityManager 생성 함수 -export function createDataSourceMock( - managerMock: Partial, -): Partial { - return { - manager: { - ...managerMock, - transaction: jest.fn().mockImplementation(async (work) => { - return work(managerMock as EntityManager); - }), - } as any, // TypeScript 오류를 피하기 위해 any로 캐스팅 - }; -} - -// QueryBuilder 모킹을 위한 헬퍼 함수 -const createQueryBuilderMock = ( - getManyResult: any[] = [], - throwError: boolean = false, -): Partial> => { - return { - leftJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getMany: jest.fn().mockImplementation(() => { - if (throwError) { - return Promise.reject(new Error('Query error')); - } - return Promise.resolve(getManyResult); - }), - }; -}; - -describe('StockDataService', () => { - const stockId = 'A005930'; - let dataSource: Partial; - let stockDataService: StockDataService; - let managerMock: any; - - beforeEach(() => { - managerMock = { - createQueryBuilder: jest.fn(), - exists: jest.fn().mockResolvedValue(true), - }; - dataSource = createDataSourceMock(managerMock); - stockDataService = new StockDataService(dataSource as DataSource); - }); - - describe('getPaginated', () => { - const PAGE_SIZE = 100; - - it('주식이 존재하지 않을 경우 NotFoundException을 던집니다.', async () => { - managerMock.exists.mockResolvedValue(false); - - await expect( - stockDataService.getPaginated(StockMinutely, stockId), - ).rejects.toThrow('stock not found'); - }); - - it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=true', async () => { - const mockData: any[] = Array.from({ length: PAGE_SIZE + 1 }, (_, i) => ({ - id: i, - close: 100 + i, - low: 90 + i, - high: 110 + i, - open: 95 + i, - volume: 1000 + i * 10, - startTime: new Date( - `2023-11-${(10 + (i % 20)).toString().padStart(2, '0')}`, - ), - stock: { id: stockId } as Stock, - createdAt: new Date(), - })); - - const queryBuilderMock = createQueryBuilderMock(mockData); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - const response: StockDataResponse = await stockDataService.getPaginated( - StockMinutely, - stockId, - ); - - expect(managerMock.createQueryBuilder).toHaveBeenCalledWith( - StockMinutely, - 'entity', - ); - expect(queryBuilderMock.where).toHaveBeenCalledWith( - 'entity.stock_id = :stockId', - { stockId }, - ); - expect(queryBuilderMock.orderBy).toHaveBeenCalledWith( - 'entity.startTime', - 'DESC', - ); - expect(queryBuilderMock.take).toHaveBeenCalledWith(PAGE_SIZE + 1); - expect(response.hasMore).toBe(true); - expect(response.priceDtoList).toHaveLength(PAGE_SIZE); - expect(response.volumeDtoList).toHaveLength(PAGE_SIZE); - }); - - it('주식 데이터를 페이지네이션하여 가져옵니다. hasMore=false', async () => { - const mockData: any[] = Array.from({ length: PAGE_SIZE }, (_, i) => ({ - id: i, - close: 100 + i, - low: 90 + i, - high: 110 + i, - open: 95 + i, - volume: 1000 + i * 10, - startTime: new Date( - `2023-11-${(10 + (i % 20)).toString().padStart(2, '0')}`, - ), - stock: { id: stockId } as Stock, - createdAt: new Date(), - })); - - const queryBuilderMock = createQueryBuilderMock(mockData); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - const response: StockDataResponse = await stockDataService.getPaginated( - StockMinutely, - stockId, - ); - - expect(response.hasMore).toBe(false); - expect(response.priceDtoList).toHaveLength(PAGE_SIZE); - expect(response.volumeDtoList).toHaveLength(PAGE_SIZE); - }); - - it('lastStartTime을 사용해 이전 데이터까지 페이지네이션 가져오기', async () => { - const lastStartTime = '2023-11-15'; - const mockData: any[] = Array.from({ length: 50 }, (_, i) => ({ - id: i, - close: 100 + i, - low: 90 + i, - high: 110 + i, - open: 95 + i, - volume: 1000 + i * 10, - startTime: new Date( - `2023-11-${(15 - (i % 15)).toString().padStart(2, '0')}`, - ), - stock: { id: stockId } as Stock, - createdAt: new Date(), - })); - - const queryBuilderMock = createQueryBuilderMock(mockData); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - const response: StockDataResponse = await stockDataService.getPaginated( - StockMinutely, - stockId, - lastStartTime, - ); - - expect(queryBuilderMock.andWhere).toHaveBeenCalledWith( - 'entity.startTime < :lastStartTime', - { lastStartTime }, - ); - expect(response.hasMore).toBe(false); - expect(response.priceDtoList).toHaveLength(50); - expect(response.volumeDtoList).toHaveLength(50); - }); - - it('쿼리에서 예외가 발생하면 예외를 던집니다.', async () => { - const queryBuilderMock = createQueryBuilderMock([], true); - (managerMock.createQueryBuilder as jest.Mock).mockReturnValue( - queryBuilderMock, - ); - - await expect( - stockDataService.getPaginated(StockMinutely, stockId), - ).rejects.toThrow('Query error'); - }); - }); - - describe('mapResultListToPriceDtoList', () => { - it('StockData 목록을 PriceDto 목록으로 매핑합니다.', () => { - const resultList = [ - { - id: 2, - startTime: new Date('2023-11-11T00:00:00Z'), - open: 105, - close: 115, - high: 120, - low: 100, - volume: 600, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - { - id: 1, - startTime: new Date('2023-11-10T00:00:00Z'), - open: 100, - close: 110, - high: 115, - low: 95, - volume: 500, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - ]; - - const priceDtoList: PriceDto[] = - stockDataService.mapResultListToPriceDtoList(resultList); - - expect(priceDtoList).toEqual([ - { - startTime: new Date('2023-11-10T00:00:00Z'), - open: 100, - close: 110, - high: 115, - low: 95, - }, - { - startTime: new Date('2023-11-11T00:00:00Z'), - open: 105, - close: 115, - high: 120, - low: 100, - }, - ]); - }); - }); - - describe('mapResultListToVolumeDtoList', () => { - it('StockData 목록을 VolumeDto 목록으로 매핑합니다.', () => { - const resultList = [ - { - id: 3, - startTime: new Date('2023-11-12T00:00:00Z'), - close: 110, - low: 95, - high: 125, - open: 115, - volume: 550, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - { - id: 2, - startTime: new Date('2023-11-11T00:00:00Z'), - close: 105, - low: 100, - high: 120, - open: 115, - volume: 600, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - { - id: 1, - startTime: new Date('2023-11-10T00:00:00Z'), - close: 100, - low: 90, - high: 110, - open: 95, - volume: 500, - stock: { id: stockId } as Stock, - createdAt: new Date(), - }, - ]; - - const volumeDtoList: VolumeDto[] = - stockDataService.mapResultListToVolumeDtoList(resultList); - - expect(volumeDtoList).toEqual([ - { - startTime: new Date('2023-11-10T00:00:00Z'), - volume: 500, - }, - - { - startTime: new Date('2023-11-11T00:00:00Z'), - volume: 600, - }, - { - startTime: new Date('2023-11-12T00:00:00Z'), - volume: 550, - }, - ]); - }); - }); - - describe('createStockDataResponse', () => { - it('PriceDto와 VolumeDto 목록을 포함한 StockDataResponse 객체를 생성합니다.', () => { - const priceDtoList: PriceDto[] = [ - { - startTime: new Date('2023-11-11T00:00:00Z'), - open: 105, - close: 115, - high: 120, - low: 100, - }, - { - startTime: new Date('2023-11-10T00:00:00Z'), - open: 100, - close: 110, - high: 115, - low: 95, - }, - ]; - - const volumeDtoList: VolumeDto[] = [ - { - startTime: new Date('2023-11-12T00:00:00Z'), - volume: 550, - }, - { - startTime: new Date('2023-11-11T00:00:00Z'), - volume: 600, - }, - { - startTime: new Date('2023-11-10T00:00:00Z'), - volume: 500, - }, - ]; - - const response: StockDataResponse = - stockDataService.createStockDataResponse( - priceDtoList, - volumeDtoList, - true, - ); - - expect(response).toHaveProperty('priceDtoList'); - expect(response).toHaveProperty('volumeDtoList'); - expect(response.hasMore).toBe(true); - expect(response.priceDtoList).toEqual( - plainToInstance(PriceDto, priceDtoList), - ); - expect(response.volumeDtoList).toEqual( - plainToInstance(VolumeDto, volumeDtoList), - ); - }); - }); -}); - -class StockDataMinutelyService extends StockDataService { - async getStockDataMinutely( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMinutely, stock_id, lastStartTime); - } -} - -class StockDataDailyService extends StockDataService { - async getStockDataDaily( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockDaily, stock_id, lastStartTime); - } -} - -class StockDataWeeklyService extends StockDataService { - async getStockDataWeekly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockWeekly, stock_id, lastStartTime); - } -} - -class StockDataMonthlyService extends StockDataService { - async getStockDataMonthly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMonthly, stock_id, lastStartTime); - } -} - -class StockDataYearlyService extends StockDataService { - async getStockDataYearly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockYearly, stock_id, lastStartTime); - } -} - -describe('StockDataService 파생 클래스 테스트', () => { - const stockId = 'A005930'; - let dataSource: Partial; - let managerMock: any; - - beforeEach(() => { - managerMock = { - createQueryBuilder: jest.fn(), - // 필요한 다른 메서드들도 여기에 추가할 수 있습니다. - }; - dataSource = createDataSourceMock(managerMock); - }); - - const testDerivedService = ( - ServiceClass: any, - EntityClass: any, - methodName: string, - ) => { - describe(`${ServiceClass.name}`, () => { - let service: any; - - beforeEach(() => { - service = new ServiceClass(dataSource as DataSource); - }); - - it(`${methodName} 메서드가 getPaginated를 호출하고 올바른 엔티티를 전달합니다.`, async () => { - const mockResponse: StockDataResponse = { - priceDtoList: [], - volumeDtoList: [], - hasMore: false, - }; - - const getPaginatedSpy = jest - .spyOn(StockDataService.prototype, 'getPaginated') - .mockResolvedValue(mockResponse); - - const response = await service[methodName](stockId); - - expect(getPaginatedSpy).toHaveBeenCalledWith( - EntityClass, - stockId, - undefined, - ); - expect(response).toBe(mockResponse); - - getPaginatedSpy.mockRestore(); - }); - - it(`${methodName} 메서드에 lastStartTime을 전달합니다.`, async () => { - const lastStartTime = '2023-11-15'; - const mockResponse: StockDataResponse = { - priceDtoList: [], - volumeDtoList: [], - hasMore: false, - }; - - const getPaginatedSpy = jest - .spyOn(StockDataService.prototype, 'getPaginated') - .mockResolvedValue(mockResponse); - - const response = await service[methodName](stockId, lastStartTime); - - expect(getPaginatedSpy).toHaveBeenCalledWith( - EntityClass, - stockId, - lastStartTime, - ); - expect(response).toBe(mockResponse); - - getPaginatedSpy.mockRestore(); - }); - }); - }; - - testDerivedService( - StockDataMinutelyService, - StockMinutely, - 'getStockDataMinutely', - ); - testDerivedService(StockDataDailyService, StockDaily, 'getStockDataDaily'); - testDerivedService(StockDataWeeklyService, StockWeekly, 'getStockDataWeekly'); - testDerivedService( - StockDataMonthlyService, - StockMonthly, - 'getStockDataMonthly', - ); - testDerivedService(StockDataYearlyService, StockYearly, 'getStockDataYearly'); -}); diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 2b9e3942..0bccece3 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -1,10 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { plainToInstance } from 'class-transformer'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { StockDaily, - StockMinutely, StockMonthly, StockWeekly, StockYearly, @@ -14,6 +16,9 @@ import { StockDataResponse, VolumeDto, } from './dto/stockData.response'; +import { OpenapiPeriodData } from '@/scraper/openapi/api/openapiPeriodData.api'; +import { Period } from '@/scraper/openapi/type/openapiPeriodData.type'; +import { NewDate } from '@/scraper/openapi/util/newDate.util'; import { StockDataCache } from '@/stock/cache/stockData.cache'; import { getFormattedDate } from '@/utils/date'; @@ -36,45 +41,53 @@ export class StockDataService { constructor( private readonly dataSource: DataSource, private readonly stockDataCache: StockDataCache, + private readonly openapiPeriodData: OpenapiPeriodData, ) {} async getPaginated( entity: new () => StockData, - stock_id: string, + stockId: string, lastStartTime?: string, ): Promise { return await this.dataSource.manager.transaction(async (manager) => { - if (!(await this.isStockExist(stock_id, manager))) + if (!(await this.isStockExist(stockId, manager))) throw new NotFoundException('stock not found'); - const date = lastStartTime ? new Date(lastStartTime) : new Date(); - const cacheKey = `${entity.name}_${stock_id}_${getFormattedDate(date)}`; + const cacheKey = this.createCacheKey(entity, stockId, lastStartTime); const cachedData = this.stockDataCache.get(cacheKey); if (cachedData) { return cachedData; } - - const queryBuilder = manager - .createQueryBuilder(entity, 'entity') - .where('entity.stock_id = :stockId', { stockId: stock_id }) - .orderBy('entity.startTime', 'DESC') - .take(this.PAGE_SIZE + 1); - - if (lastStartTime) - queryBuilder.andWhere('entity.startTime < :lastStartTime', { - lastStartTime: lastStartTime, - }); - - const resultList = await queryBuilder.getMany(); - - const hasMore = resultList.length > this.PAGE_SIZE; - if (hasMore) resultList.pop(); - const priceDtoList = this.mapResultListToPriceDtoList(resultList); - const volumeDtoList = this.mapResultListToVolumeDtoList(resultList); - const response = this.createStockDataResponse( - priceDtoList, - volumeDtoList, - hasMore, + const queryBuilder = this.createQueryBuilder( + entity, + stockId, + manager, + lastStartTime, ); + const results = await queryBuilder.getMany(); + const lastData = results[0]; + const periodType = this.getPeriodType(entity); + if (!periodType) throw new BadRequestException('period type not found'); + if ( + !lastStartTime && + (!lastData || !this.isLastDate(lastData, periodType)) + ) { + return new Promise((resolve) => { + this.openapiPeriodData.insertCartDataRequest( + (value) => { + const index = this.findExistDataIndex(value, lastData); + const response = this.convertResultsToResponse([ + ...value.slice(index + 1).reverse(), + ...results, + ]); + this.stockDataCache.set(cacheKey, response); + resolve(response); + }, + stockId, + periodType, + ); + }); + } + const response = this.convertResultsToResponse(results); this.stockDataCache.set(cacheKey, response); return response; }); @@ -84,104 +97,85 @@ export class StockDataService { return await manager.exists(Stock, { where: { id: stockId } }); } - mapResultListToPriceDtoList(resultList: StockData[]): PriceDto[] { - return resultList - .map((data: StockData) => ({ - startTime: data.startTime, - open: data.open, - close: data.close, - high: data.high, - low: data.low, - })) - .reverse(); + private isLastDate(lastData: StockData, period: Period) { + const lastDate = new NewDate(lastData.startTime); + const current = new Date(); + if (period === 'D') return lastDate.isSameDate(current); + if (period === 'M') { + return lastDate.isSameWeek(current) && lastDate.isSameYear(current); + } + if (period === 'Y') return lastDate.isSameYear(current); + return lastDate.isSameWeek(current); } - mapResultListToVolumeDtoList(resultList: StockData[]): VolumeDto[] { - return resultList - .map((data) => ({ - startTime: data.startTime, - volume: data.volume, - })) - .reverse(); + private findExistDataIndex(responseData: StockData[], lastData: StockData) { + if (!lastData) return -1; + const lastDate = new NewDate(lastData.startTime); + return responseData.findIndex((data) => + lastDate.isSameDate(data.startTime), + ); } - createStockDataResponse( - priceDtoList: PriceDto[], - volumeDtoList: VolumeDto[], - hasMore: boolean, - ): StockDataResponse { - const priceData = plainToInstance(PriceDto, priceDtoList); - const volumeData = plainToInstance(VolumeDto, volumeDtoList); - - return plainToInstance(StockDataResponse, { - priceDtoList: priceData, - volumeDtoList: volumeData, - hasMore, - }); + private getPeriodType(entity: new () => StockData) { + if (entity === StockDaily) return 'D'; + if (entity === StockWeekly) return 'W'; + if (entity === StockMonthly) return 'M'; + if (entity === StockYearly) return 'Y'; } -} -@Injectable() -export class StockDataMinutelyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); - } - async getStockDataMinutely( - stock_id: string, + private createCacheKey( + entity: new () => StockData, + stockId: string, lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMinutely, stock_id, lastStartTime); + ) { + const date = lastStartTime ? new Date(lastStartTime) : new Date(); + return `${entity.name}_${stockId}_${getFormattedDate(date)}`; } -} -@Injectable() -export class StockDataDailyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); + private convertResultsToResponse(results: StockData[]) { + const hasMore = results.length > this.PAGE_SIZE; + if (hasMore) results.pop(); + const prices = this.convertResultsToPriceDtoList(results); + const volumes = this.convertResultsToVolumeDtoList(results); + return new StockDataResponse(prices, volumes, hasMore); } - async getStockDataDaily( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockDaily, stock_id, lastStartTime); - } -} -@Injectable() -export class StockDataWeeklyService extends StockDataService { - constructor(dataSource: DataSource, sockDataCache: StockDataCache) { - super(dataSource, sockDataCache); - } - async getStockDataWeekly( + private createQueryBuilder( + entity: new () => StockData, stock_id: string, + manager: EntityManager, lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockWeekly, stock_id, lastStartTime); + ) { + const queryBuilder = manager + .createQueryBuilder(entity, 'entity') + .where('entity.stock_id = :stockId', { stockId: stock_id }) + .orderBy('entity.startTime', 'DESC') + .take(this.PAGE_SIZE + 1); + + if (lastStartTime) + queryBuilder.andWhere('entity.startTime < :lastStartTime', { + lastStartTime: lastStartTime, + }); + return queryBuilder; } -} -@Injectable() -export class StockDataMonthlyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); - } - async getStockDataMonthly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockMonthly, stock_id, lastStartTime); + private convertResultsToPriceDtoList(resultList: StockData[]): PriceDto[] { + return resultList + .reduce((acc: PriceDto[], stockData) => { + if (!stockData) return acc; + acc.push(new PriceDto(stockData)); + return acc; + }, []) + .reverse(); } -} -@Injectable() -export class StockDataYearlyService extends StockDataService { - constructor(dataSource: DataSource, stockDataCache: StockDataCache) { - super(dataSource, stockDataCache); - } - async getStockDataYearly( - stock_id: string, - lastStartTime?: string, - ): Promise { - return await this.getPaginated(StockYearly, stock_id, lastStartTime); + private convertResultsToVolumeDtoList(resultList: StockData[]): VolumeDto[] { + return resultList + .reduce((acc: VolumeDto[], stockData) => { + if (!stockData) return acc; + acc.push(new VolumeDto(stockData)); + return acc; + }, []) + .reverse(); } -} +} \ No newline at end of file From ac48adeba4fa9c70bfc81c9408b682e2c9fc4219 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 4 Dec 2024 11:04:29 +0900 Subject: [PATCH 200/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EA=B0=80=20=EB=8F=99=EC=8B=9C=EC=97=90=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=A3=BC=EC=8B=9D=20=EB=B0=A9=EC=97=90=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/liveData.service.ts | 6 ++++- .../websocket/websocketClient.websocket.ts | 27 +++++++------------ packages/backend/src/stock/stock.gateway.ts | 4 +++ 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 83b0fb91..59dea09a 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -12,7 +12,7 @@ type TR_IDS = '1' | '2'; @Injectable() export class LiveData { - private readonly startTime: Date = new Date(2024, 0, 1, 9, 0, 0, 0); + private readonly startTime: Date = new Date(2024, 0, 1, 2, 0, 0, 0); private readonly endTime: Date = new Date(2024, 0, 1, 15, 30, 0, 0); private readonly reconnectInterval = 60 * 1000; @@ -37,6 +37,10 @@ export class LiveData { } this.connect(); }); + this.subscribe('005930'); + this.subscribe('000660'); + this.subscribe('000150'); + this.subscribe('000020'); } private async openapiSubscribe(stockId: string) { diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts index d8704787..e036b5b6 100644 --- a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -6,16 +6,15 @@ import { RawData, WebSocket } from 'ws'; export class WebsocketClient { static url = process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; private client: WebSocket; - private messageQueue: string[] = []; + //현재 factory 패턴을 이용해 할당하면 socket이 열리기 전에 message가 가는 문제가 있음. + // 소켓이 할당되기 전에(client에 소켓이 없을 때) message를 보내려 시도함. constructor(@Inject('winston') private readonly logger: Logger) { this.client = new WebSocket(WebsocketClient.url); - this.initOpen(() => this.flushQueue()); - this.initError((error) => this.logger.error('WebSocket error', error)); } - static websocketFactory(logger: Logger) { - return new WebsocketClient(logger); + const websocket = new WebsocketClient(logger); + return websocket; } subscribe(message: string) { @@ -48,28 +47,22 @@ export class WebsocketClient { initCloseCallback: () => void, initErrorCallback: (error: unknown) => void, ) { - this.initOpen(initOpenCallback(this.sendMessage.bind(this))); + this.initOpen(initOpenCallback(this.sendMessage)); this.initMessage(initMessageCallback(this.client)); this.initDisconnect(initCloseCallback); this.initError(initErrorCallback); } private sendMessage(message: string) { + if (!this.client || !this.client.readyState) { + this.logger.warn('WebSocket is not open. Message not sent. '); + return; + } if (this.client.readyState === WebSocket.OPEN) { this.client.send(message); this.logger.info(`Sent message: ${message}`); } else { - this.logger.warn('WebSocket not open. Queueing message.'); - this.messageQueue.push(message); // 큐에 메시지를 추가 - } - } - - private flushQueue() { - while (this.messageQueue.length > 0) { - const message = this.messageQueue.shift(); - if (message) { - this.sendMessage(message); - } + this.logger.warn('WebSocket is not open. Message not sent. '); } } } diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 618163d1..aaf54c69 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -37,6 +37,10 @@ export class StockGateway implements OnGatewayDisconnect { ) { try { client.join(stockId); + const beforeStockId = this.users.get(client.id); + if (beforeStockId !== undefined) { + client.leave(beforeStockId); + } this.users.set(client.id, stockId); await this.mutex.runExclusive(async () => { From c69ebd9db280d8f028568119c5a0d2e1208d2473 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 4 Dec 2024 11:15:52 +0900 Subject: [PATCH 201/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EA=B0=80=20=EB=8F=99=EC=8B=9C=EC=97=90=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=A3=BC=EC=8B=9D=EC=97=90=20=EC=A0=91=EC=86=8D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 46 +++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index aaf54c69..4aef1a37 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -30,6 +30,15 @@ export class StockGateway implements OnGatewayDisconnect { @Inject('winston') private readonly logger: Logger, ) {} + private async handleJoinToRoom(stockId: string) { + const connectedSockets = await this.server.to(stockId).fetchSockets(); + + if (connectedSockets.length > 0 && !this.liveData.isSubscribe(stockId)) { + await this.liveData.subscribe(stockId); + this.logger.info(`${stockId} is subscribed`); + } + } + @SubscribeMessage('connectStock') async handleConnectStock( @MessageBody() stockId: string, @@ -37,22 +46,14 @@ export class StockGateway implements OnGatewayDisconnect { ) { try { client.join(stockId); + const beforeStockId = this.users.get(client.id); - if (beforeStockId !== undefined) { - client.leave(beforeStockId); - } + await this.handleClientStockEvent(beforeStockId, client); + this.users.set(client.id, stockId); await this.mutex.runExclusive(async () => { - const connectedSockets = await this.server.to(stockId).fetchSockets(); - - if ( - connectedSockets.length > 0 && - !this.liveData.isSubscribe(stockId) - ) { - await this.liveData.subscribe(stockId); - this.logger.info(`${stockId} is subscribed`); - } + this.handleJoinToRoom(stockId); }); client.emit('connectionSuccess', { @@ -67,16 +68,27 @@ export class StockGateway implements OnGatewayDisconnect { } } - async handleDisconnect(client: Socket) { - const stockId = this.users.get(client.id); - if (stockId) { + private async handleClientStockEvent( + stockId: string | undefined, + client: Socket, + ) { + if (stockId !== undefined) { await this.mutex.runExclusive(async () => { - await this.liveData.unsubscribe(stockId); - this.users.delete(client.id); + const values = Object.values(this.users); + const isStockIdExists = values.some((value) => stockId === value); + if (!isStockIdExists) { + await this.liveData.unsubscribe(stockId); + this.users.delete(client.id); + } }); } } + async handleDisconnect(client: Socket) { + const stockId = this.users.get(client.id); + await this.handleClientStockEvent(stockId, client); + } + onUpdateStock( stockId: string, price: number, From d45d2e6693a52b7ef89672fad8a7f06cff57194f Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 4 Dec 2024 11:18:09 +0900 Subject: [PATCH 202/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20client=20leave=20?= =?UTF-8?q?=EC=B6=94=E3=85=8F=E3=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 4aef1a37..f72c4307 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -79,6 +79,7 @@ export class StockGateway implements OnGatewayDisconnect { if (!isStockIdExists) { await this.liveData.unsubscribe(stockId); this.users.delete(client.id); + client.leave(stockId); } }); } From a4571c153599f91cfa9fcdba8908d7d0b6acc257 Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 4 Dec 2024 11:21:31 +0900 Subject: [PATCH 203/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20leave=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20values=EB=A5=BC=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EB=B3=B4=EB=8B=A4=20?= =?UTF-8?q?=EB=92=A4=EC=97=90=20=EC=9E=88=EC=96=B4=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index f72c4307..80c829dd 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -74,12 +74,12 @@ export class StockGateway implements OnGatewayDisconnect { ) { if (stockId !== undefined) { await this.mutex.runExclusive(async () => { + client.leave(stockId); const values = Object.values(this.users); const isStockIdExists = values.some((value) => stockId === value); if (!isStockIdExists) { await this.liveData.unsubscribe(stockId); this.users.delete(client.id); - client.leave(stockId); } }); } From f2ad68b61ddc8360fb67f770f33ac5a15c811e4c Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 4 Dec 2024 11:22:22 +0900 Subject: [PATCH 204/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20delete=EB=8F=84?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 80c829dd..5808a253 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -75,11 +75,11 @@ export class StockGateway implements OnGatewayDisconnect { if (stockId !== undefined) { await this.mutex.runExclusive(async () => { client.leave(stockId); + this.users.delete(client.id); const values = Object.values(this.users); const isStockIdExists = values.some((value) => stockId === value); if (!isStockIdExists) { await this.liveData.unsubscribe(stockId); - this.users.delete(client.id); } }); } From 629b47d2accd3163045657c05e76fc935455df5d Mon Sep 17 00:00:00 2001 From: sunghwki Date: Wed, 4 Dec 2024 11:41:35 +0900 Subject: [PATCH 205/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20deadlock=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=20=EC=97=86=EC=9D=B4=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/stock/stock.gateway.ts | 29 ++++++++++----------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index 5808a253..f11c7a08 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -47,12 +47,11 @@ export class StockGateway implements OnGatewayDisconnect { try { client.join(stockId); - const beforeStockId = this.users.get(client.id); - await this.handleClientStockEvent(beforeStockId, client); - - this.users.set(client.id, stockId); - await this.mutex.runExclusive(async () => { + const beforeStockId = this.users.get(client.id); + await this.handleClientStockEvent(beforeStockId, client); + + this.users.set(client.id, stockId); this.handleJoinToRoom(stockId); }); @@ -73,21 +72,21 @@ export class StockGateway implements OnGatewayDisconnect { client: Socket, ) { if (stockId !== undefined) { - await this.mutex.runExclusive(async () => { - client.leave(stockId); - this.users.delete(client.id); - const values = Object.values(this.users); - const isStockIdExists = values.some((value) => stockId === value); - if (!isStockIdExists) { - await this.liveData.unsubscribe(stockId); - } - }); + client.leave(stockId); + this.users.delete(client.id); + const values = Object.values(this.users); + const isStockIdExists = values.some((value) => stockId === value); + if (!isStockIdExists) { + await this.liveData.unsubscribe(stockId); + } } } async handleDisconnect(client: Socket) { const stockId = this.users.get(client.id); - await this.handleClientStockEvent(stockId, client); + await this.mutex.runExclusive(async () => { + await this.handleClientStockEvent(stockId, client); + }); } onUpdateStock( From 830dcd198a92a7513218f945cc9d2a96adf3c062 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:58:49 +0900 Subject: [PATCH 206/223] =?UTF-8?q?=F0=9F=90=9B=20fix:=20join=20column?= =?UTF-8?q?=EC=97=90=20user=5Fid=20=EC=9D=B4=EB=A6=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/alarm/domain/subscription.entity.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/alarm/domain/subscription.entity.ts b/packages/backend/src/alarm/domain/subscription.entity.ts index f3013973..cb70da95 100644 --- a/packages/backend/src/alarm/domain/subscription.entity.ts +++ b/packages/backend/src/alarm/domain/subscription.entity.ts @@ -1,4 +1,10 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { User } from '@/user/domain/user.entity'; @Entity() @@ -7,6 +13,7 @@ export class PushSubscription { id: number; @ManyToOne(() => User, (user) => user.subscriptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) user: User; @Column({ type: 'text' }) From 465f2d21a2b2ce519862a3a616699f9468363871 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 16:04:46 +0900 Subject: [PATCH 207/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EC=B0=A8=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=9B=84=EC=97=90=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiPeriodData.api.ts | 28 ++-- .../backend/src/stock/stock.controller.ts | 8 +- .../backend/src/stock/stockData.service.ts | 121 ++++++++++-------- 3 files changed, 88 insertions(+), 69 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 3ea81243..52d6f6d8 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -69,18 +69,22 @@ export class OpenapiPeriodData { period: Period, ) { return async (data: Json) => { + const promises: Promise[] = []; if (!data.output2 || !Array.isArray(data.output2)) return resolve([]); const result = data.output2 .reduce((acc: StockData[], item: Record) => { if (!isChartData(item)) return acc; const stockData = this.convertObjectToStockData(item, stockId); acc.push(stockData); - this.insertChartData(stockData, period).catch((e) => - this.catchAndLogError(e, stockId), + promises.push( + this.insertChartData(stockData, period).catch((e) => + this.catchAndLogError(e, stockId), + ), ); return acc; }, []) .reverse(); + await Promise.all(promises); resolve(result); }; } @@ -108,6 +112,16 @@ export class OpenapiPeriodData { ); } + isSamePeriod(stock: StockData, period: Period, date: Date) { + return ( + (period === 'W' && new NewDate(stock.startTime).isSameWeek(date)) || + (period === 'M' && + new NewDate(stock.startTime).isSameMonth(date) && + new NewDate(stock.startTime).isSameYear(date)) || + (period === 'Y' && new NewDate(stock.startTime).isSameYear(date)) + ); + } + private catchAndLogError(e: Error, stockId: string) { if ( e instanceof QueryFailedError && @@ -174,16 +188,6 @@ export class OpenapiPeriodData { }); } - private isSamePeriod(stock: StockData, period: Period, date: Date) { - return ( - (period === 'W' && new NewDate(stock.startTime).isSameWeek(date)) || - (period === 'M' && - new NewDate(stock.startTime).isSameMonth(date) && - new NewDate(stock.startTime).isSameYear(date)) || - (period === 'Y' && new NewDate(stock.startTime).isSameYear(date)) - ); - } - private async insertChartData(stock: StockData, period: Period) { const entity = DATE_TO_ENTITY[period]; const manager = this.datasource.manager; diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 495365eb..1a3725e8 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -294,7 +294,7 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataService.getPaginated( + return this.stockDataService.scrollChart( StockYearly, stockId, lastStartTime, @@ -305,7 +305,7 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataService.getPaginated( + return this.stockDataService.scrollChart( StockWeekly, stockId, lastStartTime, @@ -316,7 +316,7 @@ export class StockController { stockId: string, lastStartTime: string | undefined, ) { - return this.stockDataService.getPaginated( + return this.stockDataService.scrollChart( StockMonthly, stockId, lastStartTime, @@ -324,7 +324,7 @@ export class StockController { } private getDailyData(stockId: string, lastStartTime?: string) { - return this.stockDataService.getPaginated( + return this.stockDataService.scrollChart( StockDaily, stockId, lastStartTime, diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 0bccece3..1b557c5a 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -44,52 +44,66 @@ export class StockDataService { private readonly openapiPeriodData: OpenapiPeriodData, ) {} - async getPaginated( + async scrollChart( entity: new () => StockData, stockId: string, lastStartTime?: string, ): Promise { - return await this.dataSource.manager.transaction(async (manager) => { - if (!(await this.isStockExist(stockId, manager))) - throw new NotFoundException('stock not found'); - const cacheKey = this.createCacheKey(entity, stockId, lastStartTime); - const cachedData = this.stockDataCache.get(cacheKey); - if (cachedData) { - return cachedData; - } - const queryBuilder = this.createQueryBuilder( - entity, - stockId, - manager, - lastStartTime, - ); - const results = await queryBuilder.getMany(); - const lastData = results[0]; - const periodType = this.getPeriodType(entity); - if (!periodType) throw new BadRequestException('period type not found'); - if ( - !lastStartTime && - (!lastData || !this.isLastDate(lastData, periodType)) - ) { - return new Promise((resolve) => { - this.openapiPeriodData.insertCartDataRequest( - (value) => { - const index = this.findExistDataIndex(value, lastData); - const response = this.convertResultsToResponse([ - ...value.slice(index + 1).reverse(), - ...results, - ]); - this.stockDataCache.set(cacheKey, response); - resolve(response); - }, - stockId, - periodType, - ); - }); - } - const response = this.convertResultsToResponse(results); - this.stockDataCache.set(cacheKey, response); - return response; + if (!(await this.isStockExist(stockId, this.dataSource.manager))) + throw new NotFoundException('stock not found'); + const cacheKey = this.createCacheKey(entity, stockId, lastStartTime); + const cachedData = this.stockDataCache.get(cacheKey); + + if (cachedData) { + return cachedData; + } + const lastData = await this.findLastData(entity, stockId); + const periodType = this.getPeriodType(entity); + if (!periodType) throw new BadRequestException('period type not found'); + if ( + !lastStartTime && + (!lastData || !this.isLastDate(lastData, periodType)) + ) { + return new Promise((resolve) => { + this.openapiPeriodData.insertCartDataRequest( + async () => { + const results = await this.getChartData( + entity, + stockId, + lastStartTime, + ); + const response = this.convertResultsToResponse(results); + resolve(response); + }, + stockId, + periodType, + ); + }); + } + const results = await this.getChartData(entity, stockId, lastStartTime); + const response = this.convertResultsToResponse(results); + this.stockDataCache.set(cacheKey, response); + return response; + } + + async getChartData( + entity: new () => StockData, + stockId: string, + lastStartTime?: string, + ) { + const queryBuilder = this.createQueryBuilder( + entity, + stockId, + this.dataSource.manager, + lastStartTime, + ); + return queryBuilder.getMany(); + } + + async findLastData(entity: new () => StockData, stockId: string) { + return await this.dataSource.manager.findOne(entity, { + where: { stock: { id: stockId } }, + order: { startTime: 'DESC' }, }); } @@ -102,17 +116,18 @@ export class StockDataService { const current = new Date(); if (period === 'D') return lastDate.isSameDate(current); if (period === 'M') { - return lastDate.isSameWeek(current) && lastDate.isSameYear(current); + return ( + lastDate.isSameWeek(current) && + lastDate.isSameYear(current) && + lastDate.isSameDate(current) && + lastDate.isSameDate(current) + ); } - if (period === 'Y') return lastDate.isSameYear(current); - return lastDate.isSameWeek(current); - } - - private findExistDataIndex(responseData: StockData[], lastData: StockData) { - if (!lastData) return -1; - const lastDate = new NewDate(lastData.startTime); - return responseData.findIndex((data) => - lastDate.isSameDate(data.startTime), + if (period === 'Y') + return lastDate.isSameYear(current) && lastDate.isSameDate(current); + return ( + lastDate.isSameWeek(current) && + lastData.createdAt.getDate() === current.getDate() ); } @@ -178,4 +193,4 @@ export class StockDataService { }, []) .reverse(); } -} \ No newline at end of file +} From eb8d1c918c8f6f6ea9bb5ca819e3b476b74f1cbe Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 16:10:03 +0900 Subject: [PATCH 208/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EC=B0=A8=ED=8A=B8=20=EC=88=98=EC=A7=91=EC=9D=B4=20?= =?UTF-8?q?2=EC=B4=88=20=EC=9D=B4=EC=83=81=EC=9D=B4=20=EA=B1=B8=EB=A6=AC?= =?UTF-8?q?=EB=A9=B4,=20=ED=98=84=EC=9E=AC=20=EA=B0=80=EC=A7=80=EA=B3=A0?= =?UTF-8?q?=20=EC=9E=88=EB=8A=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stockData.service.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 1b557c5a..e885af77 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -67,12 +67,20 @@ export class StockDataService { return new Promise((resolve) => { this.openapiPeriodData.insertCartDataRequest( async () => { - const results = await this.getChartData( + setTimeout( + async () => + resolve( + await this.getChartData(entity, stockId, lastStartTime), + ), + 2000, + ); + + const response = await this.getChartData( entity, stockId, lastStartTime, ); - const response = this.convertResultsToResponse(results); + resolve(response); }, stockId, @@ -80,8 +88,7 @@ export class StockDataService { ); }); } - const results = await this.getChartData(entity, stockId, lastStartTime); - const response = this.convertResultsToResponse(results); + const response = await this.getChartData(entity, stockId, lastStartTime); this.stockDataCache.set(cacheKey, response); return response; } @@ -97,7 +104,8 @@ export class StockDataService { this.dataSource.manager, lastStartTime, ); - return queryBuilder.getMany(); + const results = await queryBuilder.getMany(); + return this.convertResultsToResponse(results); } async findLastData(entity: new () => StockData, stockId: string) { From 2a6d07aa9490b6b3a2115313aee142de66b29f60 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:16:20 +0900 Subject: [PATCH 209/223] =?UTF-8?q?Feature/#341=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=88=98=20200=EA=B0=9C=20=EC=A0=9C=ED=95=9C=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C,=20join=20column=20=EC=9D=B4=EB=A6=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=A4=EB=B0=B1=20(#356)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: join column에 user_id 이름 추가 * 🐛 fix: websocketclient에러 수정 * 🐛 fix: join column 롤백 * ♻️ refactor: 알림 수 200개 제한 해제 * 💄 style: 테스트 코드 삭제 * 📝 docs: request volum + e * 📝 docs: apiresponse status 201 정상화 --- .../backend/src/alarm/alarm.controller.ts | 13 +++++-- .../backend/src/alarm/dto/alarm.request.ts | 2 +- .../openapi/api/openapiMinuteData.api.ts | 5 +-- .../src/scraper/openapi/liveData.service.ts | 4 --- .../websocket/websocketClient.websocket.ts | 34 +++++++++---------- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/alarm/alarm.controller.ts b/packages/backend/src/alarm/alarm.controller.ts index 15dfc480..98b28e08 100644 --- a/packages/backend/src/alarm/alarm.controller.ts +++ b/packages/backend/src/alarm/alarm.controller.ts @@ -8,7 +8,12 @@ import { Delete, UseGuards, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { + ApiOkResponse, + ApiOperation, + ApiParam, + ApiResponse, +} from '@nestjs/swagger'; import { AlarmService } from './alarm.service'; import { AlarmRequest } from './dto/alarm.request'; import { AlarmResponse, AlarmSuccessResponse } from './dto/alarm.response'; @@ -25,7 +30,8 @@ export class AlarmController { summary: '알림 생성', description: '각 정보에 맞는 알림을 생성한다.', }) - @ApiOkResponse({ + @ApiResponse({ + status: 201, description: '알림 생성 완료', type: AlarmResponse, }) @@ -64,7 +70,8 @@ export class AlarmController { summary: '등록된 알림 업데이트', description: '알림 아이디 기준으로 업데이트를 할 수 있다.', }) - @ApiOkResponse({ + @ApiResponse({ + status: 201, description: '아이디와 동일한 알림 업데이트', type: AlarmResponse, }) diff --git a/packages/backend/src/alarm/dto/alarm.request.ts b/packages/backend/src/alarm/dto/alarm.request.ts index 9d8e5ad0..d5746518 100644 --- a/packages/backend/src/alarm/dto/alarm.request.ts +++ b/packages/backend/src/alarm/dto/alarm.request.ts @@ -19,7 +19,7 @@ export class AlarmRequest { example: 1000, required: false, }) - targetVolum?: number; + targetVolume?: number; @ApiProperty({ description: '알림 종료 날짜', diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index a36fd213..4e443eca 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -26,7 +26,7 @@ export class OpenapiMinuteData { private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - private readonly STOCK_LIMITS: number = 200; + constructor( private readonly datasource: DataSource, private readonly openapiQueue: OpenapiQueue, @@ -41,10 +41,7 @@ export class OpenapiMinuteData { .createQueryBuilder('alarm') .leftJoin('alarm.stock', 'stock') .select('stock.id', 'stockId') - .addSelect('COUNT(alarm.id)', 'alarmCount') .groupBy('stock.id') - .orderBy('alarmCount', 'DESC') - .limit(this.STOCK_LIMITS) .execute(); for (const alarm of alarms) { const time = getCurrentTime(); diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 59dea09a..31b05b4c 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -37,10 +37,6 @@ export class LiveData { } this.connect(); }); - this.subscribe('005930'); - this.subscribe('000660'); - this.subscribe('000150'); - this.subscribe('000020'); } private async openapiSubscribe(stockId: string) { diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts index e036b5b6..34e4698e 100644 --- a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -1,68 +1,68 @@ import { Inject, Injectable } from '@nestjs/common'; import { Logger } from 'winston'; import { RawData, WebSocket } from 'ws'; - @Injectable() export class WebsocketClient { static url = process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; private client: WebSocket; - //현재 factory 패턴을 이용해 할당하면 socket이 열리기 전에 message가 가는 문제가 있음. - // 소켓이 할당되기 전에(client에 소켓이 없을 때) message를 보내려 시도함. + private messageQueue: string[] = []; constructor(@Inject('winston') private readonly logger: Logger) { this.client = new WebSocket(WebsocketClient.url); + this.initOpen(() => this.flushQueue()); + this.initError((error) => this.logger.error('WebSocket error', error)); } + static websocketFactory(logger: Logger) { - const websocket = new WebsocketClient(logger); - return websocket; + return new WebsocketClient(logger); } subscribe(message: string) { this.sendMessage(message); } - unsubscribe(message: string) { this.sendMessage(message); } - private initOpen(fn: () => void) { this.client.on('open', fn); } - private initMessage(fn: (data: RawData) => void) { this.client.on('message', fn); } - private initDisconnect(initCloseCallback: () => void) { this.client.on('close', initCloseCallback); } - private initError(initErrorCallback: (error: unknown) => void) { this.client.on('error', initErrorCallback); } - connectFacade( initOpenCallback: (fn: (message: string) => void) => () => void, initMessageCallback: (client: WebSocket) => (data: RawData) => void, initCloseCallback: () => void, initErrorCallback: (error: unknown) => void, ) { - this.initOpen(initOpenCallback(this.sendMessage)); + this.initOpen(initOpenCallback(this.sendMessage.bind(this))); this.initMessage(initMessageCallback(this.client)); this.initDisconnect(initCloseCallback); this.initError(initErrorCallback); } private sendMessage(message: string) { - if (!this.client || !this.client.readyState) { - this.logger.warn('WebSocket is not open. Message not sent. '); - return; - } if (this.client.readyState === WebSocket.OPEN) { this.client.send(message); this.logger.info(`Sent message: ${message}`); } else { - this.logger.warn('WebSocket is not open. Message not sent. '); + this.logger.warn('WebSocket not open. Queueing message.'); + this.messageQueue.push(message); // 큐에 메시지를 추가 + } + } + + private flushQueue() { + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (message) { + this.sendMessage(message); + } } } } From 5979d4e4f034acd681e8b7deb53ef98a3d4eb873 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 17:21:53 +0900 Subject: [PATCH 210/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EC=98=88=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/chat/chat.service.spec.ts | 4 ++-- packages/backend/src/chat/dto/chat.request.ts | 2 +- packages/backend/src/chat/dto/like.response.ts | 4 ++-- .../backend/src/stock/decorator/stockData.decorator.ts | 2 +- packages/backend/src/stock/dto/stock.response.ts | 8 ++++---- packages/backend/src/stock/dto/stockView.request.ts | 2 +- packages/backend/src/stock/dto/userStock.request.ts | 2 +- packages/backend/src/stock/stock.service.spec.ts | 4 ++-- packages/backend/src/stock/stockDetail.service.spec.ts | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/chat/chat.service.spec.ts b/packages/backend/src/chat/chat.service.spec.ts index 1057ffed..d9a415de 100644 --- a/packages/backend/src/chat/chat.service.spec.ts +++ b/packages/backend/src/chat/chat.service.spec.ts @@ -9,7 +9,7 @@ describe('ChatService 테스트', () => { await expect(() => chatService.scrollChat({ - stockId: 'A005930', + stockId: '005930', pageSize: 101, }), ).rejects.toThrow('pageSize should be less than 100'); @@ -20,7 +20,7 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollChat({ stockId: 'A005930', pageSize: 101 }), + chatService.scrollChat({ stockId: '005930', pageSize: 101 }), ).rejects.toThrow('pageSize should be less than 100'); }); }); diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index 7503351f..e8f54e51 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -4,7 +4,7 @@ import { IsNumber, IsOptional, IsString } from 'class-validator'; export class ChatScrollQuery { @ApiProperty({ description: '종목 주식 id(종목방 id)', - example: 'A005930', + example: '005930', }) @IsString() stockId: string; diff --git a/packages/backend/src/chat/dto/like.response.ts b/packages/backend/src/chat/dto/like.response.ts index 5c6ff7fa..7ae7576a 100644 --- a/packages/backend/src/chat/dto/like.response.ts +++ b/packages/backend/src/chat/dto/like.response.ts @@ -12,7 +12,7 @@ export class LikeResponse { @ApiProperty({ type: 'string', description: '참여 중인 좀목 id', - example: 'A005930', + example: '005930', }) stockId: string; @@ -66,4 +66,4 @@ export class LikeResponse { function isStockId(stockId?: string): stockId is string { return stockId !== undefined; -} \ No newline at end of file +} diff --git a/packages/backend/src/stock/decorator/stockData.decorator.ts b/packages/backend/src/stock/decorator/stockData.decorator.ts index 4941fa61..cfcdb8e1 100644 --- a/packages/backend/src/stock/decorator/stockData.decorator.ts +++ b/packages/backend/src/stock/decorator/stockData.decorator.ts @@ -12,7 +12,7 @@ export function ApiGetStockData(summary: string, type: string) { name: 'stockId', type: String, description: '주식 ID', - example: 'A005930', + example: '005930', }), ApiQuery({ name: 'lastStartTime', diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index a919e1af..c91c90e2 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -5,7 +5,7 @@ import { Stock } from '@/stock/domain/stock.entity'; export class StockViewsResponse { @ApiProperty({ description: '응답 메시지', - example: 'A005930', + example: '005930', }) id: string; @@ -31,7 +31,7 @@ export class StockViewsResponse { export class StocksResponse { @ApiProperty({ description: '주식 종목 코드', - example: 'A005930', + example: '005930', }) id: string; @@ -72,7 +72,7 @@ export class StocksResponse { class StockSearchResult { @ApiProperty({ description: '주식 종목 코드', - example: 'A005930', + example: '005930', }) id: string; @@ -105,7 +105,7 @@ export class StockSearchResponse { export class StockRankResponse { @ApiProperty({ description: '주식 종목 코드', - example: 'A005930', + example: '005930', }) id: string; diff --git a/packages/backend/src/stock/dto/stockView.request.ts b/packages/backend/src/stock/dto/stockView.request.ts index d4cd4f7d..bf35318c 100644 --- a/packages/backend/src/stock/dto/stockView.request.ts +++ b/packages/backend/src/stock/dto/stockView.request.ts @@ -3,7 +3,7 @@ import { IsString } from 'class-validator'; export class StockViewRequest { @ApiProperty({ - example: 'A005930', + example: '005930', description: '개별 주식 id', }) @IsString() diff --git a/packages/backend/src/stock/dto/userStock.request.ts b/packages/backend/src/stock/dto/userStock.request.ts index 0c3f33e2..4cb4fb5f 100644 --- a/packages/backend/src/stock/dto/userStock.request.ts +++ b/packages/backend/src/stock/dto/userStock.request.ts @@ -3,7 +3,7 @@ import { IsString } from 'class-validator'; export class UserStockRequest { @ApiProperty({ - example: 'A005930', + example: '005930', description: '주식 종목 id', }) @IsString() diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 57b90374..2779a98c 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -14,11 +14,11 @@ import { StockRankResponses, StocksResponse } from '@/stock/dto/stock.response'; import { User } from '@/user/domain/user.entity'; describe('StockService 테스트', () => { - const stockId = 'A005930'; + const stockId = '005930'; const userId = 1; const result = [ { - id: 'A005930', + id: '005930', name: '삼성전자', currentPrice: '100000.0', changeRate: '2.5', diff --git a/packages/backend/src/stock/stockDetail.service.spec.ts b/packages/backend/src/stock/stockDetail.service.spec.ts index 43d8785d..efb95a0c 100644 --- a/packages/backend/src/stock/stockDetail.service.spec.ts +++ b/packages/backend/src/stock/stockDetail.service.spec.ts @@ -13,7 +13,7 @@ import { Stock } from '@/stock/domain/stock.entity'; import { StockDetailResponse } from '@/stock/dto/stockDetail.response'; describe('StockDetailService 테스트', () => { - const stockId = 'A005930'; + const stockId = '005930'; let stockDetailService: StockDetailService; let logger: Logger; let dataSourceMock: DataSource; From 1855588c2c8b39d7876777f198b04116c3458b17 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 20:38:29 +0900 Subject: [PATCH 211/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=88=9C=EC=9C=84=EA=B0=80=20=EA=B0=99=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?FIFO=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/scraper/openapi/util/priorityQueue.ts | 43 ++++++++++++------- .../src/scraper/openapi/util/queue.spec.ts | 38 ++++++++++++++++ 2 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/scraper/openapi/util/queue.spec.ts diff --git a/packages/backend/src/scraper/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/openapi/util/priorityQueue.ts index d7efc937..f86f1d1d 100644 --- a/packages/backend/src/scraper/openapi/util/priorityQueue.ts +++ b/packages/backend/src/scraper/openapi/util/priorityQueue.ts @@ -1,12 +1,11 @@ -export class PriorityQueue { - private heap: { value: T; priority: number }[]; +type HeapNode = { value: T; priority: number; order: number }; - constructor() { - this.heap = []; - } +export class PriorityQueue { + private heap: HeapNode[] = []; + private count: number = 0; enqueue(value: T, priority: number) { - this.heap.push({ value, priority }); + this.heap.push({ value, priority, order: this.count++ }); this.heapifyUp(); } @@ -31,11 +30,17 @@ export class PriorityQueue { } isEmpty(): boolean { - return this.heap.length === 0; + const result = this.heap.length === 0; + if (!result) { + return result; + } + this.count = 0; + return result; } clear() { this.heap = []; + this.count = 0; } private getParentIndex(index: number): number { @@ -56,13 +61,20 @@ export class PriorityQueue { private heapifyUp() { let index = this.heap.length - 1; - while ( - index > 0 && - this.heap[index].priority < this.heap[this.getParentIndex(index)].priority - ) { - this.swap(index, this.getParentIndex(index)); - index = this.getParentIndex(index); + + while (index > 0) { + const parentIndex = this.getParentIndex(index); + if (!this.compare(this.heap[index], this.heap[parentIndex])) break; + this.swap(index, parentIndex); + index = parentIndex; + } + } + + private compare(a: HeapNode, b: HeapNode) { + if (a.priority === b.priority) { + return a.order < b.order; } + return a.priority < b.priority; } private heapifyDown() { @@ -73,13 +85,12 @@ export class PriorityQueue { if ( rightChildIndex < this.heap.length && - this.heap[rightChildIndex].priority < - this.heap[smallerChildIndex].priority + this.compare(this.heap[rightChildIndex], this.heap[smallerChildIndex]) ) { smallerChildIndex = rightChildIndex; } - if (this.heap[index].priority <= this.heap[smallerChildIndex].priority) { + if (this.compare(this.heap[index], this.heap[smallerChildIndex])) { break; } diff --git a/packages/backend/src/scraper/openapi/util/queue.spec.ts b/packages/backend/src/scraper/openapi/util/queue.spec.ts new file mode 100644 index 00000000..5a05dc68 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/queue.spec.ts @@ -0,0 +1,38 @@ +import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; + +describe('priorityQueue', () => { + let priorityQueue: PriorityQueue; + + beforeEach(() => { + priorityQueue = new PriorityQueue(); + }); + + test('대량의 데이터 셋', () => { + const size = 1000; + const priorities: number[] = []; + for (let i = 0; i < size; i++) { + const priority = Math.floor(Math.random() * 10); + priorities.push(priority); + priorityQueue.enqueue(i, priority); + } + let lastPriority = -1; + while (!priorityQueue.isEmpty()) { + const current = priorityQueue.dequeue(); + const currentPriority = priorities[current!]; + expect(currentPriority).toBeGreaterThanOrEqual(lastPriority); + if (currentPriority > lastPriority) { + lastPriority = currentPriority; + } + } + }); + + test('동일 우선순위일 경우 FIFO', () => { + const size = 1000; + for (let i = 0; i < size; i++) { + priorityQueue.enqueue(i, 0); + } + for (let i = 0; i < size; i++) { + expect(priorityQueue.dequeue()).toBe(i); + } + }); +}); From 67c5e21a38c185b3768d77c27d7cd4577da53b24 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Wed, 4 Dec 2024 20:43:46 +0900 Subject: [PATCH 212/223] =?UTF-8?q?Bug/#361=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style: eslint 깨지는 파일 수정 * 📝 docs: stockId 잘못 들어 있던 데코레이터 수정 * 📝 docs: response alarmresponse 로 수정 * 🐛 fix: bigint가 런타임때는 string으로 작동, decimal 15,2로 변경 * 🐛 fix: stock id를 이상하게 호출하는 문제 해결, express 와 동일한 구조로 되어있어 라우터 등록 순서에 영향 받는 다는 것을 간과함 --- .../backend/src/alarm/alarm.controller.ts | 78 +++++++++---------- packages/backend/src/alarm/alarm.service.ts | 5 +- .../backend/src/alarm/domain/alarm.entity.ts | 8 +- packages/backend/src/auth/auth.module.ts | 2 +- packages/backend/src/utils/date.ts | 8 +- 5 files changed, 55 insertions(+), 46 deletions(-) diff --git a/packages/backend/src/alarm/alarm.controller.ts b/packages/backend/src/alarm/alarm.controller.ts index 98b28e08..9d3586fb 100644 --- a/packages/backend/src/alarm/alarm.controller.ts +++ b/packages/backend/src/alarm/alarm.controller.ts @@ -45,6 +45,45 @@ export class AlarmController { return await this.alarmService.create(alarmRequest, userId); } + @Get('user') + @ApiOperation({ + summary: '사용자별 알림 조회', + description: '사용자 아이디를 기준으로 모든 알림을 조회한다.', + }) + @ApiOkResponse({ + description: '사용자에게 등록되어 있는 모든 알림 조회', + type: [AlarmResponse], + }) + @UseGuards(SessionGuard) + async getByUserId(@GetUser() user: User) { + const userId = user.id; + + return await this.alarmService.findByUserId(userId); + } + + @Get('stock/:stockId') + @ApiOperation({ + summary: '주식별 알림 조회', + description: '주식 아이디를 기준으로 알림을 조회한다.', + }) + @ApiOkResponse({ + description: + '주식 아이디에 등록되어 있는 알림 중 유저에 해당하는 알림 조회', + type: [AlarmResponse], + }) + @ApiParam({ + name: 'id', + type: String, + description: '주식 아이디', + example: '005930', + }) + @UseGuards(SessionGuard) + async getByStockId(@Param('id') stockId: string, @GetUser() user: User) { + const userId = user.id; + + return await this.alarmService.findByStockId(stockId, userId); + } + @Get(':id') @ApiOperation({ summary: '등록된 알림 확인', @@ -110,43 +149,4 @@ export class AlarmController { return new AlarmSuccessResponse('알림 삭제를 성공했습니다.'); } - - @Get('user') - @ApiOperation({ - summary: '사용자별 알림 조회', - description: '사용자 아이디를 기준으로 모든 알림을 조회한다.', - }) - @ApiOkResponse({ - description: '사용자에게 등록되어 있는 모든 알림 조회', - type: [AlarmResponse], - }) - @UseGuards(SessionGuard) - async getByUserId(@GetUser() user: User) { - const userId = user.id; - - return await this.alarmService.findByUserId(userId); - } - - @Get('stock/:stockId') - @ApiOperation({ - summary: '주식별 알림 조회', - description: '주식 아이디를 기준으로 알림을 조회한다.', - }) - @ApiOkResponse({ - description: - '주식 아이디에 등록되어 있는 알림 중 유저에 해당하는 알림 조회', - type: [AlarmResponse], - }) - @ApiParam({ - name: 'id', - type: String, - description: '주식 아이디', - example: '005930', - }) - @UseGuards(SessionGuard) - async getByStockId(@Param('stockId') stockId: string, @GetUser() user: User) { - const userId = user.id; - - return await this.alarmService.findByStockId(stockId, userId); - } } diff --git a/packages/backend/src/alarm/alarm.service.ts b/packages/backend/src/alarm/alarm.service.ts index d8e6343d..6b8eb969 100644 --- a/packages/backend/src/alarm/alarm.service.ts +++ b/packages/backend/src/alarm/alarm.service.ts @@ -47,11 +47,12 @@ export class AlarmService { return result.map((val) => new AlarmResponse(val)); } - async findByStockId(stockId: string, userId: number): Promise { - return await this.alarmRepository.find({ + async findByStockId(stockId: string, userId: number) { + const result = await this.alarmRepository.find({ where: { stock: { id: stockId }, user: { id: userId } }, relations: ['user', 'stock'], }); + return result.map((val) => new AlarmResponse(val)); } async findOne(id: number) { diff --git a/packages/backend/src/alarm/domain/alarm.entity.ts b/packages/backend/src/alarm/domain/alarm.entity.ts index 4d33d215..c3461198 100644 --- a/packages/backend/src/alarm/domain/alarm.entity.ts +++ b/packages/backend/src/alarm/domain/alarm.entity.ts @@ -26,7 +26,13 @@ export class Alarm { @Column({ type: 'int', name: 'target_price', nullable: true }) targetPrice?: number; - @Column({ type: 'bigint', name: 'target_volume', nullable: true }) + @Column({ + type: 'decimal', + precision: 15, + scale: 2, + name: 'target_volume', + nullable: true, + }) targetVolume?: number; @Column({ type: 'timestamp', name: 'alarm_date', nullable: true }) diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index bb43faf7..f5bb46a0 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; +import { AuthController } from '@/auth/auth.controller'; import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; -import { AuthController } from '@/auth/auth.controller'; import { SessionSerializer } from '@/auth/session/session.serializer'; import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; diff --git a/packages/backend/src/utils/date.ts b/packages/backend/src/utils/date.ts index 4d3987b9..9ab15522 100644 --- a/packages/backend/src/utils/date.ts +++ b/packages/backend/src/utils/date.ts @@ -1,4 +1,6 @@ export function getFormattedDate(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1) - .padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; -} \ No newline at end of file + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + '0', + )}-${String(date.getDate()).padStart(2, '0')}`; +} From 18404dc3d182a30079f4f45d8c205eaf3750a79b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 20:55:52 +0900 Subject: [PATCH 213/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20stock?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A7=8E=EC=9D=80=20=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=88=98=20swagger=20=EB=8D=B0=EC=BD=94=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/decorator/stock.decorator.ts | 34 ++++++++++++++- .../backend/src/stock/stock.controller.ts | 43 ++++++------------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/packages/backend/src/stock/decorator/stock.decorator.ts b/packages/backend/src/stock/decorator/stock.decorator.ts index 0b80892e..11aad019 100644 --- a/packages/backend/src/stock/decorator/stock.decorator.ts +++ b/packages/backend/src/stock/decorator/stock.decorator.ts @@ -5,8 +5,13 @@ import { ParseIntPipe, Query, } from '@nestjs/common'; -import { ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { StocksResponse } from '../dto/stock.response'; +import { + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiResponse, +} from '@nestjs/swagger'; +import { StockRankResponses, StocksResponse } from '../dto/stock.response'; export function LimitQuery(defaultValue = 5): ParameterDecorator { return Query('limit', new DefaultValuePipe(defaultValue), ParseIntPipe); @@ -30,3 +35,28 @@ export function ApiGetStocks(summary: string) { }), ); } + +export function ApiFluctuationQuery() { + return applyDecorators( + ApiOperation({ + summary: '등가, 등락률 기반 주식 리스트 조회 API', + description: '등가, 등락률 기반 주식 리스트를 조회합니다', + }), + ApiQuery({ + name: 'limit', + required: false, + description: + '조회할 리스트 수(기본값: 20, 등가, 등락 모두 받으면 모든 데이터 전송)', + }), + ApiQuery({ + name: 'type', + required: false, + description: '데이터 타입(기본값: increase, all, increase, decrease)', + enum: ['increase', 'decrease', 'all'], + }), + ApiOkResponse({ + description: '', + type: [StockRankResponses], + }), + ); +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 1a3725e8..2b47b9b9 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -15,10 +15,13 @@ import { ApiOkResponse, ApiOperation, ApiParam, - ApiQuery, } from '@nestjs/swagger'; import { Request } from 'express'; -import { ApiGetStocks, LimitQuery } from './decorator/stock.decorator'; +import { + ApiFluctuationQuery, + ApiGetStocks, + LimitQuery, +} from './decorator/stock.decorator'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; import { StockIndexRateResponse } from './dto/stockIndexRate.response'; @@ -29,9 +32,14 @@ import SessionGuard from '@/auth/session/session.guard'; import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; import { TIME_UNIT } from '@/stock/constants/timeunit'; +import { + StockDaily, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; import { StockSearchRequest } from '@/stock/dto/stock.request'; import { - StockRankResponses, StockSearchResponse, StockViewsResponse, } from '@/stock/dto/stock.response'; @@ -45,14 +53,8 @@ import { UserStockResponse, UserStocksResponse, } from '@/stock/dto/userStock.response'; -import { User } from '@/user/domain/user.entity'; import { StockDataService } from '@/stock/stockData.service'; -import { - StockDaily, - StockMonthly, - StockWeekly, - StockYearly, -} from '@/stock/domain/stockData.entity'; +import { User } from '@/user/domain/user.entity'; const FLUCTUATION_TYPE = { INCREASE: 'increase', @@ -209,26 +211,7 @@ export class StockController { } @Get('fluctuation') - @ApiOperation({ - summary: '등가, 등락률 기반 주식 리스트 조회 API', - description: '등가, 등락률 기반 주식 리스트를 조회합니다', - }) - @ApiQuery({ - name: 'limit', - required: false, - description: - '조회할 리스트 수(기본값: 20, 등가, 등락 모두 받으면 모든 데이터 전송)', - }) - @ApiQuery({ - name: 'type', - required: false, - description: '데이터 타입(기본값: increase, all, increase, decrease)', - enum: ['increase', 'decrease', 'all'], - }) - @ApiOkResponse({ - description: '', - type: [StockRankResponses], - }) + @ApiFluctuationQuery() async getTopStocksByFluctuation( @LimitQuery(20) limit: number, @Query('type') type: FLUCTUATION_TYPE, From 274e6d6bb9a0d99a5323acb93eb05cca1187f833 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 20:57:27 +0900 Subject: [PATCH 214/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20import=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/auth/auth.module.ts | 2 +- packages/backend/src/chat/domain/chat.entity.ts | 2 +- packages/backend/src/stock/cache/stockData.cache.ts | 2 +- packages/backend/src/user/dto/user.response.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index bb43faf7..f5bb46a0 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; +import { AuthController } from '@/auth/auth.controller'; import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; -import { AuthController } from '@/auth/auth.controller'; import { SessionSerializer } from '@/auth/session/session.serializer'; import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index ccaec4c8..683a411a 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -9,10 +9,10 @@ import { } from 'typeorm'; import { ChatType } from '@/chat/domain/chatType.enum'; import { Like } from '@/chat/domain/like.entity'; +import { Mention } from '@/chat/domain/mention.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; -import { Mention } from '@/chat/domain/mention.entity'; @Entity() export class Chat { diff --git a/packages/backend/src/stock/cache/stockData.cache.ts b/packages/backend/src/stock/cache/stockData.cache.ts index 2a9e93c4..5a1ea479 100644 --- a/packages/backend/src/stock/cache/stockData.cache.ts +++ b/packages/backend/src/stock/cache/stockData.cache.ts @@ -13,4 +13,4 @@ export class StockDataCache { get(key: string): StockDataResponse | null { return this.localCache.get(key); } -} \ No newline at end of file +} diff --git a/packages/backend/src/user/dto/user.response.ts b/packages/backend/src/user/dto/user.response.ts index e348b968..b5ce391e 100644 --- a/packages/backend/src/user/dto/user.response.ts +++ b/packages/backend/src/user/dto/user.response.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { User } from '@/user/domain/user.entity'; import { OauthType } from '@/user/domain/ouathType'; +import { User } from '@/user/domain/user.entity'; interface UserResponse { nickname: string; From 26d7c0012ac044a9a15b928f6862a8d72996784b Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 21:05:27 +0900 Subject: [PATCH 215/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20scroll?= =?UTF-8?q?Chart=20=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stockData.service.ts | 102 +++++++++++------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index 141f81b4..e18ad580 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -7,7 +7,6 @@ import { DataSource, EntityManager } from 'typeorm'; import { Stock } from './domain/stock.entity'; import { StockDaily, - StockMinutely, StockMonthly, StockWeekly, StockYearly, @@ -58,6 +57,42 @@ export class StockDataService { if (cachedData) { return cachedData; } + const response = await this.getChartData(entity, stockId, lastStartTime); + this.stockDataCache.set(cacheKey, response); + return response; + } + + private async getCardDataWithMissing( + entity: new () => StockData, + stockId: string, + periodType: Period, + lastStartTime?: string, + ) { + return new Promise((resolve) => { + this.openapiPeriodData.insertCartDataRequest( + this.getHandleResponseCallback(entity, stockId, resolve, lastStartTime), + stockId, + periodType, + ); + }); + } + + private async findLastData(entity: new () => StockData, stockId: string) { + return await this.dataSource.manager.findOne(entity, { + where: { stock: { id: stockId } }, + order: { startTime: 'DESC' }, + }); + } + + private async isStockExist(stockId: string, manager: EntityManager) { + return await manager.exists(Stock, { where: { id: stockId } }); + } + + private async getChartData( + entity: new () => StockData, + stockId: string, + lastStartTime?: string, + ) { const lastData = await this.findLastData(entity, stockId); const periodType = this.getPeriodType(entity); if (!periodType) throw new BadRequestException('period type not found'); @@ -65,36 +100,17 @@ export class StockDataService { !lastStartTime && (!lastData || !this.isLastDate(lastData, periodType)) ) { - return new Promise((resolve) => { - this.openapiPeriodData.insertCartDataRequest( - async () => { - setTimeout( - async () => - resolve( - await this.getChartData(entity, stockId, lastStartTime), - ), - 2000, - ); - - const response = await this.getChartData( - entity, - stockId, - lastStartTime, - ); - - resolve(response); - }, - stockId, - periodType, - ); - }); + return this.getCardDataWithMissing( + entity, + stockId, + periodType, + lastStartTime, + ); } - const response = await this.getChartData(entity, stockId, lastStartTime); - this.stockDataCache.set(cacheKey, response); - return response; + return await this.getChartDataFromDB(entity, stockId, lastStartTime); } - async getChartData( + private async getChartDataFromDB( entity: new () => StockData, stockId: string, lastStartTime?: string, @@ -109,15 +125,29 @@ export class StockDataService { return this.convertResultsToResponse(results); } - async findLastData(entity: new () => StockData, stockId: string) { - return await this.dataSource.manager.findOne(entity, { - where: { stock: { id: stockId } }, - order: { startTime: 'DESC' }, - }); - } + private getHandleResponseCallback( + entity: new () => StockData, + stockId: string, + resolve: (value: StockDataResponse) => void, + lastStartTime?: string, + ) { + return async () => { + setTimeout( + async () => + resolve( + await this.getChartDataFromDB(entity, stockId, lastStartTime), + ), + 2000, + ); - async isStockExist(stockId: string, manager: EntityManager) { - return await manager.exists(Stock, { where: { id: stockId } }); + const response = await this.getChartDataFromDB( + entity, + stockId, + lastStartTime, + ); + + resolve(response); + }; } private isLastDate(lastData: StockData, period: Period) { From bcba0ece9bf072ea2758e4401b60d2003ec61132 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 21:24:27 +0900 Subject: [PATCH 216/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EC=8B=9D?= =?UTF-8?q?=20=EC=A0=95=EC=82=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EC=A3=BC=EA=B8=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/scraper/openapi/api/openapiDetailData.api.ts | 2 +- .../backend/src/scraper/openapi/api/openapiPeriodData.api.ts | 2 +- packages/backend/src/scraper/openapi/api/openapiToken.api.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts index b653c13c..26464e38 100644 --- a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -24,7 +24,7 @@ export class OpenapiDetailData extends Openapi { super(datasource, config, 100); } - @Cron('35 0 * * 1-5') + @Cron('35 0 * * 2-6') async start() { super.start(); } diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 52d6f6d8..09ccf1b9 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -49,7 +49,7 @@ export class OpenapiPeriodData { //this.getItemChartPriceCheck(); } - @Cron('0 1 * * 1-5') + @Cron('0 1 * * 2-6') async getItemChartPriceCheck() { if (process.env.NODE_ENV !== 'production') return; const stocks = await this.datasource.manager.find(Stock, { diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index af1b6cb6..ff1c076e 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -39,7 +39,7 @@ export class OpenapiTokenApi { return this.config; } - @Cron('30 0 * * 1-5') + @Cron('30 0 * * *') async init() { const expired_config = this.config.filter( (val) => From f8d459c6704349df433a64f5c1c9239d25c37fed Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 21:25:15 +0900 Subject: [PATCH 217/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC=EB=A7=90?= =?UTF-8?q?=EC=97=90=20=EC=B0=A8=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A7=91=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=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/stock/stockData.service.ts | 5 +++-- packages/backend/src/utils/date.ts | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index e18ad580..bc245eaa 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -20,7 +20,7 @@ import { OpenapiPeriodData } from '@/scraper/openapi/api/openapiPeriodData.api'; import { Period } from '@/scraper/openapi/type/openapiPeriodData.type'; import { NewDate } from '@/scraper/openapi/util/newDate.util'; import { StockDataCache } from '@/stock/cache/stockData.cache'; -import { getFormattedDate } from '@/utils/date'; +import { getFormattedDate, isTodayWeekend } from '@/utils/date'; type StockData = { id: number; @@ -97,10 +97,11 @@ export class StockDataService { const periodType = this.getPeriodType(entity); if (!periodType) throw new BadRequestException('period type not found'); if ( + !isTodayWeekend() && !lastStartTime && (!lastData || !this.isLastDate(lastData, periodType)) ) { - return this.getCardDataWithMissing( + return await this.getCardDataWithMissing( entity, stockId, periodType, diff --git a/packages/backend/src/utils/date.ts b/packages/backend/src/utils/date.ts index 4d3987b9..c0bbc79b 100644 --- a/packages/backend/src/utils/date.ts +++ b/packages/backend/src/utils/date.ts @@ -1,4 +1,12 @@ export function getFormattedDate(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1) - .padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + '0', + )}-${String(date.getDate()).padStart(2, '0')}`; +} + +export function isTodayWeekend() { + const today = new Date(); + const day = today.getDay(); + return day === 0 || day === 6; } \ No newline at end of file From 0ca4bee4bdc25076c42023da477971b9c6df84a7 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Wed, 4 Dec 2024 21:26:44 +0900 Subject: [PATCH 218/223] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20eslint?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scraper/korea-stock-info/korea-stock-info.service.spec.ts | 4 ++-- packages/backend/src/user/domain/theme.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts index 436da928..4a76d04f 100644 --- a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts +++ b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts @@ -1,9 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { KoreaStockInfoService } from './korea-stock-info.service'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Stock } from '@/stock/domain/stock.entity'; import { WinstonModule } from 'nest-winston'; +import { KoreaStockInfoService } from './korea-stock-info.service'; import { logger } from '@/configs/logger.config'; +import { Stock } from '@/stock/domain/stock.entity'; xdescribe('KoreaStockInfoService', () => { let service: KoreaStockInfoService; diff --git a/packages/backend/src/user/domain/theme.ts b/packages/backend/src/user/domain/theme.ts index 2370623f..a643a99d 100644 --- a/packages/backend/src/user/domain/theme.ts +++ b/packages/backend/src/user/domain/theme.ts @@ -3,4 +3,4 @@ export const Theme = { dark: 'dark', }; -export type Theme = typeof Theme[keyof typeof Theme]; +export type Theme = (typeof Theme)[keyof typeof Theme]; From b48f975cfed6190e13cacd81fad10a5eb8f20f8d Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Thu, 5 Dec 2024 01:24:12 +0900 Subject: [PATCH 219/223] =?UTF-8?q?Feature/#365=20=EC=95=8C=EB=A6=BC=20res?= =?UTF-8?q?ponse=20=EB=B3=80=EA=B2=BD,=20=EC=95=8C=EB=A6=BC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=A8=EC=88=98=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20(#366)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style: eslint 깨지는 파일 수정 * 📝 docs: stockId 잘못 들어 있던 데코레이터 수정 * 📝 docs: response alarmresponse 로 수정 * 🐛 fix: bigint가 런타임때는 string으로 작동, decimal 15,2로 변경 * 🐛 fix: stock id를 이상하게 호출하는 문제 해결, express 와 동일한 구조로 되어있어 라우터 등록 순서에 영향 받는 다는 것을 간과함 * 🐛 fix: put 에러 수정, alarmDate -\> alarmExpiredDate로 명확화 * 📝 docs: reqeust 업데이트 * 🐛 fix: alarmDate 업데이트 * 🐛 fix: alarmExpiredDate로 변환, 이에 맞춰 docs도 업데이트 * ✨ feat: 알림 서비스 검증 로직 구현 * 📝 docs: 알림 docs 에러 수정 * 🐛 fix: expired date 논리 오류 수정 * 📝 docs: 원 추가 --- .../backend/src/alarm/alarm.controller.ts | 26 +++- packages/backend/src/alarm/alarm.service.ts | 126 +++++++++++++++++- .../backend/src/alarm/alarm.subscriber.ts | 16 +-- .../backend/src/alarm/domain/alarm.entity.ts | 2 +- .../backend/src/alarm/dto/alarm.request.ts | 4 +- .../backend/src/alarm/dto/alarm.response.ts | 4 +- 6 files changed, 153 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/alarm/alarm.controller.ts b/packages/backend/src/alarm/alarm.controller.ts index 9d3586fb..648443ac 100644 --- a/packages/backend/src/alarm/alarm.controller.ts +++ b/packages/backend/src/alarm/alarm.controller.ts @@ -9,6 +9,7 @@ import { UseGuards, } from '@nestjs/common'; import { + ApiBadRequestResponse, ApiOkResponse, ApiOperation, ApiParam, @@ -35,6 +36,17 @@ export class AlarmController { description: '알림 생성 완료', type: AlarmResponse, }) + @ApiBadRequestResponse({ + description: '유효하지 않은 알람 입력값으로 인해 예외가 발생했습니다.', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { type: 'string', example: '알람 조건을 다시 확인해주세요.' }, + error: { type: 'string', example: 'Bad Request' }, + }, + }, + }) @UseGuards(SessionGuard) async create( @Body() alarmRequest: AlarmRequest, @@ -109,8 +121,7 @@ export class AlarmController { summary: '등록된 알림 업데이트', description: '알림 아이디 기준으로 업데이트를 할 수 있다.', }) - @ApiResponse({ - status: 201, + @ApiOkResponse({ description: '아이디와 동일한 알림 업데이트', type: AlarmResponse, }) @@ -120,6 +131,17 @@ export class AlarmController { description: '알림 아이디', example: 1, }) + @ApiBadRequestResponse({ + description: '유효하지 않은 알람 입력값으로 인해 예외가 발생했습니다.', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { type: 'string', example: '알람 조건을 다시 확인해주세요.' }, + error: { type: 'string', example: 'Bad Request' }, + }, + }, + }) @UseGuards(SessionGuard) async update( @Param('id') alarmId: number, diff --git a/packages/backend/src/alarm/alarm.service.ts b/packages/backend/src/alarm/alarm.service.ts index 6b8eb969..d0f1bab5 100644 --- a/packages/backend/src/alarm/alarm.service.ts +++ b/packages/backend/src/alarm/alarm.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ForbiddenException, Injectable, NotFoundException, @@ -10,6 +11,8 @@ import { PushSubscription } from './domain/subscription.entity'; import { AlarmRequest } from './dto/alarm.request'; import { AlarmResponse } from './dto/alarm.response'; import { PushService } from './push.service'; +import { StockMinutely } from '@/stock/domain/stockData.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; import { User } from '@/user/domain/user.entity'; @Injectable() @@ -21,9 +24,113 @@ export class AlarmService { private readonly pushService: PushService, ) {} + private isAlarmNotExpired( + expiredDate: Date, + recent: StockMinutely | StockLiveData, + ): boolean { + const updatedAt = + (recent as StockLiveData).updatedAt || + (recent as StockMinutely).createdAt; + return updatedAt && expiredDate >= updatedAt; + } + + private isTargetPriceMet( + targetPrice: number, + recent: StockMinutely | StockLiveData, + ): boolean { + return targetPrice <= recent.open; + } + + private isTargetVolumeMet( + targetVolume: number, + recent: StockMinutely | StockLiveData, + ): boolean { + return targetVolume <= recent.volume; + } + + isValidAlarmCompareEntity( + alarm: Partial, + recent: StockMinutely | StockLiveData, + ): boolean { + if ( + alarm.alarmExpiredDate && + this.isAlarmNotExpired(alarm.alarmExpiredDate, recent) + ) { + return true; + } + if (alarm.targetPrice && this.isTargetPriceMet(alarm.targetPrice, recent)) { + return true; + } + if ( + alarm.targetVolume && + this.isTargetVolumeMet(alarm.targetVolume, recent) + ) { + return true; + } + return false; + } + + private validAlarmThrow( + alarm: Partial, + recent: StockMinutely | StockLiveData, + ) { + if ( + alarm.alarmExpiredDate && + !this.isAlarmNotExpired(alarm.alarmExpiredDate, recent) + ) + throw new BadRequestException( + `${alarm.alarmExpiredDate}는 잘못된 날짜입니다. 다시 입력해주세요.`, + ); + + if (alarm.targetPrice && this.isTargetPriceMet(alarm.targetPrice, recent)) + throw new BadRequestException( + `${alarm.targetPrice}원은 최근 가격보다 낮습니다. 다시 입력해주세요.`, + ); + + if ( + alarm.targetVolume && + this.isTargetVolumeMet(alarm.targetVolume, recent) + ) + throw new BadRequestException( + `${alarm.targetVolume}은 최근 거래량보다 낮습니다. 다시 입력해주세요.`, + ); + } + + async validAlarmThrowException( + alarmData: AlarmRequest, + stockId: string = alarmData.stockId, + ) { + const recentLiveData = await this.dataSource.manager.findOne( + StockLiveData, + { + where: { stock: { id: stockId } }, + }, + ); + + if (recentLiveData) { + this.validAlarmThrow(alarmData, recentLiveData); + } + + const recentMinuteData = await this.dataSource.manager.findOne( + StockMinutely, + { + where: { stock: { id: stockId } }, + order: { startTime: 'DESC' }, + }, + ); + + if (recentMinuteData) { + this.validAlarmThrow(alarmData, recentMinuteData); + } + + return true; + } + async create(alarmData: AlarmRequest, userId: number) { + await this.validAlarmThrowException(alarmData); return await this.dataSource.transaction(async (manager) => { const repository = manager.getRepository(Alarm); + const user = await manager.findOne(User, { where: { id: userId } }); if (!user) { throw new ForbiddenException('유저를 찾을 수 없습니다.'); @@ -34,7 +141,9 @@ export class AlarmService { user, stock: { id: alarmData.stockId }, }); + const result = await repository.save(newAlarm); + return new AlarmResponse(result); }); } @@ -44,6 +153,7 @@ export class AlarmService { where: { user: { id: userId } }, relations: ['user', 'stock'], }); + return result.map((val) => new AlarmResponse(val)); } @@ -52,29 +162,38 @@ export class AlarmService { where: { stock: { id: stockId }, user: { id: userId } }, relations: ['user', 'stock'], }); + return result.map((val) => new AlarmResponse(val)); } async findOne(id: number) { const result = await this.alarmRepository.findOne({ where: { id }, - relations: ['user', 'stock'], + relations: ['stock'], }); + if (result) return new AlarmResponse(result); else throw new NotFoundException('등록된 알림을 찾을 수 없습니다.'); } async update(id: number, updateData: AlarmRequest) { + await this.validAlarmThrowException(updateData); const alarm = await this.alarmRepository.findOne({ where: { id } }); if (!alarm) { throw new NotFoundException('등록된 알림을 찾을 수 없습니다.'); } - await this.alarmRepository.update(id, updateData); + await this.alarmRepository.update(id, { + stock: { id: updateData.stockId }, + targetVolume: updateData.targetVolume, + targetPrice: updateData.targetPrice, + alarmExpiredDate: updateData.alarmExpiredDate, + }); const updatedAlarm = await this.alarmRepository.findOne({ where: { id }, - relations: ['user', 'stock'], + relations: ['stock'], }); + if (updatedAlarm) return new AlarmResponse(updatedAlarm); else throw new NotFoundException( @@ -84,6 +203,7 @@ export class AlarmService { async delete(id: number) { const alarm = await this.alarmRepository.findOne({ where: { id } }); + if (!alarm) { throw new NotFoundException(`${id} : 삭제할 알림을 찾을 수 없습니다.`); } diff --git a/packages/backend/src/alarm/alarm.subscriber.ts b/packages/backend/src/alarm/alarm.subscriber.ts index 3f320b73..14cdf97b 100644 --- a/packages/backend/src/alarm/alarm.subscriber.ts +++ b/packages/backend/src/alarm/alarm.subscriber.ts @@ -27,20 +27,6 @@ export class AlarmSubscriber return StockMinutely; } - isValidAlarm(alarm: Alarm, entity: StockMinutely) { - if (alarm.alarmDate && alarm.alarmDate >= entity.createdAt) { - return false; - } else { - if (alarm.targetPrice && alarm.targetPrice <= entity.open) { - return true; - } - if (alarm.targetVolume && alarm.targetVolume <= entity.volume) { - return true; - } - return false; - } - } - async afterInsert(event: InsertEvent) { try { const stockMinutely = event.entity; @@ -49,7 +35,7 @@ export class AlarmSubscriber relations: ['user', 'stock'], }); const alarms = rawAlarms.filter((val) => - this.isValidAlarm(val, stockMinutely), + this.alarmService.isValidAlarmCompareEntity(val, stockMinutely), ); for (const alarm of alarms) { await this.alarmService.sendPushNotification(alarm); diff --git a/packages/backend/src/alarm/domain/alarm.entity.ts b/packages/backend/src/alarm/domain/alarm.entity.ts index c3461198..eb8718c3 100644 --- a/packages/backend/src/alarm/domain/alarm.entity.ts +++ b/packages/backend/src/alarm/domain/alarm.entity.ts @@ -36,7 +36,7 @@ export class Alarm { targetVolume?: number; @Column({ type: 'timestamp', name: 'alarm_date', nullable: true }) - alarmDate?: Date; + alarmExpiredDate?: Date; @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) createdAt: Date; diff --git a/packages/backend/src/alarm/dto/alarm.request.ts b/packages/backend/src/alarm/dto/alarm.request.ts index d5746518..a47f76e0 100644 --- a/packages/backend/src/alarm/dto/alarm.request.ts +++ b/packages/backend/src/alarm/dto/alarm.request.ts @@ -23,8 +23,8 @@ export class AlarmRequest { @ApiProperty({ description: '알림 종료 날짜', - example: '2024-12-01T00:00:00Z', + example: '2026-12-01T00:00:00Z', required: false, }) - alarmDate?: Date; + alarmExpiredDate?: Date; } diff --git a/packages/backend/src/alarm/dto/alarm.response.ts b/packages/backend/src/alarm/dto/alarm.response.ts index d2dc21fd..ff434a74 100644 --- a/packages/backend/src/alarm/dto/alarm.response.ts +++ b/packages/backend/src/alarm/dto/alarm.response.ts @@ -33,14 +33,14 @@ export class AlarmResponse { example: 10, nullable: true, }) - alarmDate?: Date; + alarmExpiredDate?: Date; constructor(alarm: Alarm) { this.alarmId = alarm.id; this.stockId = alarm.stock.id; this.targetPrice = alarm.targetPrice; this.targetVolume = alarm.targetVolume; - this.alarmDate = alarm.alarmDate; + this.alarmExpiredDate = alarm.alarmExpiredDate; } } From de66fde6b80f881fb9e671d8ac0bd4dafc3905b5 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 5 Dec 2024 02:33:14 +0900 Subject: [PATCH 220/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=A3=BC,=EC=9B=94?= =?UTF-8?q?,=EB=85=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=8A=94=201=EC=8B=9C?= =?UTF-8?q?=EB=B6=80=ED=84=B0=2016=EC=8B=9C=EA=B9=8C=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/dto/stockData.response.ts | 40 +++++++++++++++--- .../backend/src/stock/stockData.service.ts | 42 +++++++++++++++---- packages/backend/src/utils/date.ts | 10 ++++- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts index 0f183d66..a9c92cd0 100644 --- a/packages/backend/src/stock/dto/stockData.response.ts +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -1,6 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { StockData } from '@/stock/domain/stockData.entity'; +import { + StockDaily, + StockData, + StockWeekly, +} from '@/stock/domain/stockData.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { getToday } from '@/utils/date'; export class PriceDto { @ApiProperty({ @@ -37,10 +43,10 @@ export class PriceDto { constructor(stockData: StockData) { this.startTime = stockData.startTime; - this.open = stockData.open; - this.high = stockData.high; - this.low = stockData.low; - this.close = stockData.close; + this.open = Number(stockData.open); + this.high = Number(stockData.high); + this.low = Number(stockData.low); + this.close = Number(stockData.close); } } @@ -61,7 +67,7 @@ export class VolumeDto { constructor(stockData: StockData) { this.startTime = stockData.startTime; - this.volume = stockData.volume; + this.volume = Number(stockData.volume); } } @@ -95,4 +101,26 @@ export class StockDataResponse { this.volumeDtoList = volumeDtoList; this.hasMore = hasMore; } + + renewLastData(stockLiveData: StockLiveData, entity: new () => StockData) { + const lastIndex = this.priceDtoList.length - 1; + this.priceDtoList[lastIndex].close = Number(stockLiveData.currentPrice); + this.priceDtoList[lastIndex].high = + stockLiveData.high > this.priceDtoList[lastIndex].high + ? stockLiveData.high + : this.priceDtoList[lastIndex].high; + this.priceDtoList[lastIndex].low = + stockLiveData.low < this.priceDtoList[lastIndex].low + ? stockLiveData.low + : this.priceDtoList[lastIndex].low; + + this.priceDtoList[lastIndex].startTime = + entity !== StockWeekly + ? getToday() + : this.priceDtoList[lastIndex].startTime; + this.volumeDtoList[lastIndex].volume = + entity === StockDaily + ? stockLiveData.volume + : this.volumeDtoList[lastIndex].volume + stockLiveData.volume; + } } diff --git a/packages/backend/src/stock/stockData.service.ts b/packages/backend/src/stock/stockData.service.ts index bc245eaa..3527b240 100644 --- a/packages/backend/src/stock/stockData.service.ts +++ b/packages/backend/src/stock/stockData.service.ts @@ -20,6 +20,7 @@ import { OpenapiPeriodData } from '@/scraper/openapi/api/openapiPeriodData.api'; import { Period } from '@/scraper/openapi/type/openapiPeriodData.type'; import { NewDate } from '@/scraper/openapi/util/newDate.util'; import { StockDataCache } from '@/stock/cache/stockData.cache'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; import { getFormattedDate, isTodayWeekend } from '@/utils/date'; type StockData = { @@ -108,7 +109,30 @@ export class StockDataService { lastStartTime, ); } - return await this.getChartDataFromDB(entity, stockId, lastStartTime); + const response = await this.getChartDataFromDB( + entity, + stockId, + lastStartTime, + ); + const time = new Date(); + if (!lastStartTime && time.getHours() < 16 && time.getHours() >= 9) { + return await this.renewResponse(response, entity, stockId); + } + return response; + } + + private async renewResponse( + response: StockDataResponse, + entity: new () => StockData, + stockId: string, + ) { + const liveData = await this.dataSource.manager.findOne(StockLiveData, { + where: { stock: { id: stockId } }, + }); + if (liveData) { + response.renewLastData(liveData, entity); + } + return response; } private async getChartDataFromDB( @@ -157,17 +181,21 @@ export class StockDataService { if (period === 'D') return lastDate.isSameDate(current); if (period === 'M') { return ( - lastDate.isSameWeek(current) && - lastDate.isSameYear(current) && - lastDate.isSameDate(current) && - lastDate.isSameDate(current) + lastDate.isSameMonth(current) && + (lastDate.isSameDate(current) || + (current.getHours() < 16 && current.getHours() > 1)) ); } if (period === 'Y') - return lastDate.isSameYear(current) && lastDate.isSameDate(current); + return ( + lastDate.isSameYear(current) && + (lastDate.isSameDate(current) || + (current.getHours() < 16 && current.getHours() > 1)) + ); return ( lastDate.isSameWeek(current) && - lastData.createdAt.getDate() === current.getDate() + (lastData.createdAt.getDate() === current.getDate() || + (current.getHours() < 16 && current.getHours() > 1)) ); } diff --git a/packages/backend/src/utils/date.ts b/packages/backend/src/utils/date.ts index c0bbc79b..bea2b209 100644 --- a/packages/backend/src/utils/date.ts +++ b/packages/backend/src/utils/date.ts @@ -9,4 +9,12 @@ export function isTodayWeekend() { const today = new Date(); const day = today.getDay(); return day === 0 || day === 6; -} \ No newline at end of file +} + +export function getToday() { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + const day = now.getDate(); + return new Date(year, month, day); +} From 89725df0ec639d40f1d86e7eea5f3e807f2c2b89 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 5 Dec 2024 02:44:00 +0900 Subject: [PATCH 221/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9B=B9=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=B2=AB=20=EC=97=B0=EA=B2=B0=20=EC=8B=9C?= =?UTF-8?q?=20rest=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=ED=81=90=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/api/openapiLiveData.api.ts | 13 +++++- .../src/scraper/openapi/liveData.service.ts | 44 +++++++------------ 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts index f7288829..7b17568a 100644 --- a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -5,7 +5,7 @@ import { openApiConfig } from '../config/openapi.config'; import { isOpenapiLiveData } from '../type/openapiLiveData.type'; import { TR_IDS } from '../type/openapiUtil.type'; import { getOpenApi } from '../util/openapiUtil.api'; -import { Json } from '@/scraper/openapi/queue/openapi.queue'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; import { Stock } from '@/stock/domain/stock.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @@ -15,6 +15,7 @@ export class OpenapiLiveData { '/uapi/domestic-stock/v1/quotations/inquire-ccnl'; constructor( private readonly datasource: DataSource, + private readonly openapiQueue: OpenapiQueue, @Inject('winston') private readonly logger: Logger, ) {} @@ -76,6 +77,16 @@ export class OpenapiLiveData { return stockData; } + insertLiveDataRequest(stockId: string) { + const query = this.makeLiveDataQuery(stockId); + this.openapiQueue.enqueue({ + url: this.url, + query, + trId: TR_IDS.LIVE_DATA, + callback: this.getLiveDataSaveCallback(stockId), + }); + } + async connectLiveData(stockId: string, config: typeof openApiConfig) { const query = this.makeLiveDataQuery(stockId); diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts index 31b05b4c..473405b2 100644 --- a/packages/backend/src/scraper/openapi/liveData.service.ts +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -39,22 +39,6 @@ export class LiveData { }); } - private async openapiSubscribe(stockId: string) { - const config = (await this.openApiToken.configs())[0]; - const result = await this.openapiLiveData.connectLiveData(stockId, config); - try { - const stockLiveData = this.openapiLiveData.convertResponseToStockLiveData( - result.output, - stockId, - ); - if (stockLiveData) { - await this.openapiLiveData.saveLiveData(stockLiveData); - } - } catch (error) { - this.logger.warn(`Subscribe error in open api : ${error}`); - } - } - isSubscribe(stockId: string) { return Object.keys(this.subscribeStocks).some((val) => val === stockId); } @@ -105,6 +89,22 @@ export class LiveData { } } + @Cron('0 2 * * 1-5') + connect() { + this.websocketClient.forEach((socket, idx) => { + socket.connectFacade( + this.initOpenCallback(idx), + this.initMessageCallback, + this.initCloseCallback, + this.initErrorCallback, + ); + }); + } + + private async openapiSubscribe(stockId: string) { + this.openapiLiveData.insertLiveDataRequest(stockId); + } + private initOpenCallback = (idx: number) => (sendMessage: (message: string) => void) => async () => { this.logger.info('WebSocket connection established'); @@ -160,18 +160,6 @@ export class LiveData { return dateMinutes <= startMinutes || dateMinutes >= endMinutes; } - @Cron('0 2 * * 1-5') - connect() { - this.websocketClient.forEach((socket, idx) => { - socket.connectFacade( - this.initOpenCallback(idx), - this.initMessageCallback, - this.initCloseCallback, - this.initErrorCallback, - ); - }); - } - private convertObjectToMessage( config: typeof openApiConfig, stockId: string, From 29cddd42cb6e7e288a3b608eccd29159bfde98c8 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Thu, 5 Dec 2024 03:52:39 +0900 Subject: [PATCH 222/223] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=8A=94=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/stock/dto/stockData.response.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/stock/dto/stockData.response.ts b/packages/backend/src/stock/dto/stockData.response.ts index a9c92cd0..f251aa6d 100644 --- a/packages/backend/src/stock/dto/stockData.response.ts +++ b/packages/backend/src/stock/dto/stockData.response.ts @@ -43,10 +43,10 @@ export class PriceDto { constructor(stockData: StockData) { this.startTime = stockData.startTime; - this.open = Number(stockData.open); - this.high = Number(stockData.high); - this.low = Number(stockData.low); - this.close = Number(stockData.close); + this.open = stockData.open; + this.high = stockData.high; + this.low = stockData.low; + this.close = stockData.close; } } @@ -63,11 +63,11 @@ export class VolumeDto { description: '거래량', example: 1000, }) - volume: number; + volume: string; constructor(stockData: StockData) { this.startTime = stockData.startTime; - this.volume = Number(stockData.volume); + this.volume = String(stockData.volume); } } @@ -104,13 +104,13 @@ export class StockDataResponse { renewLastData(stockLiveData: StockLiveData, entity: new () => StockData) { const lastIndex = this.priceDtoList.length - 1; - this.priceDtoList[lastIndex].close = Number(stockLiveData.currentPrice); + this.priceDtoList[lastIndex].close = stockLiveData.currentPrice; this.priceDtoList[lastIndex].high = - stockLiveData.high > this.priceDtoList[lastIndex].high + Number(stockLiveData.high) > Number(this.priceDtoList[lastIndex].high) ? stockLiveData.high : this.priceDtoList[lastIndex].high; this.priceDtoList[lastIndex].low = - stockLiveData.low < this.priceDtoList[lastIndex].low + Number(stockLiveData.low) < Number(this.priceDtoList[lastIndex].low) ? stockLiveData.low : this.priceDtoList[lastIndex].low; @@ -120,7 +120,10 @@ export class StockDataResponse { : this.priceDtoList[lastIndex].startTime; this.volumeDtoList[lastIndex].volume = entity === StockDaily - ? stockLiveData.volume - : this.volumeDtoList[lastIndex].volume + stockLiveData.volume; + ? String(stockLiveData.volume) + : String( + Number(this.volumeDtoList[lastIndex].volume) + + Number(stockLiveData.volume), + ); } } From 9add4dc2c98eb1bee2a7dc5ef5e2469a07067f61 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Thu, 5 Dec 2024 04:00:15 +0900 Subject: [PATCH 223/223] =?UTF-8?q?Feature/#369=20eslint,=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20string=20=EB=A6=AC=ED=84=B4=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20swagger=20=EC=84=B1=EA=B3=B5=ED=95=98=EA=B2=8C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B3=80=EA=B2=BD=20(#370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style: eslint 깨지는 파일 수정 * 📝 docs: stockId 잘못 들어 있던 데코레이터 수정 * 📝 docs: response alarmresponse 로 수정 * 🐛 fix: bigint가 런타임때는 string으로 작동, decimal 15,2로 변경 * 🐛 fix: stock id를 이상하게 호출하는 문제 해결, express 와 동일한 구조로 되어있어 라우터 등록 순서에 영향 받는 다는 것을 간과함 * 🐛 fix: put 에러 수정, alarmDate -\> alarmExpiredDate로 명확화 * 📝 docs: reqeust 업데이트 * 🐛 fix: alarmDate 업데이트 * 🐛 fix: alarmExpiredDate로 변환, 이에 맞춰 docs도 업데이트 * ✨ feat: 알림 서비스 검증 로직 구현 * 📝 docs: 알림 docs 에러 수정 * 🐛 fix: expired date 논리 오류 수정 * 📝 docs: 원 추가 * 🐛 fix: api/alarm/stock 받아올 때 중복 stock 수정 * 📝 docs: swagger 성공하게 변경 * 💄 style: 줄 제약으로 인한 커스텀 데코레이터 적용 * 🐛 fix: response에 number 추가 --- .../backend/src/alarm/alarm.controller.ts | 32 +++---------------- .../src/alarm/decorator/wrong.decorator.ts | 23 +++++++++++++ .../backend/src/alarm/dto/alarm.request.ts | 4 +-- .../backend/src/alarm/dto/alarm.response.ts | 4 +-- 4 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 packages/backend/src/alarm/decorator/wrong.decorator.ts diff --git a/packages/backend/src/alarm/alarm.controller.ts b/packages/backend/src/alarm/alarm.controller.ts index 648443ac..e83df2b7 100644 --- a/packages/backend/src/alarm/alarm.controller.ts +++ b/packages/backend/src/alarm/alarm.controller.ts @@ -9,13 +9,13 @@ import { UseGuards, } from '@nestjs/common'; import { - ApiBadRequestResponse, ApiOkResponse, ApiOperation, ApiParam, ApiResponse, } from '@nestjs/swagger'; import { AlarmService } from './alarm.service'; +import { WrongAlarmApi } from './decorator/wrong.decorator'; import { AlarmRequest } from './dto/alarm.request'; import { AlarmResponse, AlarmSuccessResponse } from './dto/alarm.response'; import SessionGuard from '@/auth/session/session.guard'; @@ -36,18 +36,7 @@ export class AlarmController { description: '알림 생성 완료', type: AlarmResponse, }) - @ApiBadRequestResponse({ - description: '유효하지 않은 알람 입력값으로 인해 예외가 발생했습니다.', - schema: { - type: 'object', - properties: { - statusCode: { type: 'number', example: 400 }, - message: { type: 'string', example: '알람 조건을 다시 확인해주세요.' }, - error: { type: 'string', example: 'Bad Request' }, - }, - }, - }) - @UseGuards(SessionGuard) + @WrongAlarmApi() async create( @Body() alarmRequest: AlarmRequest, @GetUser() user: User, @@ -84,13 +73,13 @@ export class AlarmController { type: [AlarmResponse], }) @ApiParam({ - name: 'id', + name: 'stockId', type: String, description: '주식 아이디', example: '005930', }) @UseGuards(SessionGuard) - async getByStockId(@Param('id') stockId: string, @GetUser() user: User) { + async getByStockId(@Param('stockId') stockId: string, @GetUser() user: User) { const userId = user.id; return await this.alarmService.findByStockId(stockId, userId); @@ -131,18 +120,7 @@ export class AlarmController { description: '알림 아이디', example: 1, }) - @ApiBadRequestResponse({ - description: '유효하지 않은 알람 입력값으로 인해 예외가 발생했습니다.', - schema: { - type: 'object', - properties: { - statusCode: { type: 'number', example: 400 }, - message: { type: 'string', example: '알람 조건을 다시 확인해주세요.' }, - error: { type: 'string', example: 'Bad Request' }, - }, - }, - }) - @UseGuards(SessionGuard) + @WrongAlarmApi() async update( @Param('id') alarmId: number, @Body() updateData: AlarmRequest, diff --git a/packages/backend/src/alarm/decorator/wrong.decorator.ts b/packages/backend/src/alarm/decorator/wrong.decorator.ts new file mode 100644 index 00000000..e799c593 --- /dev/null +++ b/packages/backend/src/alarm/decorator/wrong.decorator.ts @@ -0,0 +1,23 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { ApiBadRequestResponse } from '@nestjs/swagger'; +import SessionGuard from '@/auth/session/session.guard'; + +export const WrongAlarmApi = () => { + return applyDecorators( + ApiBadRequestResponse({ + description: '유효하지 않은 알람 입력값으로 인해 예외가 발생했습니다.', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { + type: 'string', + example: '알람 조건을 다시 확인해주세요.', + }, + error: { type: 'string', example: 'Bad Request' }, + }, + }, + }), + UseGuards(SessionGuard), + ); +}; diff --git a/packages/backend/src/alarm/dto/alarm.request.ts b/packages/backend/src/alarm/dto/alarm.request.ts index a47f76e0..38ce9ea0 100644 --- a/packages/backend/src/alarm/dto/alarm.request.ts +++ b/packages/backend/src/alarm/dto/alarm.request.ts @@ -9,14 +9,14 @@ export class AlarmRequest { @ApiProperty({ description: '목표 가격', - example: 150.0, + example: 100000, required: false, }) targetPrice?: number; @ApiProperty({ description: '목표 거래량', - example: 1000, + example: 1000000000, required: false, }) targetVolume?: number; diff --git a/packages/backend/src/alarm/dto/alarm.response.ts b/packages/backend/src/alarm/dto/alarm.response.ts index ff434a74..5cccae6c 100644 --- a/packages/backend/src/alarm/dto/alarm.response.ts +++ b/packages/backend/src/alarm/dto/alarm.response.ts @@ -38,8 +38,8 @@ export class AlarmResponse { constructor(alarm: Alarm) { this.alarmId = alarm.id; this.stockId = alarm.stock.id; - this.targetPrice = alarm.targetPrice; - this.targetVolume = alarm.targetVolume; + this.targetPrice = Number(alarm.targetPrice); + this.targetVolume = Number(alarm.targetVolume); this.alarmExpiredDate = alarm.alarmExpiredDate; } }