From cf6a168e537eecd1c101b441e43a51b4cd15419a Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Mon, 25 Nov 2024 16:09:31 +0900 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20tradehistory=20api=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 이승관 --- .github/workflows/CICD.yml | 2 +- .../trade-history/trade-history.controller.ts | 3 ++- .../trade-history/trade-history.service.ts | 25 ------------------- .../server/src/trade/trade-ask.service.ts | 4 +++ 4 files changed, 7 insertions(+), 27 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index b972114a..a5202e6f 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -6,7 +6,7 @@ on: - main - dev - dev-be - - feature-be-#105 + - hotfix-be-#37 jobs: build_and_deploy: runs-on: ubuntu-latest diff --git a/packages/server/src/trade-history/trade-history.controller.ts b/packages/server/src/trade-history/trade-history.controller.ts index 10fb29b5..0eec0d7a 100644 --- a/packages/server/src/trade-history/trade-history.controller.ts +++ b/packages/server/src/trade-history/trade-history.controller.ts @@ -11,7 +11,7 @@ import { Res } from '@nestjs/common'; import { AuthGuard } from 'src/auth/auth.guard'; - import { ApiBearerAuth, ApiSecurity, ApiBody } from '@nestjs/swagger'; + import { ApiBearerAuth, ApiSecurity, ApiBody, ApiQuery } from '@nestjs/swagger'; import {Response} from "express"; import { TradeHistoryService } from './trade-history.service'; @@ -23,6 +23,7 @@ import { TradeHistoryService } from './trade-history.service'; @ApiBearerAuth('access-token') @ApiSecurity('access-token') + @ApiQuery({ name: 'coins', required: false, type: String }) @UseGuards(AuthGuard) @Get('tradehistoryData') async getMyTradeData( diff --git a/packages/server/src/trade-history/trade-history.service.ts b/packages/server/src/trade-history/trade-history.service.ts index 848a94bb..23d9dd7a 100644 --- a/packages/server/src/trade-history/trade-history.service.ts +++ b/packages/server/src/trade-history/trade-history.service.ts @@ -1,20 +1,14 @@ import { Injectable, - OnModuleInit, - UnprocessableEntityException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { CoinDataUpdaterService } from 'src/upbit/coin-data-updater.service'; import { TradeHistoryRepository } from '../trade-history/trade-history.repository'; -import { UPBIT_IMAGE_URL } from 'common/upbit'; -import { TradeHistoryDataDto } from './dtos/tradehistoryData.dto'; @Injectable() export class TradeHistoryService { constructor( private tradehistoryRepository: TradeHistoryRepository, - private coinDataUpdaterService: CoinDataUpdaterService, private readonly dataSource: DataSource, ) {} @@ -32,31 +26,12 @@ export class TradeHistoryService { result : [] } } - const coinNameData = await this.coinDataUpdaterService.getCoinNameList(); if(coin){ const [assetName, tradeCurrency] = coin.split("-") tradehistoryData = tradehistoryData.filter(({ assetName: a, tradeCurrency: t }) => (a === assetName && t === tradeCurrency) || (a === tradeCurrency && t === assetName)); } - tradehistoryData.forEach(tradehistory=>{ - const name = tradehistory.tradeType === 'buy' ? tradehistory.tradeCurrency : tradehistory.assetName; - const tradeType = tradehistory.tradeType - const tradedata: TradeHistoryDataDto = { - img_url : `${UPBIT_IMAGE_URL}${name}.png`, - koreanName : coinNameData.get(`${tradehistory.assetName}-${tradehistory.tradeCurrency}`) || coinNameData.get(`${tradehistory.tradeCurrency}-${tradehistory.assetName}`), - coin : tradeType === 'buy' ? tradehistory.assetName : tradehistory.tradeCurrency, - market : tradeType === 'sell' ? tradehistory.assetName : tradehistory.tradeCurrency, - tradeType : tradeType, - price : tradehistory.price, - averagePrice : tradehistory.quantity / tradehistory.price, - quantity : tradehistory.quantity, - createdAt : tradehistory.createdAt, - tradeDate : tradehistory.tradeDate, - userId : user.userId - }; - result.push(tradedata) - }) return { statusCode : 200, message : "거래 내역을 찾았습니다.", diff --git a/packages/server/src/trade/trade-ask.service.ts b/packages/server/src/trade/trade-ask.service.ts index e07e5455..9b1e50d9 100644 --- a/packages/server/src/trade/trade-ask.service.ts +++ b/packages/server/src/trade/trade-ask.service.ts @@ -194,6 +194,10 @@ export class AskService implements OnModuleInit { } const user = await this.userRepository.getUser(userId); + const assetName = buyData.assetName; + buyData.assetName = buyData.tradeCurrency; + buyData.tradeCurrency = assetName; + await this.tradeHistoryRepository.createTradeHistory( user, buyData, From 3a94e5bc025f00a02e65c768e4c70e6c737bf48d Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Mon, 25 Nov 2024 17:33:57 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20favorite=20module=20=EB=B0=8F=20a?= =?UTF-8?q?pi=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/app.module.ts | 2 + packages/server/src/auth/user.entity.ts | 7 ++ .../src/favorite/favorite.controller.spec.ts | 18 +++++ .../src/favorite/favorite.controller.ts | 77 +++++++++++++++++++ .../server/src/favorite/favorite.entity.ts | 27 +++++++ .../server/src/favorite/favorite.module.ts | 16 ++++ .../src/favorite/favorite.repository.ts | 13 ++++ .../src/favorite/favorite.service.spec.ts | 18 +++++ .../server/src/favorite/favorite.service.ts | 60 +++++++++++++++ 9 files changed, 238 insertions(+) create mode 100644 packages/server/src/favorite/favorite.controller.spec.ts create mode 100644 packages/server/src/favorite/favorite.controller.ts create mode 100644 packages/server/src/favorite/favorite.entity.ts create mode 100644 packages/server/src/favorite/favorite.module.ts create mode 100644 packages/server/src/favorite/favorite.repository.ts create mode 100644 packages/server/src/favorite/favorite.service.spec.ts create mode 100644 packages/server/src/favorite/favorite.service.ts diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index b436e9dc..7b82d60f 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -12,6 +12,7 @@ import { RedisModule } from './redis/redis.module'; import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from './schedule/schedule.module'; import { TradehistoryModule } from './trade-history/trade-history.module'; +import { FavoriteModule } from './favorite/favorite.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { TradehistoryModule } from './trade-history/trade-history.module'; NestScheduleModule.forRoot(), ScheduleModule, TradehistoryModule, + FavoriteModule, ], controllers: [AppController], providers: [AppService], diff --git a/packages/server/src/auth/user.entity.ts b/packages/server/src/auth/user.entity.ts index c9f56bad..90b71eeb 100644 --- a/packages/server/src/auth/user.entity.ts +++ b/packages/server/src/auth/user.entity.ts @@ -10,6 +10,7 @@ import { import { Account } from 'src/account/account.entity'; import { Trade } from 'src/trade/trade.entity'; import { TradeHistory } from 'src/trade-history/trade-history.entity'; +import { Favorite } from '@src/favorite/favorite.entity'; @Entity() export class User extends BaseEntity { @@ -48,4 +49,10 @@ export class User extends BaseEntity { onDelete: 'CASCADE', }) tradeHistories: TradeHistory[]; + + @OneToMany(() => Favorite, (favorite) => favorite.user, { + cascade: true, + onDelete: 'CASCADE', + }) + favorites: Favorite[]; } diff --git a/packages/server/src/favorite/favorite.controller.spec.ts b/packages/server/src/favorite/favorite.controller.spec.ts new file mode 100644 index 00000000..33aa4bce --- /dev/null +++ b/packages/server/src/favorite/favorite.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FavoriteController } from './favorite.controller'; + +describe('FavoriteController', () => { + let controller: FavoriteController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FavoriteController], + }).compile(); + + controller = module.get(FavoriteController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/server/src/favorite/favorite.controller.ts b/packages/server/src/favorite/favorite.controller.ts new file mode 100644 index 00000000..4ab3405a --- /dev/null +++ b/packages/server/src/favorite/favorite.controller.ts @@ -0,0 +1,77 @@ +import { + Controller, + Delete, + Get, + Param, + Post, + Query, + Request, + Res, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@src/auth/auth.guard'; +import { FavoriteService } from './favorite.service'; +import { ApiBearerAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger'; +@Controller('favorite') +@ApiBearerAuth('access-token') +@ApiSecurity('access-token') +@UseGuards(AuthGuard) +export class FavoriteController { + constructor(private favoriteService: FavoriteService) {} + @ApiQuery({ name: 'assetName', required: false, type: String }) + @Get() + async getFavorites( + @Request() req, + @Res() res, + @Query('assetName') assetName?: string, + ) { + try { + const result = await this.favoriteService.getFavorites( + req.user, + assetName, + ); + return res.status(result.statusCode).json(result); + } catch { + return res.status(500).json({ message: 'Failed to get favorites.' }); + } + } + + @Post() + async createFavorite( + @Query('assetName') assetName: String, + @Request() req, + @Res() res, + ) { + try { + return this.favoriteService.createFavorite(req.user, assetName); + } catch { + return res.status(500).json({ message: 'Failed to create favorite.' }); + } + } + + @Delete() + async deleteFavorite( + @Query('assetName') assetName: String, + @Request() req, + @Res() res, + ) { + try { + return this.favoriteService.deleteFavorite(req.user, assetName); + } catch { + return res.status(500).json({ message: 'Failed to delete favorite.' }); + } + } + + @Post('/toggle') + async toggleFavorite( + @Query('assetName') assetName: String, + @Request() req, + @Res() res, + ) { + try { + return this.favoriteService.toggleFavorite(req.user, assetName); + } catch { + return res.status(500).json({ message: 'Failed to toggle favorite.' }); + } + } +} diff --git a/packages/server/src/favorite/favorite.entity.ts b/packages/server/src/favorite/favorite.entity.ts new file mode 100644 index 00000000..036b35a0 --- /dev/null +++ b/packages/server/src/favorite/favorite.entity.ts @@ -0,0 +1,27 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Column, + Unique +} from 'typeorm'; + +import { User } from '@src/auth/user.entity'; + +@Entity() +export class Favorite extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + assetName: string; + + @ManyToOne(() => User, (user) => user.favorites, { + nullable: true, + onDelete: 'CASCADE', + }) + @JoinColumn() + user: User; +} diff --git a/packages/server/src/favorite/favorite.module.ts b/packages/server/src/favorite/favorite.module.ts new file mode 100644 index 00000000..27cef4cb --- /dev/null +++ b/packages/server/src/favorite/favorite.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { FavoriteController } from './favorite.controller'; +import { FavoriteService } from './favorite.service'; +import { FavoriteRepository } from './favorite.repository'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Favorite } from './favorite.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Favorite]), + ], + controllers: [FavoriteController], + providers: [FavoriteService, FavoriteRepository], + exports: [FavoriteRepository], +}) +export class FavoriteModule {} diff --git a/packages/server/src/favorite/favorite.repository.ts b/packages/server/src/favorite/favorite.repository.ts new file mode 100644 index 00000000..1ea0f92e --- /dev/null +++ b/packages/server/src/favorite/favorite.repository.ts @@ -0,0 +1,13 @@ +import { DataSource, Repository } from 'typeorm'; +import { Favorite } from './favorite.entity'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class FavoriteRepository extends Repository { + constructor( + private dataSource: DataSource, + ) { + super(Favorite, dataSource.createEntityManager()); + } + +} diff --git a/packages/server/src/favorite/favorite.service.spec.ts b/packages/server/src/favorite/favorite.service.spec.ts new file mode 100644 index 00000000..229adaf4 --- /dev/null +++ b/packages/server/src/favorite/favorite.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FavoriteService } from './favorite.service'; + +describe('FavoriteService', () => { + let service: FavoriteService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FavoriteService], + }).compile(); + + service = module.get(FavoriteService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/server/src/favorite/favorite.service.ts b/packages/server/src/favorite/favorite.service.ts new file mode 100644 index 00000000..f7ce5626 --- /dev/null +++ b/packages/server/src/favorite/favorite.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { FavoriteRepository } from './favorite.repository'; + +@Injectable() +export class FavoriteService { + constructor(private favoriteRepository: FavoriteRepository) {} + + async getFavorites(user, assetName) { + if (assetName) { + const result = await this.favoriteRepository.find({ + where: { + user: { id: user.userId }, + assetName, + }, + }) + return { + statusCode: 200, + result + }; + } else { + const result = await this.favoriteRepository.find({ + where: { user: { id: user.userId } }, + }) + console.log(result) + return { + statusCode: 200, + result + }; + } + } + + async createFavorite(user, assetName) { + return await this.favoriteRepository.save({ + user: { id: user.userId }, + assetName, + }); + } + + async deleteFavorite(user, assetName) { + return await this.favoriteRepository.delete({ + user: { id: user.userId }, + assetName, + }); + } + + async toggleFavorite(user, assetName) { + const favorite = await this.favoriteRepository.find({ + where: { + user: { id: user.userId }, + assetName, + }, + }); + console.log(favorite); + if (favorite.length > 0) { + return await this.deleteFavorite(user, assetName); + } else { + return await this.createFavorite(user, assetName); + } + } +} From b31a03698b5f077953faff64707cbec590feddba Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Mon, 25 Nov 2024 18:04:39 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20yarn=20lint=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/account/account.controller.ts | 88 ++- packages/server/src/account/account.entity.ts | 8 +- packages/server/src/account/account.module.ts | 9 +- .../server/src/account/account.repository.ts | 83 +-- .../server/src/account/account.service.ts | 94 +-- packages/server/src/asset/asset.repository.ts | 22 +- packages/server/src/auth/auth.controller.ts | 276 ++++----- packages/server/src/auth/auth.module.ts | 40 +- packages/server/src/auth/auth.service.ts | 437 ++++++------- packages/server/src/auth/constants.ts | 4 +- packages/server/src/auth/dtos/sign-up.dto.ts | 2 +- .../src/auth/strategies/google.strategy.ts | 50 +- .../src/auth/strategies/kakao.strategy.ts | 48 +- packages/server/src/auth/user.entity.ts | 3 +- .../src/favorite/favorite.controller.ts | 125 ++-- .../server/src/favorite/favorite.entity.ts | 9 +- .../server/src/favorite/favorite.module.ts | 4 +- .../src/favorite/favorite.repository.ts | 5 +- .../server/src/favorite/favorite.service.ts | 98 +-- packages/server/src/main.ts | 65 +- .../1731911925616-consent-record.ts | 10 +- .../1731911957654-consent-record.ts | 10 +- packages/server/src/redis/redis.module.ts | 20 +- packages/server/src/redis/redis.repository.ts | 35 ++ .../dtos/tradehistoryData.dto.ts | 68 +-- .../trade-history/trade-history.controller.ts | 66 +- .../src/trade-history/trade-history.entity.ts | 51 +- .../src/trade-history/trade-history.module.ts | 9 +- .../trade-history/trade-history.repository.ts | 7 +- .../trade-history/trade-history.service.ts | 68 +-- .../server/src/trade/dtos/tradeData.dto.ts | 62 +- .../server/src/trade/trade-ask.service.ts | 578 +++++++++--------- .../server/src/trade/trade-bid.service.ts | 523 ++++++++-------- packages/server/src/trade/trade.controller.ts | 43 +- packages/server/src/trade/trade.module.ts | 2 +- packages/server/src/trade/trade.repository.ts | 20 +- packages/server/src/trade/trade.service.ts | 181 +++--- packages/server/src/upbit/chart.repository.ts | 39 -- packages/server/src/upbit/chart.service.ts | 485 ++++++--------- .../src/upbit/coin-data-updater.service.ts | 3 - .../server/src/upbit/coin-list.service.ts | 309 +++++----- .../upbit/coin-ticker-websocket.service.ts | 16 +- packages/server/src/upbit/dtos/candle.dto.ts | 54 +- packages/server/src/upbit/sse.service.ts | 6 +- packages/server/src/upbit/upbit.controller.ts | 170 +++--- packages/server/src/upbit/upbit.module.ts | 38 +- 46 files changed, 2103 insertions(+), 2240 deletions(-) delete mode 100644 packages/server/src/upbit/chart.repository.ts diff --git a/packages/server/src/account/account.controller.ts b/packages/server/src/account/account.controller.ts index a034cd9f..8fdb25e8 100644 --- a/packages/server/src/account/account.controller.ts +++ b/packages/server/src/account/account.controller.ts @@ -1,52 +1,48 @@ import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Post, - Request, - Res, - UnauthorizedException, - UseGuards, - } from '@nestjs/common'; - import { AuthGuard } from '../auth/auth.guard'; - import { AuthService } from '@src/auth/auth.service'; - import { - ApiBody, - ApiBearerAuth, - ApiSecurity, - ApiResponse, - } from '@nestjs/swagger'; + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Post, + Request, + Res, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthService } from '@src/auth/auth.service'; +import { + ApiBody, + ApiBearerAuth, + ApiSecurity, + ApiResponse, +} from '@nestjs/swagger'; import { AccountService } from './account.service'; -import {Response} from "express"; +import { Response } from 'express'; + +@Controller('account') +export class AccountController { + constructor(private accountService: AccountService) {} - @Controller('account') - export class AccountController { - constructor(private accountService: AccountService) {} - - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Get('myaccount') - async signIn( - @Request() req, - @Res() res: Response - ) { - try{ - const response = await this.accountService.getMyAccountData(req.user); - if (response instanceof UnauthorizedException) { - return res - .status(HttpStatus.UNAUTHORIZED) - .json({ message: response.message }); // UnauthorizedException 처리 - } + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('access-token') + @ApiSecurity('access-token') + @UseGuards(AuthGuard) + @Get('myaccount') + async signIn(@Request() req, @Res() res: Response) { + try { + const response = await this.accountService.getMyAccountData(req.user); + if (response instanceof UnauthorizedException) { + return res + .status(HttpStatus.UNAUTHORIZED) + .json({ message: response.message }); // UnauthorizedException 처리 + } - return res.status(response.statusCode).json(response.message); - }catch(error){ - return res.status(error.statusCode).json(error) - } + return res.status(response.statusCode).json(response.message); + } catch (error) { + return res.status(error.statusCode).json(error); } } - \ No newline at end of file +} diff --git a/packages/server/src/account/account.entity.ts b/packages/server/src/account/account.entity.ts index e5f9277e..fe87b050 100644 --- a/packages/server/src/account/account.entity.ts +++ b/packages/server/src/account/account.entity.ts @@ -24,10 +24,10 @@ export class Account { BTC: number; @OneToOne(() => User, (user) => user.account, { - nullable: true, - onDelete: 'CASCADE', - }) - @JoinColumn() + nullable: true, + onDelete: 'CASCADE', + }) + @JoinColumn() user: User; @OneToMany(() => Asset, (asset) => asset.account, { diff --git a/packages/server/src/account/account.module.ts b/packages/server/src/account/account.module.ts index 135a74f8..2878fad6 100644 --- a/packages/server/src/account/account.module.ts +++ b/packages/server/src/account/account.module.ts @@ -4,16 +4,11 @@ import { Account } from './account.entity'; import { AccountRepository } from './account.repository'; import { User } from 'src/auth/user.entity'; import { AccountController } from './account.controller'; -import { AuthService } from '@src/auth/auth.service'; import { AccountService } from './account.service'; import { AssetRepository } from '@src/asset/asset.repository'; @Module({ imports: [TypeOrmModule.forFeature([Account, User])], - controllers:[AccountController], - providers: [ - AccountRepository, - AccountService, - AssetRepository - ], + controllers: [AccountController], + providers: [AccountRepository, AccountService, AssetRepository], }) export class AccountModule {} diff --git a/packages/server/src/account/account.repository.ts b/packages/server/src/account/account.repository.ts index 1dc4a81f..e262912e 100644 --- a/packages/server/src/account/account.repository.ts +++ b/packages/server/src/account/account.repository.ts @@ -5,48 +5,53 @@ import { User } from 'src/auth/user.entity'; @Injectable() export class AccountRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Account, dataSource.createEntityManager()); - } - async createAccountForAdmin(adminUser: User) { - const account = new Account(); - account.KRW = 300000000; - account.USDT = 300000; - account.BTC = 0; - account.user = adminUser; - await this.save(account); - console.log('admin 계정에 Account가 성공적으로 생성되었습니다.'); - } - async getMyMoney(user, moneyType: string) { - const account = await this.findOne({ - where: { user: { id: user.userId } }, - }); + constructor(private dataSource: DataSource) { + super(Account, dataSource.createEntityManager()); + } + async createAccountForAdmin(adminUser: User) { + const account = new Account(); + account.KRW = 300000000; + account.USDT = 300000; + account.BTC = 0; + account.user = adminUser; + await this.save(account); + console.log('admin 계정에 Account가 성공적으로 생성되었습니다.'); + } + async getMyMoney(user, moneyType: string) { + const account = await this.findOne({ + where: { user: { id: user.userId } }, + }); - if (!account[moneyType]) return 0; - return account[moneyType]; - } - async updateAccountCurrency(typeGiven, accountBalance, accountId, queryRunner) { - try{ - await queryRunner.manager - .createQueryBuilder() - .update(Account) - .set({ - [typeGiven]: accountBalance, - }) - .where('id = :id', { id: accountId }) - .execute(); - }catch(error){ - console.log(error) - } - } - async updateAccountBTC(id, quantity, queryRunner){ - await queryRunner.manager + if (!account[moneyType]) return 0; + return account[moneyType]; + } + async updateAccountCurrency( + typeGiven, + accountBalance, + accountId, + queryRunner, + ) { + try { + await queryRunner.manager .createQueryBuilder() .update(Account) - .set({ - BTC: quantity, + .set({ + [typeGiven]: accountBalance, }) - .where('id = :id', { id: id }) + .where('id = :id', { id: accountId }) .execute(); - } + } catch (error) { + console.log(error); + } + } + async updateAccountBTC(id, quantity, queryRunner) { + await queryRunner.manager + .createQueryBuilder() + .update(Account) + .set({ + BTC: quantity, + }) + .where('id = :id', { id: id }) + .execute(); + } } diff --git a/packages/server/src/account/account.service.ts b/packages/server/src/account/account.service.ts index c8e4724b..08b99f99 100644 --- a/packages/server/src/account/account.service.ts +++ b/packages/server/src/account/account.service.ts @@ -1,58 +1,60 @@ -import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { AssetRepository } from '@src/asset/asset.repository'; import { UPBIT_IMAGE_URL } from 'common/upbit'; import { AccountRepository } from 'src/account/account.repository'; import { MyAccountDto } from './dtos/myAccount.dto'; import { CoinDataUpdaterService } from '@src/upbit/coin-data-updater.service'; - @Injectable() export class AccountService { - constructor( - private accountRepository : AccountRepository, - private assetRepository: AssetRepository, - private coinDataUpdaterService : CoinDataUpdaterService - ) {} + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + ) {} - async getMyAccountData(user){ - const accountData: MyAccountDto = new MyAccountDto(); + async getMyAccountData(user) { + const accountData: MyAccountDto = new MyAccountDto(); - const account = await this.accountRepository.findOne({ - where: {user:{id:user.userId}} - }) - if(!account){ - return new UnauthorizedException({ - statusCode: 401, - message: "등록되지 않은 사용자입니다." - }) - } - const KRW = account.KRW; - let total_price = 0; - - const myCoins = await this.assetRepository.find({ - where: { account: { id : account.id } } - }) - const coinNameData = this.coinDataUpdaterService.getCoinNameList(); - const coins = []; - myCoins.forEach((myCoin)=>{ - const name = myCoin.assetName - const coin = { - img_url : `${UPBIT_IMAGE_URL}${name}.png`, - koreanName: coinNameData.get(`KRW-${name}`)||coinNameData.get(`BTC-${name}`)||coinNameData.get(`USDT-${name}`), - market: name, - quantity: myCoin.quantity, - price : myCoin.price, - averagePrice : myCoin.price / myCoin.quantity - }; - coins.push(coin) - total_price+=myCoin.price - }) - accountData.KRW = KRW; - accountData.total_bid = total_price; - accountData.coins = coins; - return { - statusCode: 200, - message: accountData - }; + const account = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + if (!account) { + return new UnauthorizedException({ + statusCode: 401, + message: '등록되지 않은 사용자입니다.', + }); } + const KRW = account.KRW; + let total_price = 0; + + const myCoins = await this.assetRepository.find({ + where: { account: { id: account.id } }, + }); + const coinNameData = this.coinDataUpdaterService.getCoinNameList(); + const coins = []; + myCoins.forEach((myCoin) => { + const name = myCoin.assetName; + const coin = { + img_url: `${UPBIT_IMAGE_URL}${name}.png`, + koreanName: + coinNameData.get(`KRW-${name}`) || + coinNameData.get(`BTC-${name}`) || + coinNameData.get(`USDT-${name}`), + market: name, + quantity: myCoin.quantity, + price: myCoin.price, + averagePrice: myCoin.price / myCoin.quantity, + }; + coins.push(coin); + total_price += myCoin.price; + }); + accountData.KRW = KRW; + accountData.total_bid = total_price; + accountData.coins = coins; + return { + statusCode: 200, + message: accountData, + }; + } } diff --git a/packages/server/src/asset/asset.repository.ts b/packages/server/src/asset/asset.repository.ts index 57bb7678..3e3d88e9 100644 --- a/packages/server/src/asset/asset.repository.ts +++ b/packages/server/src/asset/asset.repository.ts @@ -1,4 +1,4 @@ -import { DataSource, Repository, QueryRunner } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { Asset } from './asset.entity'; import { Injectable } from '@nestjs/common'; @@ -25,9 +25,9 @@ export class AssetRepository extends Repository { await queryRunner.manager .createQueryBuilder() .update(Asset) - .set({ + .set({ quantity: asset.quantity, - price: asset.price + price: asset.price, }) .where('assetId = :assetId', { assetId: asset.assetId }) .execute(); @@ -52,9 +52,9 @@ export class AssetRepository extends Repository { await queryRunner.manager .createQueryBuilder() .update(Asset) - .set({ + .set({ price: asset.price, - quantity: asset.quantity + quantity: asset.quantity, }) .where('assetId = :assetId', { assetId: asset.assetId }) .execute(); @@ -62,17 +62,17 @@ export class AssetRepository extends Repository { console.log(e); } } - async getAsset(id,assetName, queryRunner){ + async getAsset(id, assetName, queryRunner) { try { - return await queryRunner.manager.findOne(Asset,{ + return await queryRunner.manager.findOne(Asset, { where: { - account: {id: id}, - assetName: assetName + account: { id: id }, + assetName: assetName, }, - }) + }); } catch (error) { console.error('Error fetching asset:', error); throw new Error('Failed to fetch asset'); - } + } } } diff --git a/packages/server/src/auth/auth.controller.ts b/packages/server/src/auth/auth.controller.ts index f9a61ced..e9d9a087 100644 --- a/packages/server/src/auth/auth.controller.ts +++ b/packages/server/src/auth/auth.controller.ts @@ -1,151 +1,151 @@ import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Post, - Request, - Res, - UseGuards, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Post, + Request, + Res, + UseGuards, } from '@nestjs/common'; import { AuthGuard } from './auth.guard'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; import { AuthService } from './auth.service'; import { - ApiBody, - ApiBearerAuth, - ApiSecurity, - ApiResponse, + ApiBody, + ApiBearerAuth, + ApiSecurity, + ApiResponse, } from '@nestjs/swagger'; import { SignInDto } from './dtos/sign-in.dto'; import { SignUpDto } from './dtos/sign-up.dto'; @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} - - @ApiBody({ type: SignInDto }) - @HttpCode(HttpStatus.OK) - @Post('login') - signIn(@Body() signInDto: Record) { - return this.authService.signIn(signInDto.username); - } - - @HttpCode(HttpStatus.OK) - @Post('guest-login') - guestSignIn() { - return this.authService.guestSignIn(); - } - - @Get('google') - @UseGuards(PassportAuthGuard('google')) - async googleLogin() {} - - @Get('google/callback') - @UseGuards(PassportAuthGuard('google')) - async googleLoginCallback(@Request() req, @Res() res): Promise { - const googleUser = req.user; - - const signUpDto: SignUpDto = { - name: googleUser.name, - email: googleUser.email, - provider: googleUser.provider, - providerId: googleUser.id, - isGuest: false, - }; - - const tokens = await this.authService.validateOAuthLogin(signUpDto); - // 요청 Origin 기반으로 리다이렉트 URL 결정 - const origin = req.headers['origin']; - const frontendURL = - origin && origin.includes('localhost') - ? 'http://localhost:5173' - : 'https://www.corinee.site'; - const redirectURL = new URL('/auth/callback', frontendURL); - - redirectURL.searchParams.append('access_token', tokens.access_token); - redirectURL.searchParams.append('refresh_token', tokens.refresh_token); - console.log(redirectURL); - return res.redirect(redirectURL.toString()); - } - - @Get('kakao') - @UseGuards(PassportAuthGuard('kakao')) - async kakaoLogin() {} - - @Get('kakao/callback') - @UseGuards(PassportAuthGuard('kakao')) - async kakaoLoginCallback(@Request() req, @Res() res) { - const kakaoUser = req.user; - - const signUpDto: SignUpDto = { - name: kakaoUser.name, - email: kakaoUser.email, - provider: kakaoUser.provider, - providerId: kakaoUser.id, - isGuest: false, - }; - - const tokens = await this.authService.validateOAuthLogin(signUpDto); - - const origin = req.headers['origin']; - const frontendURL = - origin && origin.includes('localhost') - ? 'http://localhost:5173' - : 'https://www.corinee.site'; - const redirectURL = new URL('/auth/callback', frontendURL); - redirectURL.searchParams.append('access_token', tokens.access_token); - redirectURL.searchParams.append('refresh_token', tokens.refresh_token); - return res.redirect(redirectURL.toString()); - } - - @ApiResponse({ - status: HttpStatus.OK, - description: 'New user successfully registered', - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input or user already exists', - }) - @HttpCode(HttpStatus.CREATED) - @Post('signup') - async signUp(@Body() signUpDto: SignUpDto) { - return this.authService.signUp(signUpDto); - } - - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Delete('logout') - logout(@Request() req) { - return this.authService.logout(req.user.userId); - } - - @UseGuards(AuthGuard) - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @Get('profile') - getProfile(@Request() req) { - return req.user; - } - - @ApiBody({ - schema: { - type: 'object', - properties: { - refreshToken: { - type: 'string', - description: 'Refresh token used for renewing access token', - example: 'your-refresh-token', - }, - }, - }, - }) - @HttpCode(HttpStatus.OK) - @Post('refresh') - refreshTokens(@Body() body: { refreshToken: string }) { - return this.authService.refreshTokens(body.refreshToken); - } + constructor(private authService: AuthService) {} + + @ApiBody({ type: SignInDto }) + @HttpCode(HttpStatus.OK) + @Post('login') + signIn(@Body() signInDto: Record) { + return this.authService.signIn(signInDto.username); + } + + @HttpCode(HttpStatus.OK) + @Post('guest-login') + guestSignIn() { + return this.authService.guestSignIn(); + } + + @Get('google') + @UseGuards(PassportAuthGuard('google')) + async googleLogin() {} + + @Get('google/callback') + @UseGuards(PassportAuthGuard('google')) + async googleLoginCallback(@Request() req, @Res() res): Promise { + const googleUser = req.user; + + const signUpDto: SignUpDto = { + name: googleUser.name, + email: googleUser.email, + provider: googleUser.provider, + providerId: googleUser.id, + isGuest: false, + }; + + const tokens = await this.authService.validateOAuthLogin(signUpDto); + // 요청 Origin 기반으로 리다이렉트 URL 결정 + const origin = req.headers['origin']; + const frontendURL = + origin && origin.includes('localhost') + ? 'http://localhost:5173' + : 'https://www.corinee.site'; + const redirectURL = new URL('/auth/callback', frontendURL); + + redirectURL.searchParams.append('access_token', tokens.access_token); + redirectURL.searchParams.append('refresh_token', tokens.refresh_token); + console.log(redirectURL); + return res.redirect(redirectURL.toString()); + } + + @Get('kakao') + @UseGuards(PassportAuthGuard('kakao')) + async kakaoLogin() {} + + @Get('kakao/callback') + @UseGuards(PassportAuthGuard('kakao')) + async kakaoLoginCallback(@Request() req, @Res() res) { + const kakaoUser = req.user; + + const signUpDto: SignUpDto = { + name: kakaoUser.name, + email: kakaoUser.email, + provider: kakaoUser.provider, + providerId: kakaoUser.id, + isGuest: false, + }; + + const tokens = await this.authService.validateOAuthLogin(signUpDto); + + const origin = req.headers['origin']; + const frontendURL = + origin && origin.includes('localhost') + ? 'http://localhost:5173' + : 'https://www.corinee.site'; + const redirectURL = new URL('/auth/callback', frontendURL); + redirectURL.searchParams.append('access_token', tokens.access_token); + redirectURL.searchParams.append('refresh_token', tokens.refresh_token); + return res.redirect(redirectURL.toString()); + } + + @ApiResponse({ + status: HttpStatus.OK, + description: 'New user successfully registered', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input or user already exists', + }) + @HttpCode(HttpStatus.CREATED) + @Post('signup') + async signUp(@Body() signUpDto: SignUpDto) { + return this.authService.signUp(signUpDto); + } + + @ApiBearerAuth('access-token') + @ApiSecurity('access-token') + @UseGuards(AuthGuard) + @Delete('logout') + logout(@Request() req) { + return this.authService.logout(req.user.userId); + } + + @UseGuards(AuthGuard) + @ApiBearerAuth('access-token') + @ApiSecurity('access-token') + @Get('profile') + getProfile(@Request() req) { + return req.user; + } + + @ApiBody({ + schema: { + type: 'object', + properties: { + refreshToken: { + type: 'string', + description: 'Refresh token used for renewing access token', + example: 'your-refresh-token', + }, + }, + }, + }) + @HttpCode(HttpStatus.OK) + @Post('refresh') + refreshTokens(@Body() body: { refreshToken: string }) { + return this.authService.refreshTokens(body.refreshToken); + } } diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index dbce4048..c26a5e40 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -12,25 +12,25 @@ import { KakaoStrategy } from './strategies/kakao.strategy'; import { GoogleStrategy } from './strategies/google.strategy'; import { PassportModule } from '@nestjs/passport'; @Module({ - imports: [ - TypeOrmModule.forFeature([User]), - JwtModule.register({ - global: true, - secret: jwtConstants.secret, - signOptions: { expiresIn: '6000s' }, - }), - AccountModule, - PassportModule - ], - providers: [ - UserRepository, - AccountRepository, - AuthService, - JwtService, - GoogleStrategy, - KakaoStrategy, - ], - controllers: [AuthController], - exports: [UserRepository], + imports: [ + TypeOrmModule.forFeature([User]), + JwtModule.register({ + global: true, + secret: jwtConstants.secret, + signOptions: { expiresIn: '6000s' }, + }), + AccountModule, + PassportModule, + ], + providers: [ + UserRepository, + AccountRepository, + AuthService, + JwtService, + GoogleStrategy, + KakaoStrategy, + ], + controllers: [AuthController], + exports: [UserRepository], }) export class AuthModule {} diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index aa2568e6..5388ddad 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -1,19 +1,19 @@ import { - ConflictException, - ForbiddenException, - Injectable, - UnauthorizedException, + ConflictException, + ForbiddenException, + Injectable, + UnauthorizedException, } from '@nestjs/common'; import { UserRepository } from './user.repository'; import { JwtService } from '@nestjs/jwt'; import { - ACCESS_TOKEN_TTL, - DEFAULT_BTC, - DEFAULT_KRW, - DEFAULT_USDT, - GUEST_ID_TTL, - REFRESH_TOKEN_TTL, - jwtConstants, + ACCESS_TOKEN_TTL, + DEFAULT_BTC, + DEFAULT_KRW, + DEFAULT_USDT, + GUEST_ID_TTL, + REFRESH_TOKEN_TTL, + jwtConstants, } from './constants'; import { v4 as uuidv4 } from 'uuid'; import { AccountRepository } from 'src/account/account.repository'; @@ -22,211 +22,212 @@ import { User } from './user.entity'; import { SignUpDto } from './dtos/sign-up.dto'; @Injectable() export class AuthService { - constructor( - private userRepository: UserRepository, - private accountRepository: AccountRepository, - private jwtService: JwtService, - private readonly redisRepository: RedisRepository, - ) { - this.createAdminUser(); - } - - async signIn( - username: string, - ): Promise<{ access_token: string; refresh_token: string }> { - const user = await this.userRepository.findOneBy({ username }); - if (!user) { - throw new UnauthorizedException('Invalid credentials'); - } - return this.generateTokens(user.id, user.username); - } - - async guestSignIn(): Promise<{ - access_token: string; - refresh_token: string; - }> { - const guestName = `guest_${uuidv4()}`; - const user = { name: guestName, isGuest: true }; - - await this.signUp(user); - - const guestUser = await this.userRepository.findOneBy({ - username: guestName, - }); - - await this.redisRepository.setAuthData( - `guest:${guestUser.id}`, - JSON.stringify({ userId: guestUser.id }), - GUEST_ID_TTL, - ); - return this.generateTokens(guestUser.id, guestUser.username); - } - - async signUp(user: { - name: string; - email?: string; - provider?: string; - providerId?: string; - isGuest?: boolean; - }): Promise<{ message: string }> { - const { name, email, provider, providerId, isGuest } = user; - - const existingUser = isGuest - ? await this.userRepository.findOneBy({ username: name }) - : await this.userRepository.findOne({ - where: { provider, providerId }, - }); - - if (existingUser) { - throw new ConflictException('User already exists'); - } - - const newUser = await this.userRepository.save({ - username: name, - email, - provider, - providerId, - isGuest, - }); - - await this.accountRepository.save({ - user: newUser, - KRW: DEFAULT_KRW, - USDT: DEFAULT_USDT, - BTC: DEFAULT_BTC, - }); - - return { - message: isGuest - ? 'Guest user successfully registered' - : 'User successfully registered', - }; - } - - async validateOAuthLogin( - signUpDto: SignUpDto, - ): Promise<{ access_token: string; refresh_token: string }> { - const { name, email, provider, providerId, isGuest } = signUpDto; - - let user = await this.userRepository.findOne({ - where: { provider, providerId }, - }); - - if (!user) { - await this.signUp({ name, email, provider, providerId, isGuest: false }); - user = await this.userRepository.findOne({ - where: { provider, providerId }, - }); - } - - if (!user) { - throw new UnauthorizedException('OAuth user creation failed'); - } - - return this.generateTokens(user.id, user.username); - } - - private async generateTokens( - userId: number, - username: string, - ): Promise<{ access_token: string; refresh_token: string }> { - const payload = { userId, userName: username }; - - const accessToken = await this.jwtService.signAsync(payload, { - secret: jwtConstants.secret, - expiresIn: ACCESS_TOKEN_TTL, - }); - - const refreshToken = await this.jwtService.signAsync( - { userId }, - { - secret: jwtConstants.refreshSecret, - expiresIn: REFRESH_TOKEN_TTL, - }, - ); - - await this.redisRepository.setAuthData( - `refresh:${userId}`, - refreshToken, - REFRESH_TOKEN_TTL, - ); - - return { - access_token: accessToken, - refresh_token: refreshToken, - }; - } - - async refreshTokens( - refreshToken: string, - ): Promise<{ access_token: string; refresh_token: string }> { - try { - const payload = await this.jwtService.verifyAsync(refreshToken, { - secret: jwtConstants.refreshSecret, - }); - const userId = payload.userId; - - const storedToken = await this.redisRepository.getAuthData( - `refresh:${userId}`, - ); - - if (!storedToken) { - throw new ForbiddenException({ - message: 'Refresh token has expired', - errorCode: 'REFRESH_TOKEN_EXPIRED', - }); - } - - if (storedToken !== refreshToken) { - throw new UnauthorizedException({ - message: 'Invalid refresh token', - errorCode: 'INVALID_REFRESH_TOKEN', - }); - } - - const user = await this.userRepository.findOneBy({ id: userId }); - if (!user) { - throw new UnauthorizedException('User not found'); - } - return this.generateTokens(user.id, user.username); - } catch (error) { - throw new UnauthorizedException({ - message: 'Failed to refresh tokens', - errorCode: 'TOKEN_REFRESH_FAILED', - }); - } - } - - async logout(userId: number): Promise<{ message: string }> { - try { - const user = await this.userRepository.findOneBy({ id: userId }); - - if (!user) { - throw new Error('User not found'); - } - - await this.redisRepository.deleteAuthData(`refresh:${userId}`); - - if (user.isGuest) { - await this.userRepository.delete({ id: userId }); - return { message: 'Guest user data successfully deleted' }; - } - } catch (error) { - console.error(error); - } - } - - async createAdminUser() { - const user = await this.userRepository.findOneBy({ username: 'admin' }); - - if (!user) { - const adminUser = new User(); - adminUser.username = 'admin'; - await this.userRepository.save(adminUser); - await this.accountRepository.createAccountForAdmin(adminUser); - console.log('Admin user created successfully.'); - } else { - console.log('Admin user already exists.'); - } - } + constructor( + private userRepository: UserRepository, + private accountRepository: AccountRepository, + private jwtService: JwtService, + private readonly redisRepository: RedisRepository, + ) { + this.createAdminUser(); + } + + async signIn( + username: string, + ): Promise<{ access_token: string; refresh_token: string }> { + const user = await this.userRepository.findOneBy({ username }); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + return this.generateTokens(user.id, user.username); + } + + async guestSignIn(): Promise<{ + access_token: string; + refresh_token: string; + }> { + const guestName = `guest_${uuidv4()}`; + const user = { name: guestName, isGuest: true }; + + await this.signUp(user); + + const guestUser = await this.userRepository.findOneBy({ + username: guestName, + }); + + await this.redisRepository.setAuthData( + `guest:${guestUser.id}`, + JSON.stringify({ userId: guestUser.id }), + GUEST_ID_TTL, + ); + + return this.generateTokens(guestUser.id, guestUser.username); + } + + async signUp(user: { + name: string; + email?: string; + provider?: string; + providerId?: string; + isGuest?: boolean; + }): Promise<{ message: string }> { + const { name, email, provider, providerId, isGuest } = user; + + const existingUser = isGuest + ? await this.userRepository.findOneBy({ username: name }) + : await this.userRepository.findOne({ + where: { provider, providerId }, + }); + + if (existingUser) { + throw new ConflictException('User already exists'); + } + + const newUser = await this.userRepository.save({ + username: name, + email, + provider, + providerId, + isGuest, + }); + + await this.accountRepository.save({ + user: newUser, + KRW: DEFAULT_KRW, + USDT: DEFAULT_USDT, + BTC: DEFAULT_BTC, + }); + + return { + message: isGuest + ? 'Guest user successfully registered' + : 'User successfully registered', + }; + } + + async validateOAuthLogin( + signUpDto: SignUpDto, + ): Promise<{ access_token: string; refresh_token: string }> { + const { name, email, provider, providerId } = signUpDto; + + let user = await this.userRepository.findOne({ + where: { provider, providerId }, + }); + + if (!user) { + await this.signUp({ name, email, provider, providerId, isGuest: false }); + user = await this.userRepository.findOne({ + where: { provider, providerId }, + }); + } + + if (!user) { + throw new UnauthorizedException('OAuth user creation failed'); + } + + return this.generateTokens(user.id, user.username); + } + + private async generateTokens( + userId: number, + username: string, + ): Promise<{ access_token: string; refresh_token: string }> { + const payload = { userId, userName: username }; + + const accessToken = await this.jwtService.signAsync(payload, { + secret: jwtConstants.secret, + expiresIn: ACCESS_TOKEN_TTL, + }); + + const refreshToken = await this.jwtService.signAsync( + { userId }, + { + secret: jwtConstants.refreshSecret, + expiresIn: REFRESH_TOKEN_TTL, + }, + ); + + await this.redisRepository.setAuthData( + `refresh:${userId}`, + refreshToken, + REFRESH_TOKEN_TTL, + ); + + return { + access_token: accessToken, + refresh_token: refreshToken, + }; + } + + async refreshTokens( + refreshToken: string, + ): Promise<{ access_token: string; refresh_token: string }> { + try { + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: jwtConstants.refreshSecret, + }); + const userId = payload.userId; + + const storedToken = await this.redisRepository.getAuthData( + `refresh:${userId}`, + ); + + if (!storedToken) { + throw new ForbiddenException({ + message: 'Refresh token has expired', + errorCode: 'REFRESH_TOKEN_EXPIRED', + }); + } + + if (storedToken !== refreshToken) { + throw new UnauthorizedException({ + message: 'Invalid refresh token', + errorCode: 'INVALID_REFRESH_TOKEN', + }); + } + + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + return this.generateTokens(user.id, user.username); + } catch { + throw new UnauthorizedException({ + message: 'Failed to refresh tokens', + errorCode: 'TOKEN_REFRESH_FAILED', + }); + } + } + + async logout(userId: number): Promise<{ message: string }> { + try { + const user = await this.userRepository.findOneBy({ id: userId }); + + if (!user) { + throw new Error('User not found'); + } + + await this.redisRepository.deleteAuthData(`refresh:${userId}`); + + if (user.isGuest) { + await this.userRepository.delete({ id: userId }); + return { message: 'Guest user data successfully deleted' }; + } + } catch (error) { + console.error(error); + } + } + + async createAdminUser() { + const user = await this.userRepository.findOneBy({ username: 'admin' }); + + if (!user) { + const adminUser = new User(); + adminUser.username = 'admin'; + await this.userRepository.save(adminUser); + await this.accountRepository.createAccountForAdmin(adminUser); + console.log('Admin user created successfully.'); + } else { + console.log('Admin user already exists.'); + } + } } diff --git a/packages/server/src/auth/constants.ts b/packages/server/src/auth/constants.ts index fa2638ef..f7dbd305 100644 --- a/packages/server/src/auth/constants.ts +++ b/packages/server/src/auth/constants.ts @@ -1,6 +1,6 @@ export const jwtConstants = { - secret: 'superSecureAccessTokenSecret', - refreshSecret: 'superSecureAccessTokenSecret_superSecureAccessTokenSecret', + secret: 'superSecureAccessTokenSecret', + refreshSecret: 'superSecureAccessTokenSecret_superSecureAccessTokenSecret', }; export const DEFAULT_KRW = 30000000; diff --git a/packages/server/src/auth/dtos/sign-up.dto.ts b/packages/server/src/auth/dtos/sign-up.dto.ts index b64fd51b..cbd62eef 100644 --- a/packages/server/src/auth/dtos/sign-up.dto.ts +++ b/packages/server/src/auth/dtos/sign-up.dto.ts @@ -15,7 +15,7 @@ export class SignUpDto { @IsBoolean() isGuest: boolean; - + @IsString() provider: string; diff --git a/packages/server/src/auth/strategies/google.strategy.ts b/packages/server/src/auth/strategies/google.strategy.ts index e2035579..065b7a2a 100644 --- a/packages/server/src/auth/strategies/google.strategy.ts +++ b/packages/server/src/auth/strategies/google.strategy.ts @@ -4,30 +4,30 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { - constructor() { - super({ - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: `${process.env.CALLBACK_URL}/api/auth/google/callback`, - scope: ['email', 'profile'], - }); - } + constructor() { + super({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${process.env.CALLBACK_URL}/api/auth/google/callback`, + scope: ['email', 'profile'], + }); + } - async validate( - accessToken: string, - refreshToken: string, - profile: any, - done: VerifyCallback, - ): Promise { - const { id, displayName, emails } = profile; - const user = { - provider: 'google', - id, - name: displayName, - email: emails?.[0]?.value, - accessToken, - refreshToken, - }; - done(null, user); - } + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { id, displayName, emails } = profile; + const user = { + provider: 'google', + id, + name: displayName, + email: emails?.[0]?.value, + accessToken, + refreshToken, + }; + done(null, user); + } } diff --git a/packages/server/src/auth/strategies/kakao.strategy.ts b/packages/server/src/auth/strategies/kakao.strategy.ts index 1b73db52..fb0daec8 100644 --- a/packages/server/src/auth/strategies/kakao.strategy.ts +++ b/packages/server/src/auth/strategies/kakao.strategy.ts @@ -4,29 +4,29 @@ import { Strategy, Profile } from 'passport-kakao'; @Injectable() export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') { - constructor() { - super({ - clientID: process.env.KAKAO_CLIENT_ID, - clientSecret: '', - callbackURL: `${process.env.CALLBACK_URL}/api/auth/kakao/callback`, - }); - } + constructor() { + super({ + clientID: process.env.KAKAO_CLIENT_ID, + clientSecret: '', + callbackURL: `${process.env.CALLBACK_URL}/api/auth/kakao/callback`, + }); + } - async validate( - accessToken: string, - refreshToken: string, - profile: Profile, - done: Function, - ): Promise { - const { id, username, _json } = profile; - const user = { - provider: 'kakao', - id, - name: username, - email: _json.kakao_account?.email, - accessToken, - refreshToken, - }; - done(null, user); - } + async validate( + accessToken: string, + refreshToken: string, + profile: Profile, + done, + ): Promise { + const { id, username, _json } = profile; + const user = { + provider: 'kakao', + id, + name: username, + email: _json.kakao_account?.email, + accessToken, + refreshToken, + }; + done(null, user); + } } diff --git a/packages/server/src/auth/user.entity.ts b/packages/server/src/auth/user.entity.ts index 90b71eeb..eea203ac 100644 --- a/packages/server/src/auth/user.entity.ts +++ b/packages/server/src/auth/user.entity.ts @@ -4,7 +4,6 @@ import { Entity, OneToMany, PrimaryGeneratedColumn, - Unique, OneToOne, } from 'typeorm'; import { Account } from 'src/account/account.entity'; @@ -28,7 +27,7 @@ export class User extends BaseEntity { @Column({ nullable: true }) provider: string; - + @Column({ nullable: true }) providerId: string; diff --git a/packages/server/src/favorite/favorite.controller.ts b/packages/server/src/favorite/favorite.controller.ts index 4ab3405a..86750ae9 100644 --- a/packages/server/src/favorite/favorite.controller.ts +++ b/packages/server/src/favorite/favorite.controller.ts @@ -1,13 +1,12 @@ import { - Controller, - Delete, - Get, - Param, - Post, - Query, - Request, - Res, - UseGuards, + Controller, + Delete, + Get, + Post, + Query, + Request, + Res, + UseGuards, } from '@nestjs/common'; import { AuthGuard } from '@src/auth/auth.guard'; import { FavoriteService } from './favorite.service'; @@ -17,61 +16,61 @@ import { ApiBearerAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger'; @ApiSecurity('access-token') @UseGuards(AuthGuard) export class FavoriteController { - constructor(private favoriteService: FavoriteService) {} - @ApiQuery({ name: 'assetName', required: false, type: String }) - @Get() - async getFavorites( - @Request() req, - @Res() res, - @Query('assetName') assetName?: string, - ) { - try { - const result = await this.favoriteService.getFavorites( - req.user, - assetName, - ); - return res.status(result.statusCode).json(result); - } catch { - return res.status(500).json({ message: 'Failed to get favorites.' }); - } - } + constructor(private favoriteService: FavoriteService) {} + @ApiQuery({ name: 'assetName', required: false, type: String }) + @Get() + async getFavorites( + @Request() req, + @Res() res, + @Query('assetName') assetName?: string, + ) { + try { + const result = await this.favoriteService.getFavorites( + req.user, + assetName, + ); + return res.status(result.statusCode).json(result); + } catch { + return res.status(500).json({ message: 'Failed to get favorites.' }); + } + } - @Post() - async createFavorite( - @Query('assetName') assetName: String, - @Request() req, - @Res() res, - ) { - try { - return this.favoriteService.createFavorite(req.user, assetName); - } catch { - return res.status(500).json({ message: 'Failed to create favorite.' }); - } - } + @Post() + async createFavorite( + @Query('assetName') assetName: string, + @Request() req, + @Res() res, + ) { + try { + return this.favoriteService.createFavorite(req.user, assetName); + } catch { + return res.status(500).json({ message: 'Failed to create favorite.' }); + } + } - @Delete() - async deleteFavorite( - @Query('assetName') assetName: String, - @Request() req, - @Res() res, - ) { - try { - return this.favoriteService.deleteFavorite(req.user, assetName); - } catch { - return res.status(500).json({ message: 'Failed to delete favorite.' }); - } - } + @Delete() + async deleteFavorite( + @Query('assetName') assetName: string, + @Request() req, + @Res() res, + ) { + try { + return this.favoriteService.deleteFavorite(req.user, assetName); + } catch { + return res.status(500).json({ message: 'Failed to delete favorite.' }); + } + } - @Post('/toggle') - async toggleFavorite( - @Query('assetName') assetName: String, - @Request() req, - @Res() res, - ) { - try { - return this.favoriteService.toggleFavorite(req.user, assetName); - } catch { - return res.status(500).json({ message: 'Failed to toggle favorite.' }); - } - } + @Post('/toggle') + async toggleFavorite( + @Query('assetName') assetName: string, + @Request() req, + @Res() res, + ) { + try { + return this.favoriteService.toggleFavorite(req.user, assetName); + } catch { + return res.status(500).json({ message: 'Failed to toggle favorite.' }); + } + } } diff --git a/packages/server/src/favorite/favorite.entity.ts b/packages/server/src/favorite/favorite.entity.ts index 036b35a0..3201a001 100644 --- a/packages/server/src/favorite/favorite.entity.ts +++ b/packages/server/src/favorite/favorite.entity.ts @@ -5,7 +5,6 @@ import { ManyToOne, JoinColumn, Column, - Unique } from 'typeorm'; import { User } from '@src/auth/user.entity'; @@ -19,9 +18,9 @@ export class Favorite extends BaseEntity { assetName: string; @ManyToOne(() => User, (user) => user.favorites, { - nullable: true, - onDelete: 'CASCADE', - }) - @JoinColumn() + nullable: true, + onDelete: 'CASCADE', + }) + @JoinColumn() user: User; } diff --git a/packages/server/src/favorite/favorite.module.ts b/packages/server/src/favorite/favorite.module.ts index 27cef4cb..8bcdad7d 100644 --- a/packages/server/src/favorite/favorite.module.ts +++ b/packages/server/src/favorite/favorite.module.ts @@ -6,9 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Favorite } from './favorite.entity'; @Module({ - imports: [ - TypeOrmModule.forFeature([Favorite]), - ], + imports: [TypeOrmModule.forFeature([Favorite])], controllers: [FavoriteController], providers: [FavoriteService, FavoriteRepository], exports: [FavoriteRepository], diff --git a/packages/server/src/favorite/favorite.repository.ts b/packages/server/src/favorite/favorite.repository.ts index 1ea0f92e..3c35a66f 100644 --- a/packages/server/src/favorite/favorite.repository.ts +++ b/packages/server/src/favorite/favorite.repository.ts @@ -4,10 +4,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class FavoriteRepository extends Repository { - constructor( - private dataSource: DataSource, - ) { + constructor(private dataSource: DataSource) { super(Favorite, dataSource.createEntityManager()); } - } diff --git a/packages/server/src/favorite/favorite.service.ts b/packages/server/src/favorite/favorite.service.ts index f7ce5626..79a268fe 100644 --- a/packages/server/src/favorite/favorite.service.ts +++ b/packages/server/src/favorite/favorite.service.ts @@ -3,58 +3,58 @@ import { FavoriteRepository } from './favorite.repository'; @Injectable() export class FavoriteService { - constructor(private favoriteRepository: FavoriteRepository) {} + constructor(private favoriteRepository: FavoriteRepository) {} - async getFavorites(user, assetName) { - if (assetName) { - const result = await this.favoriteRepository.find({ - where: { - user: { id: user.userId }, - assetName, - }, - }) - return { - statusCode: 200, - result - }; - } else { - const result = await this.favoriteRepository.find({ - where: { user: { id: user.userId } }, - }) - console.log(result) - return { - statusCode: 200, - result - }; - } - } + async getFavorites(user, assetName) { + if (assetName) { + const result = await this.favoriteRepository.find({ + where: { + user: { id: user.userId }, + assetName, + }, + }); + return { + statusCode: 200, + result, + }; + } else { + const result = await this.favoriteRepository.find({ + where: { user: { id: user.userId } }, + }); + console.log(result); + return { + statusCode: 200, + result, + }; + } + } - async createFavorite(user, assetName) { - return await this.favoriteRepository.save({ - user: { id: user.userId }, - assetName, - }); - } + async createFavorite(user, assetName) { + return await this.favoriteRepository.save({ + user: { id: user.userId }, + assetName, + }); + } - async deleteFavorite(user, assetName) { - return await this.favoriteRepository.delete({ - user: { id: user.userId }, - assetName, - }); - } + async deleteFavorite(user, assetName) { + return await this.favoriteRepository.delete({ + user: { id: user.userId }, + assetName, + }); + } - async toggleFavorite(user, assetName) { - const favorite = await this.favoriteRepository.find({ - where: { - user: { id: user.userId }, - assetName, - }, - }); + async toggleFavorite(user, assetName) { + const favorite = await this.favoriteRepository.find({ + where: { + user: { id: user.userId }, + assetName, + }, + }); console.log(favorite); - if (favorite.length > 0) { - return await this.deleteFavorite(user, assetName); - } else { - return await this.createFavorite(user, assetName); - } - } + if (favorite.length > 0) { + return await this.deleteFavorite(user, assetName); + } else { + return await this.createFavorite(user, assetName); + } + } } diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 2ea32864..c52f5ea2 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -1,9 +1,8 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { - SwaggerModule, - DocumentBuilder, - SwaggerCustomOptions, + SwaggerModule, + DocumentBuilder, } from '@nestjs/swagger'; import { config } from 'dotenv'; import { setupSshTunnel } from './configs/ssh-tunnel'; @@ -12,40 +11,36 @@ import { AllExceptionsFilter } from 'common/all-exceptions.filter'; config(); async function bootstrap() { - await setupSshTunnel(); - const app = await NestFactory.create(AppModule); - app.enableCors({ - origin: true, - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', - credentials: true, - }); + await setupSshTunnel(); + const app = await NestFactory.create(AppModule); + app.enableCors({ + origin: true, + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + credentials: true, + }); - const config = new DocumentBuilder() - .setTitle('CorinEE API example') - .setDescription('CorinEE API description') - .setVersion('1.0') - .addTag('corinee') - .addBearerAuth( - { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Access token used for authentication', - }, - 'access-token', - ) - .build(); - const customOptions: SwaggerCustomOptions = { - swaggerOptions: { - persistAuthorization: true, - }, - }; - const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, documentFactory); + const config = new DocumentBuilder() + .setTitle('CorinEE API example') + .setDescription('CorinEE API description') + .setVersion('1.0') + .addTag('corinee') + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Access token used for authentication', + }, + 'access-token', + ) + .build(); - app.setGlobalPrefix('api'); - app.useGlobalFilters(new AllExceptionsFilter()); + const documentFactory = () => SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, documentFactory); - await app.listen(process.env.PORT ?? 3000); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new AllExceptionsFilter()); + + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/packages/server/src/migrations/1731911925616-consent-record.ts b/packages/server/src/migrations/1731911925616-consent-record.ts index ac54020a..38ba1da5 100644 --- a/packages/server/src/migrations/1731911925616-consent-record.ts +++ b/packages/server/src/migrations/1731911925616-consent-record.ts @@ -1,11 +1,7 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class ConsentRecord1731911925616 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise {} - public async up(queryRunner: QueryRunner): Promise { - } - - public async down(queryRunner: QueryRunner): Promise { - } - + public async down(queryRunner: QueryRunner): Promise {} } diff --git a/packages/server/src/migrations/1731911957654-consent-record.ts b/packages/server/src/migrations/1731911957654-consent-record.ts index c70773e3..a32ab100 100644 --- a/packages/server/src/migrations/1731911957654-consent-record.ts +++ b/packages/server/src/migrations/1731911957654-consent-record.ts @@ -1,11 +1,7 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class ConsentRecord1731911957654 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise {} - public async up(queryRunner: QueryRunner): Promise { - } - - public async down(queryRunner: QueryRunner): Promise { - } - + public async down(queryRunner: QueryRunner): Promise {} } diff --git a/packages/server/src/redis/redis.module.ts b/packages/server/src/redis/redis.module.ts index c91b17ec..f6f262d0 100644 --- a/packages/server/src/redis/redis.module.ts +++ b/packages/server/src/redis/redis.module.ts @@ -7,10 +7,10 @@ import { RedisRepository } from './redis.repository'; @Module({ providers: [ { - provide: 'TRADE_REDIS_CLIENT', // 트레이드용 Redis 클라이언트 + provide: 'TRADE_REDIS_CLIENT', useFactory: () => { const config = getRedisConfig(); - const client = new Redis({ ...config, db: 1 }); // DB 1로 설정 + const client = new Redis({ ...config, db: 1 }); client.on('connect', () => console.log('트레이드용 Redis 연결 성공')); client.on('error', (error) => console.error('트레이드용 Redis 연결 실패:', error), @@ -19,10 +19,10 @@ import { RedisRepository } from './redis.repository'; }, }, { - provide: 'AUTH_REDIS_CLIENT', // Auth용 Redis 클라이언트 + provide: 'AUTH_REDIS_CLIENT', useFactory: () => { const config = getRedisConfig(); - const client = new Redis({ ...config, db: 2 }); // DB 2로 설정 + const client = new Redis({ ...config, db: 2 }); client.on('connect', () => console.log('Auth용 Redis 연결 성공')); client.on('error', (error) => console.error('Auth용 Redis 연결 실패:', error), @@ -30,6 +30,18 @@ import { RedisRepository } from './redis.repository'; return client; }, }, + { + provide: 'CHART_REDIS_CLIENT', + useFactory: () => { + const config = getRedisConfig(); + const client = new Redis({ ...config, db: 3 }); + client.on('connect', () => console.log('Chart용 Redis 연결 성공')); + client.on('error', (error) => + console.error('Chart용 Redis 연결 실패:', error), + ); + return client; + }, + }, RedisRepository, ], exports: ['TRADE_REDIS_CLIENT', 'AUTH_REDIS_CLIENT', RedisRepository], diff --git a/packages/server/src/redis/redis.repository.ts b/packages/server/src/redis/redis.repository.ts index 763e082a..31ab94db 100644 --- a/packages/server/src/redis/redis.repository.ts +++ b/packages/server/src/redis/redis.repository.ts @@ -6,8 +6,10 @@ export class RedisRepository { constructor( @Inject('TRADE_REDIS_CLIENT') private readonly tradeRedis: Redis, @Inject('AUTH_REDIS_CLIENT') private readonly authRedis: Redis, + @Inject('CHART_REDIS_CLIENT') private readonly chartRedis: Redis, ) {} + //trade async setTradeData( key: string, value: string, @@ -23,6 +25,7 @@ export class RedisRepository { return this.tradeRedis.get(key); } + //auth async setAuthData(key: string, value: string, ttl?: number): Promise { if (ttl) { return this.authRedis.set(key, value, 'EX', ttl); @@ -41,4 +44,36 @@ export class RedisRepository { async deleteAuthData(key: string): Promise { return this.authRedis.del(key); } + + //chart + async setChartData(key, value) { + this.chartRedis.set(key, value); + } + async getChartDate(keys) { + try { + const results = await Promise.all( + keys.map(async (key) => { + try { + const data = await this.chartRedis.get(key); + if (!data) { + return null; + } + return JSON.parse(data); + } catch (error) { + console.error(`Error fetching data for key ${key}:`, error); + return null; + } + }), + ); + return results.filter((data) => data !== null); + } catch (error) { + console.error('DB Searching Error : ' + error); + } + } + async getSimpleChartData(key) { + const data = await this.chartRedis.get(key); + if (!data) { + return false; + } else return JSON.parse(data); + } } diff --git a/packages/server/src/trade-history/dtos/tradehistoryData.dto.ts b/packages/server/src/trade-history/dtos/tradehistoryData.dto.ts index d43e57e1..d5ac68a6 100644 --- a/packages/server/src/trade-history/dtos/tradehistoryData.dto.ts +++ b/packages/server/src/trade-history/dtos/tradehistoryData.dto.ts @@ -1,36 +1,36 @@ -import { IsDate, IsNumber, IsString } from "class-validator"; +import { IsDate, IsNumber, IsString } from 'class-validator'; export class TradeHistoryDataDto { - @IsString() - img_url: string; - - @IsString() - koreanName: string; - - @IsString() - coin: string; - - @IsString() - tradeType: string; - - @IsString() - market: string; - - @IsNumber() - price: number; - - @IsNumber() - averagePrice: number; - - @IsNumber() - quantity: number; - - @IsDate() - createdAt: Date; - - @IsDate() - tradeDate: Date; - - @IsNumber() - userId: number; - } \ No newline at end of file + @IsString() + img_url: string; + + @IsString() + koreanName: string; + + @IsString() + coin: string; + + @IsString() + tradeType: string; + + @IsString() + market: string; + + @IsNumber() + price: number; + + @IsNumber() + averagePrice: number; + + @IsNumber() + quantity: number; + + @IsDate() + createdAt: Date; + + @IsDate() + tradeDate: Date; + + @IsNumber() + userId: number; +} diff --git a/packages/server/src/trade-history/trade-history.controller.ts b/packages/server/src/trade-history/trade-history.controller.ts index 0eec0d7a..2fcb0be0 100644 --- a/packages/server/src/trade-history/trade-history.controller.ts +++ b/packages/server/src/trade-history/trade-history.controller.ts @@ -1,38 +1,34 @@ import { - Body, - Controller, - Get, - Post, - Query, - Param, - Request, - UseGuards, - UnauthorizedException, - Res - } from '@nestjs/common'; - import { AuthGuard } from 'src/auth/auth.guard'; - import { ApiBearerAuth, ApiSecurity, ApiBody, ApiQuery } from '@nestjs/swagger'; - import {Response} from "express"; + Controller, + Get, + Query, + Request, + UseGuards, + Res, +} from '@nestjs/common'; +import { AuthGuard } from 'src/auth/auth.guard'; +import { ApiBearerAuth, ApiSecurity, ApiQuery } from '@nestjs/swagger'; +import { Response } from 'express'; import { TradeHistoryService } from './trade-history.service'; - - @Controller('tradehistory') - export class TradeHistoryController { - constructor( - private tradeHistoryService: TradeHistoryService - ) {} - - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @ApiQuery({ name: 'coins', required: false, type: String }) - @UseGuards(AuthGuard) - @Get('tradehistoryData') - async getMyTradeData( - @Request() req, - @Res() res: Response, - @Query('coins') coins?: string, - ) { - const response = await this.tradeHistoryService.getMyTradeHistoryData(req.user, coins) - return res.status(response.statusCode).json(response) - } + +@Controller('tradehistory') +export class TradeHistoryController { + constructor(private tradeHistoryService: TradeHistoryService) {} + + @ApiBearerAuth('access-token') + @ApiSecurity('access-token') + @ApiQuery({ name: 'coins', required: false, type: String }) + @UseGuards(AuthGuard) + @Get('tradehistoryData') + async getMyTradeData( + @Request() req, + @Res() res: Response, + @Query('coins') coins?: string, + ) { + const response = await this.tradeHistoryService.getMyTradeHistoryData( + req.user, + coins, + ); + return res.status(response.statusCode).json(response); } - \ No newline at end of file +} diff --git a/packages/server/src/trade-history/trade-history.entity.ts b/packages/server/src/trade-history/trade-history.entity.ts index de11a547..561a0a5b 100644 --- a/packages/server/src/trade-history/trade-history.entity.ts +++ b/packages/server/src/trade-history/trade-history.entity.ts @@ -1,41 +1,40 @@ import { User } from '@src/auth/user.entity'; import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - CreateDateColumn, - UpdateDateColumn, + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, } from 'typeorm'; @Entity() export class TradeHistory { - @PrimaryGeneratedColumn() - tradeHistoryId: number; + @PrimaryGeneratedColumn() + tradeHistoryId: number; - @Column() - assetName: string; + @Column() + assetName: string; - @Column() - tradeType: string; + @Column() + tradeType: string; - @Column() - tradeCurrency: string; + @Column() + tradeCurrency: string; - @Column('double') - price: number; + @Column('double') + price: number; - @Column('double') - quantity: number; + @Column('double') + quantity: number; - @Column({ type: 'timestamp' }) - createdAt: Date; + @Column({ type: 'timestamp' }) + createdAt: Date; - @CreateDateColumn({ type: 'timestamp' }) - tradeDate: Date; + @CreateDateColumn({ type: 'timestamp' }) + tradeDate: Date; - @ManyToOne(() => User, (user) => user.tradeHistories, { - onDelete: 'CASCADE', - }) - user: User; + @ManyToOne(() => User, (user) => user.tradeHistories, { + onDelete: 'CASCADE', + }) + user: User; } diff --git a/packages/server/src/trade-history/trade-history.module.ts b/packages/server/src/trade-history/trade-history.module.ts index 657971d0..e4a66a0f 100644 --- a/packages/server/src/trade-history/trade-history.module.ts +++ b/packages/server/src/trade-history/trade-history.module.ts @@ -9,11 +9,8 @@ import { TradeHistoryService } from './trade-history.service'; import { UpbitModule } from '@src/upbit/upbit.module'; @Module({ - imports: [TypeOrmModule.forFeature([TradeHistory]), HttpModule, UpbitModule], - providers: [ - TradeHistoryRepository, - TradeHistoryService, - ], - controllers: [TradeHistoryController], + imports: [TypeOrmModule.forFeature([TradeHistory]), HttpModule, UpbitModule], + providers: [TradeHistoryRepository, TradeHistoryService], + controllers: [TradeHistoryController], }) export class TradehistoryModule {} diff --git a/packages/server/src/trade-history/trade-history.repository.ts b/packages/server/src/trade-history/trade-history.repository.ts index faf74a7d..ffc05d52 100644 --- a/packages/server/src/trade-history/trade-history.repository.ts +++ b/packages/server/src/trade-history/trade-history.repository.ts @@ -1,13 +1,10 @@ import { DataSource, Repository, QueryRunner } from 'typeorm'; import { TradeHistory } from './trade-history.entity'; import { Injectable } from '@nestjs/common'; -import { UserRepository } from 'src/auth/user.repository'; @Injectable() export class TradeHistoryRepository extends Repository { - constructor( - private dataSource: DataSource, - ) { + constructor(private dataSource: DataSource) { super(TradeHistory, dataSource.createEntityManager()); } async createTradeHistory(user, trade: any, queryRunner: QueryRunner) { @@ -23,7 +20,7 @@ export class TradeHistoryRepository extends Repository { await queryRunner.manager.save(TradeHistory, tradeHistory); } catch (e) { - console.log(e); + console.error(e); } } } diff --git a/packages/server/src/trade-history/trade-history.service.ts b/packages/server/src/trade-history/trade-history.service.ts index 23d9dd7a..708a254b 100644 --- a/packages/server/src/trade-history/trade-history.service.ts +++ b/packages/server/src/trade-history/trade-history.service.ts @@ -1,45 +1,45 @@ -import { - Injectable, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { TradeHistoryRepository } from '../trade-history/trade-history.repository'; @Injectable() export class TradeHistoryService { + constructor( + private tradehistoryRepository: TradeHistoryRepository, + private readonly dataSource: DataSource, + ) {} - constructor( - private tradehistoryRepository: TradeHistoryRepository, - private readonly dataSource: DataSource, - ) {} + async getMyTradeHistoryData(user, coin) { + try { + let tradehistoryData = await this.tradehistoryRepository.find({ + where: { user: { id: user.userId } }, + }); - async getMyTradeHistoryData(user,coin){ - try{ - const result = []; - let tradehistoryData = await this.tradehistoryRepository.find({ - where: {user : {id : user.userId}} - }) - - if(tradehistoryData.length === 0){ - return { - statusCode : 201, - message : "거래 내역이 없습니다.", - result : [] - } - } + if (tradehistoryData.length === 0) { + return { + statusCode: 201, + message: '거래 내역이 없습니다.', + result: [], + }; + } - if(coin){ - const [assetName, tradeCurrency] = coin.split("-") - tradehistoryData = tradehistoryData.filter(({ assetName: a, tradeCurrency: t }) => (a === assetName && t === tradeCurrency) || (a === tradeCurrency && t === assetName)); - } + if (coin) { + const [assetName, tradeCurrency] = coin.split('-'); + tradehistoryData = tradehistoryData.filter( + ({ assetName: a, tradeCurrency: t }) => + (a === assetName && t === tradeCurrency) || + (a === tradeCurrency && t === assetName), + ); + } - return { - statusCode : 200, - message : "거래 내역을 찾았습니다.", - result: tradehistoryData - } - }catch(error){ - console.error(error) - return error - } + return { + statusCode: 200, + message: '거래 내역을 찾았습니다.', + result: tradehistoryData, + }; + } catch (error) { + console.error(error); + return error; } + } } diff --git a/packages/server/src/trade/dtos/tradeData.dto.ts b/packages/server/src/trade/dtos/tradeData.dto.ts index 07a23944..b1dec0e9 100644 --- a/packages/server/src/trade/dtos/tradeData.dto.ts +++ b/packages/server/src/trade/dtos/tradeData.dto.ts @@ -1,33 +1,33 @@ -import { IsDate, IsNumber, IsString } from "class-validator"; +import { IsDate, IsNumber, IsString } from 'class-validator'; export class TradeDataDto { - @IsString() - img_url: string; - - @IsString() - koreanName: string; - - @IsString() - coin: string; - - @IsString() - market: string; - - @IsString() - tradeType: string; - - @IsString() - tradeId: number; - - @IsNumber() - price: number; - - @IsNumber() - quantity: number; - - @IsDate() - createdAt: Date; - - @IsNumber() - userId: number; - } \ No newline at end of file + @IsString() + img_url: string; + + @IsString() + koreanName: string; + + @IsString() + coin: string; + + @IsString() + market: string; + + @IsString() + tradeType: string; + + @IsString() + tradeId: number; + + @IsNumber() + price: number; + + @IsNumber() + quantity: number; + + @IsDate() + createdAt: Date; + + @IsNumber() + userId: number; +} diff --git a/packages/server/src/trade/trade-ask.service.ts b/packages/server/src/trade/trade-ask.service.ts index 9b1e50d9..cdd78bfe 100644 --- a/packages/server/src/trade/trade-ask.service.ts +++ b/packages/server/src/trade/trade-ask.service.ts @@ -1,8 +1,8 @@ import { - BadRequestException, - Injectable, - OnModuleInit, - UnprocessableEntityException, + BadRequestException, + Injectable, + OnModuleInit, + UnprocessableEntityException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AccountRepository } from 'src/account/account.repository'; @@ -15,302 +15,302 @@ import { UserRepository } from '@src/auth/user.repository'; @Injectable() export class AskService implements OnModuleInit { - private transactionAsk: boolean = false; - private transactionCreateAsk: boolean = false; - private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; + private transactionAsk: boolean = false; + private transactionCreateAsk: boolean = false; + private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; - constructor( - private accountRepository: AccountRepository, - private assetRepository: AssetRepository, - private tradeRepository: TradeRepository, - private coinDataUpdaterService: CoinDataUpdaterService, - private userRepository: UserRepository, - private readonly dataSource: DataSource, - private tradeHistoryRepository: TradeHistoryRepository, - ) {} + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private tradeRepository: TradeRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + private userRepository: UserRepository, + private readonly dataSource: DataSource, + private tradeHistoryRepository: TradeHistoryRepository, + ) {} - onModuleInit() { - this.matchPendingTrades(); - } + onModuleInit() { + this.matchPendingTrades(); + } - async calculatePercentBuy(user, moneyType: string, percent: number) { - const account = await this.accountRepository.findOne({ - where: { user: { id: user.userId } }, - }); - const asset = await this.assetRepository.findOne({ - where: { - account: { id: account.id }, - assetName: moneyType, - }, - }); - if (!asset) return 0; - return parseFloat((asset.quantity * (percent / 100)).toFixed(8)); - } - async createAskTrade(user, askDto) { - if (askDto.receivedAmount * askDto.receivedPrice < 5000) - throw new BadRequestException(); - if (this.transactionCreateAsk) await this.waitForTransactionCreate(); - this.transactionCreateAsk = true; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - try { - if (askDto.receivedAmount <= 0) throw new BadRequestException(); - const userAccount = await this.accountRepository.findOne({ - where: { - user: { id: user.userId }, - }, - }); - if (!userAccount) { - throw new UnprocessableEntityException({ - message: '유저가 존재하지 않습니다.', - statusCode: 422, - }); - } - const userAsset = await this.checkCurrency( - askDto, - userAccount, - queryRunner, - ); - const assetBalance = parseFloat( - (userAsset.quantity - askDto.receivedAmount).toFixed(8), - ); - if (assetBalance <= 0) { - await this.assetRepository.delete({ - assetId: userAsset.assetId, - }); - } else { - userAsset.quantity = assetBalance; - userAsset.price -= - parseFloat(askDto.receivedPrice.toFixed(8)) * - parseFloat(askDto.receivedAmount.toFixed(8)); - this.assetRepository.updateAssetPrice(userAsset, queryRunner); - } - await this.tradeRepository.createTrade( - askDto, - user.userId, - 'sell', - queryRunner, - ); - await queryRunner.commitTransaction(); + async calculatePercentBuy(user, moneyType: string, percent: number) { + const account = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + const asset = await this.assetRepository.findOne({ + where: { + account: { id: account.id }, + assetName: moneyType, + }, + }); + if (!asset) return 0; + return parseFloat((asset.quantity * (percent / 100)).toFixed(8)); + } + async createAskTrade(user, askDto) { + if (askDto.receivedAmount * askDto.receivedPrice < 5000) + throw new BadRequestException(); + if (this.transactionCreateAsk) await this.waitForTransactionCreate(); + this.transactionCreateAsk = true; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + try { + if (askDto.receivedAmount <= 0) throw new BadRequestException(); + const userAccount = await this.accountRepository.findOne({ + where: { + user: { id: user.userId }, + }, + }); + if (!userAccount) { + throw new UnprocessableEntityException({ + message: '유저가 존재하지 않습니다.', + statusCode: 422, + }); + } + const userAsset = await this.checkCurrency( + askDto, + userAccount, + queryRunner, + ); + const assetBalance = parseFloat( + (userAsset.quantity - askDto.receivedAmount).toFixed(8), + ); + if (assetBalance <= 0) { + await this.assetRepository.delete({ + assetId: userAsset.assetId, + }); + } else { + userAsset.quantity = assetBalance; + userAsset.price -= + parseFloat(askDto.receivedPrice.toFixed(8)) * + parseFloat(askDto.receivedAmount.toFixed(8)); + this.assetRepository.updateAssetPrice(userAsset, queryRunner); + } + await this.tradeRepository.createTrade( + askDto, + user.userId, + 'sell', + queryRunner, + ); + await queryRunner.commitTransaction(); - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - console.log(error); - await queryRunner.rollbackTransaction(); - if (error instanceof UnprocessableEntityException || BadRequestException) - throw error; - return new UnprocessableEntityException({ - statusCode: 422, - message: '거래 등록에 실패했습니다.', - }); - } finally { - await queryRunner.release(); - this.transactionCreateAsk = false; - } - } - async checkCurrency(askDto, account, queryRunner) { - const { typeGiven, receivedAmount } = askDto; - const userAsset = await this.assetRepository.getAsset( - account.id, - typeGiven, - queryRunner, - ); - if (!userAsset) { - throw new UnprocessableEntityException({ - message: '자산이 부족합니다.', - statusCode: 422, - }); - } - const accountBalance = userAsset.quantity; - const accountResult = accountBalance - receivedAmount; - if (accountResult < 0) - throw new UnprocessableEntityException({ - message: '자산이 부족합니다.', - statusCode: 422, - }); - return userAsset; - } - async askTradeService(askDto) { - if (this.transactionAsk) await this.waitForTransactionOrder(); - this.transactionAsk = true; - const { tradeId, typeGiven, receivedPrice, userId } = askDto; - try { - const account = await this.accountRepository.findOne({ - where: { user: { id: userId } }, - }); - const userAsset = await this.assetRepository.findOne({ - where: { - account: { id: account.id }, - assetName: typeGiven, - }, - }); - if (userAsset) { - askDto.assetBalance = userAsset.quantity; - askDto.asset = userAsset; - } - const currentCoinOrderbook = - this.coinDataUpdaterService.getCoinOrderbookByAsk(askDto); - for (const order of currentCoinOrderbook) { - if (order.bid_price < receivedPrice) break; - const tradeData = await this.tradeRepository.findOne({ - where: { tradeId: tradeId }, - }); - if (!tradeData) break; - const result = await this.executeTrade(askDto, order, tradeData); - if (!result) break; - } + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + console.log(error); + await queryRunner.rollbackTransaction(); + if (error instanceof UnprocessableEntityException || BadRequestException) + throw error; + return new UnprocessableEntityException({ + statusCode: 422, + message: '거래 등록에 실패했습니다.', + }); + } finally { + await queryRunner.release(); + this.transactionCreateAsk = false; + } + } + async checkCurrency(askDto, account, queryRunner) { + const { typeGiven, receivedAmount } = askDto; + const userAsset = await this.assetRepository.getAsset( + account.id, + typeGiven, + queryRunner, + ); + if (!userAsset) { + throw new UnprocessableEntityException({ + message: '자산이 부족합니다.', + statusCode: 422, + }); + } + const accountBalance = userAsset.quantity; + const accountResult = accountBalance - receivedAmount; + if (accountResult < 0) + throw new UnprocessableEntityException({ + message: '자산이 부족합니다.', + statusCode: 422, + }); + return userAsset; + } + async askTradeService(askDto) { + if (this.transactionAsk) await this.waitForTransactionOrder(); + this.transactionAsk = true; + const { tradeId, typeGiven, receivedPrice, userId } = askDto; + try { + const account = await this.accountRepository.findOne({ + where: { user: { id: userId } }, + }); + const userAsset = await this.assetRepository.findOne({ + where: { + account: { id: account.id }, + assetName: typeGiven, + }, + }); + if (userAsset) { + askDto.assetBalance = userAsset.quantity; + askDto.asset = userAsset; + } + const currentCoinOrderbook = + this.coinDataUpdaterService.getCoinOrderbookByAsk(askDto); + for (const order of currentCoinOrderbook) { + if (order.bid_price < receivedPrice) break; + const tradeData = await this.tradeRepository.findOne({ + where: { tradeId: tradeId }, + }); + if (!tradeData) break; + const result = await this.executeTrade(askDto, order, tradeData); + if (!result) break; + } - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - throw error; - } finally { - this.transactionAsk = false; - } - } - async executeTrade(askDto, order, tradeData) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - const { bid_price, bid_size } = order; - const { userId, tradeId, asset, typeGiven, typeReceived, krw } = askDto; - let result = false; - try { - const buyData = { ...tradeData }; - buyData.quantity = - tradeData.quantity >= bid_size - ? parseFloat(bid_size.toFixed(8)) - : parseFloat(tradeData.quantity.toFixed(8)); - buyData.price = parseFloat((bid_price * krw).toFixed(8)); - if (buyData.quantity < 0.00000001) { - await queryRunner.commitTransaction(); - return true; - } - const user = await this.userRepository.getUser(userId); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + throw error; + } finally { + this.transactionAsk = false; + } + } + async executeTrade(askDto, order, tradeData) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + const { bid_price, bid_size } = order; + const { userId, tradeId, asset, typeGiven, typeReceived, krw } = askDto; + let result = false; + try { + const buyData = { ...tradeData }; + buyData.quantity = + tradeData.quantity >= bid_size + ? parseFloat(bid_size.toFixed(8)) + : parseFloat(tradeData.quantity.toFixed(8)); + buyData.price = parseFloat((bid_price * krw).toFixed(8)); + if (buyData.quantity < 0.00000001) { + await queryRunner.commitTransaction(); + return true; + } + const user = await this.userRepository.getUser(userId); - const assetName = buyData.assetName; - buyData.assetName = buyData.tradeCurrency; - buyData.tradeCurrency = assetName; - - await this.tradeHistoryRepository.createTradeHistory( - user, - buyData, - queryRunner, - ); + const assetName = buyData.assetName; + buyData.assetName = buyData.tradeCurrency; + buyData.tradeCurrency = assetName; - if (!asset && tradeData.price > buyData.price) { - asset.price = parseFloat( - ( - asset.price + - (tradeData.price - buyData.price) * buyData.quantity - ).toFixed(8), - ); + await this.tradeHistoryRepository.createTradeHistory( + user, + buyData, + queryRunner, + ); - await this.assetRepository.updateAssetPrice(asset, queryRunner); - } + if (!asset && tradeData.price > buyData.price) { + asset.price = parseFloat( + ( + asset.price + + (tradeData.price - buyData.price) * buyData.quantity + ).toFixed(8), + ); - const account = await this.accountRepository.findOne({ - where: { user: { id: userId } }, - }); + await this.assetRepository.updateAssetPrice(asset, queryRunner); + } - if (typeGiven === 'BTC') { - const BTC_QUANTITY = account.BTC - buyData.quantity; - await this.accountRepository.updateAccountBTC( - account.id, - BTC_QUANTITY, - queryRunner, - ); - } - const change = parseFloat( - (account[typeReceived] + buyData.price * buyData.quantity).toFixed(8), - ); - await this.accountRepository.updateAccountCurrency( - typeReceived, - change, - account.id, - queryRunner, - ); + const account = await this.accountRepository.findOne({ + where: { user: { id: userId } }, + }); - tradeData.quantity -= buyData.quantity; + if (typeGiven === 'BTC') { + const BTC_QUANTITY = account.BTC - buyData.quantity; + await this.accountRepository.updateAccountBTC( + account.id, + BTC_QUANTITY, + queryRunner, + ); + } + const change = parseFloat( + (account[typeReceived] + buyData.price * buyData.quantity).toFixed(8), + ); + await this.accountRepository.updateAccountCurrency( + typeReceived, + change, + account.id, + queryRunner, + ); - if (tradeData.quantity <= 0.00000001) { - await this.tradeRepository.deleteTrade(tradeId, queryRunner); - } else { - await this.tradeRepository.updateTradeTransaction( - tradeData, - queryRunner, - ); - } - await queryRunner.commitTransaction(); - result = true; - } catch (error) { - await queryRunner.rollbackTransaction(); - console.log(error); - } finally { - await queryRunner.release(); - return result; - } - } + tradeData.quantity -= buyData.quantity; - async waitForTransactionOrder() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionAsk) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async waitForTransactionCreate() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionCreateAsk) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async matchPendingTrades() { - try { - const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); - if (coinLatestInfo.size === 0) return; - const coinPrice = []; - coinLatestInfo.forEach((value, key) => { - const price = value.trade_price; - const [give, receive] = key.split('-'); - coinPrice.push({ give: receive, receive: give, price: price }); - }); - const availableTrades = - await this.tradeRepository.searchSellTrade(coinPrice); - availableTrades.forEach((trade) => { - const krw = coinLatestInfo.get( - ['KRW', trade.tradeCurrency].join('-'), - ).trade_price; - const another = coinLatestInfo.get( - [trade.assetName, trade.tradeCurrency].join('-'), - ).trade_price; - const askDto = { - userId: trade.user.id, - typeGiven: trade.tradeCurrency, //건네주는 통화 - typeReceived: trade.assetName, //건네받을 통화 타입 - receivedPrice: trade.price, //건네받을 통화 가격 - receivedAmount: trade.quantity, //건네 받을 통화 갯수 - tradeId: trade.tradeId, - krw: another / krw, - }; - this.askTradeService(askDto); - }); - } catch (error) { - console.error('미체결 거래 처리 오류:', error); - } finally { - console.log(`미체결 거래 처리 완료: ${Date()}`); - setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); - } - } + if (tradeData.quantity <= 0.00000001) { + await this.tradeRepository.deleteTrade(tradeId, queryRunner); + } else { + await this.tradeRepository.updateTradeTransaction( + tradeData, + queryRunner, + ); + } + await queryRunner.commitTransaction(); + result = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + console.log(error); + } finally { + await queryRunner.release(); + return result; + } + } + + async waitForTransactionOrder() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionAsk) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async waitForTransactionCreate() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionCreateAsk) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async matchPendingTrades() { + try { + const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); + if (coinLatestInfo.size === 0) return; + const coinPrice = []; + coinLatestInfo.forEach((value, key) => { + const price = value.trade_price; + const [give, receive] = key.split('-'); + coinPrice.push({ give: receive, receive: give, price: price }); + }); + const availableTrades = + await this.tradeRepository.searchSellTrade(coinPrice); + availableTrades.forEach((trade) => { + const krw = coinLatestInfo.get( + ['KRW', trade.tradeCurrency].join('-'), + ).trade_price; + const another = coinLatestInfo.get( + [trade.assetName, trade.tradeCurrency].join('-'), + ).trade_price; + const askDto = { + userId: trade.user.id, + typeGiven: trade.tradeCurrency, //건네주는 통화 + typeReceived: trade.assetName, //건네받을 통화 타입 + receivedPrice: trade.price, //건네받을 통화 가격 + receivedAmount: trade.quantity, //건네 받을 통화 갯수 + tradeId: trade.tradeId, + krw: another / krw, + }; + this.askTradeService(askDto); + }); + } catch (error) { + console.error('미체결 거래 처리 오류:', error); + } finally { + console.log(`미체결 거래 처리 완료: ${Date()}`); + setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); + } + } } diff --git a/packages/server/src/trade/trade-bid.service.ts b/packages/server/src/trade/trade-bid.service.ts index c7e47b21..12705add 100644 --- a/packages/server/src/trade/trade-bid.service.ts +++ b/packages/server/src/trade/trade-bid.service.ts @@ -1,9 +1,9 @@ import { - BadRequestException, - Injectable, - InternalServerErrorException, - OnModuleInit, - UnprocessableEntityException, + BadRequestException, + Injectable, + InternalServerErrorException, + OnModuleInit, + UnprocessableEntityException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AccountRepository } from 'src/account/account.repository'; @@ -16,275 +16,276 @@ import { UserRepository } from '@src/auth/user.repository'; @Injectable() export class BidService implements OnModuleInit { - private transactionBuy: boolean = false; - private transactionCreateBid: boolean = false; - private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; + private transactionBuy: boolean = false; + private transactionCreateBid: boolean = false; + private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; - constructor( - private accountRepository: AccountRepository, - private assetRepository: AssetRepository, - private tradeRepository: TradeRepository, - private coinDataUpdaterService: CoinDataUpdaterService, - private userRepository: UserRepository, - private readonly dataSource: DataSource, - private tradeHistoryRepository: TradeHistoryRepository, - ) {} + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private tradeRepository: TradeRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + private userRepository: UserRepository, + private readonly dataSource: DataSource, + private tradeHistoryRepository: TradeHistoryRepository, + ) {} - onModuleInit() { - this.matchPendingTrades(); - } + onModuleInit() { + this.matchPendingTrades(); + } - async calculatePercentBuy(user, moneyType: string, percent: number) { - const money = await this.accountRepository.getMyMoney(user, moneyType); + async calculatePercentBuy(user, moneyType: string, percent: number) { + const money = await this.accountRepository.getMyMoney(user, moneyType); - return parseFloat((money * (percent / 100)).toFixed(8)); - } - async createBidTrade(user, bidDto) { - if (bidDto.receivedAmount * bidDto.receivedPrice < 5000) - throw new BadRequestException(); - if (this.transactionCreateBid) await this.waitForTransactionOrderBid(); - this.transactionCreateBid = true; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - try { - if (bidDto.receivedAmount <= 0) throw new BadRequestException(); - const userAccount = await this.accountRepository.findOne({ - where: { - user: { id: user.userId }, - }, - }); - if (!userAccount) { - throw new UnprocessableEntityException({ - message: '유저가 존재하지 않습니다.', - statusCode: 422, - }); - } - const accountBalance = await this.checkCurrency(user, bidDto); - await this.accountRepository.updateAccountCurrency( - bidDto.typeGiven, - parseFloat(accountBalance.toFixed(8)), - userAccount.id, - queryRunner, - ); - await this.tradeRepository.createTrade( - bidDto, - user.userId, - 'buy', - queryRunner, - ); - await queryRunner.commitTransaction(); - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - console.log(error); - await queryRunner.rollbackTransaction(); - if (error instanceof UnprocessableEntityException || BadRequestException) - throw error; - return new InternalServerErrorException({ - statusCode: 500, - message: '거래 등록에 실패했습니다.', - }); - } finally { - await queryRunner.release(); - this.transactionCreateBid = false; - } - } - async checkCurrency(user, bidDto) { - const { typeGiven, receivedPrice, receivedAmount } = bidDto; - const givenAmount = parseFloat((receivedPrice * receivedAmount).toFixed(8)); - const userAccount = await this.accountRepository.findOne({ - where: { - user: { id: user.userId }, - }, - }); - const accountBalance = userAccount[typeGiven]; - const accountResult = accountBalance - givenAmount; + return parseFloat((money * (percent / 100)).toFixed(8)); + } + async createBidTrade(user, bidDto) { + if (bidDto.receivedAmount * bidDto.receivedPrice < 5000) + throw new BadRequestException(); + if (this.transactionCreateBid) await this.waitForTransactionOrderBid(); + this.transactionCreateBid = true; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + try { + if (bidDto.receivedAmount <= 0) throw new BadRequestException(); + const userAccount = await this.accountRepository.findOne({ + where: { + user: { id: user.userId }, + }, + }); + if (!userAccount) { + throw new UnprocessableEntityException({ + message: '유저가 존재하지 않습니다.', + statusCode: 422, + }); + } + const accountBalance = await this.checkCurrency(user, bidDto); + await this.accountRepository.updateAccountCurrency( + bidDto.typeGiven, + parseFloat(accountBalance.toFixed(8)), + userAccount.id, + queryRunner, + ); + await this.tradeRepository.createTrade( + bidDto, + user.userId, + 'buy', + queryRunner, + ); + await queryRunner.commitTransaction(); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + console.log(error); + await queryRunner.rollbackTransaction(); + if (error instanceof UnprocessableEntityException || BadRequestException) + throw error; + return new InternalServerErrorException({ + statusCode: 500, + message: '거래 등록에 실패했습니다.', + }); + } finally { + await queryRunner.release(); + this.transactionCreateBid = false; + } + } + async checkCurrency(user, bidDto) { + const { typeGiven, receivedPrice, receivedAmount } = bidDto; + const givenAmount = parseFloat((receivedPrice * receivedAmount).toFixed(8)); + const userAccount = await this.accountRepository.findOne({ + where: { + user: { id: user.userId }, + }, + }); + const accountBalance = userAccount[typeGiven]; + const accountResult = accountBalance - givenAmount; - if (accountResult < 0) { - throw new UnprocessableEntityException({ - message: '자산이 부족합니다.', - statusCode: 422, - }); - } - return accountResult; - } - async bidTradeService(bidDto) { - if (this.transactionBuy) await this.waitForTransactionOrder(); - this.transactionBuy = true; - const { tradeId, typeGiven, receivedPrice, userId } = bidDto; - try { - const currentCoinOrderbook = - this.coinDataUpdaterService.getCoinOrderbookByBid(bidDto); - for (const order of currentCoinOrderbook) { - if (order.ask_price > receivedPrice) break; - const account = await this.accountRepository.findOne({ - where: { - user: { id: userId }, - }, - }); - bidDto.accountBalance = account[typeGiven]; - bidDto.account = account; - const tradeData = await this.tradeRepository.findOne({ - where: { tradeId: tradeId }, - }); - if (!tradeData) break; - const result = await this.executeTrade(bidDto, order, tradeData); - if (!result) break; - } + if (accountResult < 0) { + throw new UnprocessableEntityException({ + message: '자산이 부족합니다.', + statusCode: 422, + }); + } + return accountResult; + } + async bidTradeService(bidDto) { + if (this.transactionBuy) await this.waitForTransactionOrder(); + this.transactionBuy = true; + const { tradeId, typeGiven, receivedPrice, userId } = bidDto; + try { + const currentCoinOrderbook = + this.coinDataUpdaterService.getCoinOrderbookByBid(bidDto); + for (const order of currentCoinOrderbook) { + if (order.ask_price > receivedPrice) break; + const account = await this.accountRepository.findOne({ + where: { + user: { id: userId }, + }, + }); + bidDto.accountBalance = account[typeGiven]; + bidDto.account = account; + const tradeData = await this.tradeRepository.findOne({ + where: { tradeId: tradeId }, + }); + if (!tradeData) break; + const result = await this.executeTrade(bidDto, order, tradeData); + if (!result) break; + } - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - throw error; - } finally { - this.transactionBuy = false; - } - } - async executeTrade(bidDto, order, tradeData) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - const { ask_price, ask_size } = order; - const { userId, account, typeGiven, typeReceived, tradeId, krw } = bidDto; - let result = false; - try { - const buyData = { ...tradeData }; - buyData.quantity = - buyData.quantity >= ask_size - ? parseFloat(ask_size.toFixed(8)) - : parseFloat(buyData.quantity.toFixed(8)); - if (buyData.quantity < 0.00000001) { - await queryRunner.commitTransaction(); - return true; - } - buyData.price = parseFloat((ask_price * krw).toFixed(8)); - const user = await this.userRepository.getUser(userId); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + throw error; + } finally { + this.transactionBuy = false; + } + } + async executeTrade(bidDto, order, tradeData) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + const { ask_price, ask_size } = order; + const { userId, account, typeGiven, typeReceived, tradeId, krw } = bidDto; + let result = false; + try { + const buyData = { ...tradeData }; + buyData.quantity = + buyData.quantity >= ask_size + ? parseFloat(ask_size.toFixed(8)) + : parseFloat(buyData.quantity.toFixed(8)); + if (buyData.quantity < 0.00000001) { + await queryRunner.commitTransaction(); + return true; + } + buyData.price = parseFloat((ask_price * krw).toFixed(8)); + const user = await this.userRepository.getUser(userId); - await this.tradeHistoryRepository.createTradeHistory( - user, - buyData, - queryRunner, - ); + await this.tradeHistoryRepository.createTradeHistory( + user, + buyData, + queryRunner, + ); - const asset = await this.assetRepository.findOne({ - where: { account: { id: account.id }, assetName: typeReceived }, - }); + const asset = await this.assetRepository.findOne({ + where: { account: { id: account.id }, assetName: typeReceived }, + }); - if (asset) { - asset.price = parseFloat( - (asset.price + buyData.price * buyData.quantity).toFixed(8), - ); - asset.quantity += parseFloat(buyData.quantity.toFixed(8)); - await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); - } else { - await this.assetRepository.createAsset( - bidDto, - parseFloat((buyData.price * buyData.quantity).toFixed(8)), - buyData.quantity, - queryRunner, - ); - } + if (asset) { + asset.price = parseFloat( + (asset.price + buyData.price * buyData.quantity).toFixed(8), + ); + asset.quantity += parseFloat(buyData.quantity.toFixed(8)); + await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); + } else { + await this.assetRepository.createAsset( + bidDto, + parseFloat((buyData.price * buyData.quantity).toFixed(8)), + buyData.quantity, + queryRunner, + ); + } - tradeData.quantity -= parseFloat(buyData.quantity.toFixed(8)); - if (tradeData.quantity <= 0.00000001) { - await this.tradeRepository.deleteTrade(tradeId, queryRunner); - } else - await this.tradeRepository.updateTradeTransaction( - tradeData, - queryRunner, - ); + tradeData.quantity -= parseFloat(buyData.quantity.toFixed(8)); - const change = (tradeData.price - buyData.price) * buyData.quantity; - const returnChange = parseFloat((change + account[typeGiven]).toFixed(8)); - const new_asset = await this.assetRepository.findOne({ - where: { account: { id: account.id }, assetName: 'BTC' }, - }); + if (tradeData.quantity <= 0.00000001) { + await this.tradeRepository.deleteTrade(tradeId, queryRunner); + } else + await this.tradeRepository.updateTradeTransaction( + tradeData, + queryRunner, + ); - if (typeReceived === 'BTC') { - const BTC_QUANTITY = new_asset ? asset.quantity : buyData.quantity; - await this.accountRepository.updateAccountBTC( - account.id, - BTC_QUANTITY, - queryRunner, - ); - } + const change = (tradeData.price - buyData.price) * buyData.quantity; + const returnChange = parseFloat((change + account[typeGiven]).toFixed(8)); + const new_asset = await this.assetRepository.findOne({ + where: { account: { id: account.id }, assetName: 'BTC' }, + }); - await this.accountRepository.updateAccountCurrency( - typeGiven, - returnChange, - account.id, - queryRunner, - ); + if (typeReceived === 'BTC') { + const BTC_QUANTITY = new_asset ? asset.quantity : buyData.quantity; + await this.accountRepository.updateAccountBTC( + account.id, + BTC_QUANTITY, + queryRunner, + ); + } - await queryRunner.commitTransaction(); - result = true; - } catch (error) { - await queryRunner.rollbackTransaction(); - console.log(error); - } finally { - await queryRunner.release(); - return result; - } - } + await this.accountRepository.updateAccountCurrency( + typeGiven, + returnChange, + account.id, + queryRunner, + ); - async waitForTransactionOrder() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionBuy) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async waitForTransactionOrderBid() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionCreateBid) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async matchPendingTrades() { - try { - const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); - if (coinLatestInfo.size === 0) return; - const coinPrice = []; - coinLatestInfo.forEach((value, key) => { - const price = value.trade_price; - const [give, receive] = key.split('-'); - coinPrice.push({ give: give, receive: receive, price: price }); - }); - const availableTrades = - await this.tradeRepository.searchBuyTrade(coinPrice); - availableTrades.forEach((trade) => { - const krw = coinLatestInfo.get( - ['KRW', trade.assetName].join('-'), - ).trade_price; - const another = coinLatestInfo.get( - [trade.tradeCurrency, trade.assetName].join('-'), - ).trade_price; - const bidDto = { - userId: trade.user.id, - typeGiven: trade.tradeCurrency, //건네주는 통화 - typeReceived: trade.assetName, //건네받을 통화 타입 - receivedPrice: trade.price, //건네받을 통화 가격 - receivedAmount: trade.quantity, //건네 받을 통화 갯수 - tradeId: trade.tradeId, - krw: another / krw, - }; - this.bidTradeService(bidDto); - }); - } catch (error) { - console.error('미체결 거래 처리 오류:', error); - } finally { - console.log(`미체결 거래 처리 완료: ${Date()}`); - setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); - } - } + await queryRunner.commitTransaction(); + result = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + console.log(error); + } finally { + await queryRunner.release(); + return result; + } + } + + async waitForTransactionOrder() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionBuy) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async waitForTransactionOrderBid() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionCreateBid) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async matchPendingTrades() { + try { + const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); + if (coinLatestInfo.size === 0) return; + const coinPrice = []; + coinLatestInfo.forEach((value, key) => { + const price = value.trade_price; + const [give, receive] = key.split('-'); + coinPrice.push({ give: give, receive: receive, price: price }); + }); + const availableTrades = + await this.tradeRepository.searchBuyTrade(coinPrice); + availableTrades.forEach((trade) => { + const krw = coinLatestInfo.get( + ['KRW', trade.assetName].join('-'), + ).trade_price; + const another = coinLatestInfo.get( + [trade.tradeCurrency, trade.assetName].join('-'), + ).trade_price; + const bidDto = { + userId: trade.user.id, + typeGiven: trade.tradeCurrency, //건네주는 통화 + typeReceived: trade.assetName, //건네받을 통화 타입 + receivedPrice: trade.price, //건네받을 통화 가격 + receivedAmount: trade.quantity, //건네 받을 통화 갯수 + tradeId: trade.tradeId, + krw: another / krw, + }; + this.bidTradeService(bidDto); + }); + } catch (error) { + console.error('미체결 거래 처리 오류:', error); + } finally { + console.log(`미체결 거래 처리 완료: ${Date()}`); + setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); + } + } } diff --git a/packages/server/src/trade/trade.controller.ts b/packages/server/src/trade/trade.controller.ts index 2855b5a8..7bff784d 100644 --- a/packages/server/src/trade/trade.controller.ts +++ b/packages/server/src/trade/trade.controller.ts @@ -7,8 +7,7 @@ import { Param, Request, UseGuards, - UnauthorizedException, - Res + Res, } from '@nestjs/common'; import { BidService } from './trade-bid.service'; import { AuthGuard } from 'src/auth/auth.guard'; @@ -16,14 +15,14 @@ import { ApiBearerAuth, ApiSecurity, ApiBody } from '@nestjs/swagger'; import { AskService } from './trade-ask.service'; import { TradeDto } from './dtos/trade.dto'; import { TradeService } from './trade.service'; -import {Response} from "express"; +import { Response } from 'express'; @Controller('trade') export class TradeController { constructor( private bidService: BidService, private askService: AskService, - private tradeService: TradeService + private tradeService: TradeService, ) {} @ApiBearerAuth('access-token') @@ -44,19 +43,18 @@ export class TradeController { @UseGuards(AuthGuard) @Post('bid') async bidTrade( - @Request() req, + @Request() req, @Body() bidDto: Record, - @Res() res: Response + @Res() res: Response, ) { try { const response = await this.bidService.createBidTrade(req.user, bidDto); return res.status(200).json(response); } catch (error) { - return res.status(error.status) - .json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - });; + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); } } @@ -66,19 +64,18 @@ export class TradeController { @UseGuards(AuthGuard) @Post('ask') async askTrade( - @Request() req, + @Request() req, @Body() askDto: Record, - @Res() res: Response + @Res() res: Response, ) { try { const response = await this.askService.createAskTrade(req.user, askDto); return res.status(200).json(response); } catch (error) { - return res.status(error.status) - .json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - });; + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); } } @@ -101,10 +98,10 @@ export class TradeController { async getMyCoinData( @Request() req, @Param('coin') coin: string, - @Res() res: Response + @Res() res: Response, ) { - const response = await this.tradeService.checkMyCoinData(req.user, coin) - return res.status(response.statusCode).json(response) + const response = await this.tradeService.checkMyCoinData(req.user, coin); + return res.status(response.statusCode).json(response); } @ApiBearerAuth('access-token') @@ -116,7 +113,7 @@ export class TradeController { @Res() res: Response, @Param('coin') coin?: string, ) { - const response = await this.tradeService.getMyTradeData(req.user, coin) - return res.status(response.statusCode).json(response) + const response = await this.tradeService.getMyTradeData(req.user, coin); + return res.status(response.statusCode).json(response); } } diff --git a/packages/server/src/trade/trade.module.ts b/packages/server/src/trade/trade.module.ts index 1b98bbe8..ca7d21d2 100644 --- a/packages/server/src/trade/trade.module.ts +++ b/packages/server/src/trade/trade.module.ts @@ -23,7 +23,7 @@ import { TradeService } from './trade.service'; AssetRepository, UserRepository, TradeHistoryRepository, - TradeService + TradeService, ], controllers: [TradeController], }) diff --git a/packages/server/src/trade/trade.repository.ts b/packages/server/src/trade/trade.repository.ts index b61ecf1b..e47f077a 100644 --- a/packages/server/src/trade/trade.repository.ts +++ b/packages/server/src/trade/trade.repository.ts @@ -11,10 +11,14 @@ export class TradeRepository extends Repository { ) { super(Trade, dataSource.createEntityManager()); } - async createTrade(buyDto: any, userId, tradeType, queryRunner): Promise { + async createTrade( + buyDto: any, + userId, + tradeType, + queryRunner, + ): Promise { try { - const { typeGiven, typeReceived, receivedPrice, receivedAmount } = - buyDto; + const { typeGiven, typeReceived, receivedPrice, receivedAmount } = buyDto; const user = await this.userRepository.getUser(userId); @@ -41,11 +45,11 @@ export class TradeRepository extends Repository { console.log(e); } } - async updateTradeTransaction(tradeData, queryRunner){ + async updateTradeTransaction(tradeData, queryRunner) { await queryRunner.manager .createQueryBuilder() .update(Trade) - .set({ + .set({ quantity: tradeData.quantity, }) .where('tradeId = :tradeId', { tradeId: tradeData.tradeId }) @@ -94,7 +98,7 @@ export class TradeRepository extends Repository { return trades; } catch (e) { console.log(e); - } + } } async searchSellTrade(coinPrice) { try { @@ -125,6 +129,6 @@ export class TradeRepository extends Repository { return trades; } catch (e) { console.log(e); - } + } } -} \ No newline at end of file +} diff --git a/packages/server/src/trade/trade.service.ts b/packages/server/src/trade/trade.service.ts index 7bc59817..71e787f6 100644 --- a/packages/server/src/trade/trade.service.ts +++ b/packages/server/src/trade/trade.service.ts @@ -1,101 +1,102 @@ -import { - Injectable, - OnModuleInit, - UnprocessableEntityException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AccountRepository } from 'src/account/account.repository'; import { AssetRepository } from 'src/asset/asset.repository'; import { TradeRepository } from './trade.repository'; import { CoinDataUpdaterService } from 'src/upbit/coin-data-updater.service'; -import { TradeHistoryRepository } from '../trade-history/trade-history.repository'; -import { UPBIT_IMAGE_URL, UPBIT_UPDATED_COIN_INFO_TIME } from 'common/upbit'; -import { TradeDataDto } from './dtos/tradeData.dto' +import { UPBIT_IMAGE_URL } from 'common/upbit'; +import { TradeDataDto } from './dtos/tradeData.dto'; @Injectable() export class TradeService { - - constructor( - private accountRepository: AccountRepository, - private assetRepository: AssetRepository, - private tradeRepository: TradeRepository, - private coinDataUpdaterService: CoinDataUpdaterService, - private readonly dataSource: DataSource, - ) {} - async checkMyCoinData(user,coin){ - const account = await this.accountRepository.findOne({ - where: {user : {id : user.userId}} - }) - if(!account) { - return { - statusCode :400, - message: "등록되지 않은 사용자입니다." - } - } - const coinData = await this.assetRepository.findOne({ - where: { - account: {id : account.id}, - assetName: coin - } - }) - if(coinData){ - return { - statusCode : 200, - message: "보유하고 계신 코인입니다.", - own : true - } - }else{ - return { - statusCode : 201, - message : "보유하지 않은 코인입니다.", - own : false - } - } + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private tradeRepository: TradeRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + private readonly dataSource: DataSource, + ) {} + async checkMyCoinData(user, coin) { + const account = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + if (!account) { + return { + statusCode: 400, + message: '등록되지 않은 사용자입니다.', + }; + } + const coinData = await this.assetRepository.findOne({ + where: { + account: { id: account.id }, + assetName: coin, + }, + }); + if (coinData) { + return { + statusCode: 200, + message: '보유하고 계신 코인입니다.', + own: true, + }; + } else { + return { + statusCode: 201, + message: '보유하지 않은 코인입니다.', + own: false, + }; } - async getMyTradeData(user,coin){ - try{ - const result = []; - let tradeData = await this.tradeRepository.find({ - where: {user : {id : user.userId}} - }) - - if(tradeData.length === 0){ - return { - statusCode : 201, - message : "미체결 데이터가 없습니다.", - result : [] - } - } - const coinNameData = this.coinDataUpdaterService.getCoinNameList(); - if(coin){ - const [assetName, tradeCurrency] = coin.split("-") - tradeData = tradeData.filter(({ assetName: a, tradeCurrency: t }) => (a === assetName && t === tradeCurrency) || (a === tradeCurrency && t === assetName)); - } - tradeData.forEach(trade=>{ - const name = trade.tradeType === 'buy' ? trade.tradeCurrency : trade.assetName; - const tradeType = trade.tradeType - const tradedata: TradeDataDto = { - img_url : `${UPBIT_IMAGE_URL}${name}.png`, - koreanName : coinNameData.get(`${trade.assetName}-${trade.tradeCurrency}`) || coinNameData.get(`${trade.tradeCurrency}-${trade.assetName}`), - coin : tradeType === 'buy' ? trade.assetName : trade.tradeCurrency, - market : tradeType === 'sell' ? trade.assetName : trade.tradeCurrency, - tradeId : trade.tradeId, - tradeType : tradeType, - price : trade.price, - quantity : trade.quantity, - createdAt : trade.createdAt, - userId : user.userId - }; + } + async getMyTradeData(user, coin) { + try { + const result = []; + let tradeData = await this.tradeRepository.find({ + where: { user: { id: user.userId } }, + }); + + if (tradeData.length === 0) { + return { + statusCode: 201, + message: '미체결 데이터가 없습니다.', + result: [], + }; + } + const coinNameData = this.coinDataUpdaterService.getCoinNameList(); + if (coin) { + const [assetName, tradeCurrency] = coin.split('-'); + tradeData = tradeData.filter( + ({ assetName: a, tradeCurrency: t }) => + (a === assetName && t === tradeCurrency) || + (a === tradeCurrency && t === assetName), + ); + } + tradeData.forEach((trade) => { + const name = + trade.tradeType === 'buy' ? trade.tradeCurrency : trade.assetName; + const tradeType = trade.tradeType; + const tradedata: TradeDataDto = { + img_url: `${UPBIT_IMAGE_URL}${name}.png`, + koreanName: + coinNameData.get(`${trade.assetName}-${trade.tradeCurrency}`) || + coinNameData.get(`${trade.tradeCurrency}-${trade.assetName}`), + coin: tradeType === 'buy' ? trade.assetName : trade.tradeCurrency, + market: tradeType === 'sell' ? trade.assetName : trade.tradeCurrency, + tradeId: trade.tradeId, + tradeType: tradeType, + price: trade.price, + quantity: trade.quantity, + createdAt: trade.createdAt, + userId: user.userId, + }; - result.push(tradedata) - }) - return { - statusCode : 200, - message : "미체결 데이터가 있습니다.", - result - } - }catch(error){ - console.error(error) - return error - } + result.push(tradedata); + }); + return { + statusCode: 200, + message: '미체결 데이터가 있습니다.', + result, + }; + } catch (error) { + console.error(error); + return error; } + } } diff --git a/packages/server/src/upbit/chart.repository.ts b/packages/server/src/upbit/chart.repository.ts deleted file mode 100644 index f9ef2720..00000000 --- a/packages/server/src/upbit/chart.repository.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable, Inject } from '@nestjs/common'; -import Redis from 'ioredis'; - -@Injectable() -export class ChartRepository { - constructor( - @Inject('CHART_REDIS_CLIENT') private readonly chartRedis: Redis, - ) {} - async setChartData(key, value){ - this.chartRedis.set(key,value); - } - async getChartDate(keys){ - try{ - const results = await Promise.all( - keys.map(async (key) => { - try { - const data = await this.chartRedis.get(key); - if (!data) { - return null; - } - return JSON.parse(data); - } catch (error) { - console.error(`Error fetching data for key ${key}:`, error); - return null; - } - }) - ); - return results.filter(data => data !== null); - }catch(error){ - console.error("DB Searching Error : "+error) - } - } - async getSimpleChartData(key){ - const data = await this.chartRedis.get(key); - if(!data){ - return false; - }else return JSON.parse(data) - } -} diff --git a/packages/server/src/upbit/chart.service.ts b/packages/server/src/upbit/chart.service.ts index f4bb967c..8fb24b90 100644 --- a/packages/server/src/upbit/chart.service.ts +++ b/packages/server/src/upbit/chart.service.ts @@ -3,295 +3,204 @@ import { firstValueFrom } from 'rxjs'; import { HttpService } from '@nestjs/axios'; import { ONE_SECOND, UPBIT_CANDLE_URL, UPBIT_REQUEST_SIZE } from 'common/upbit'; import { CandleDto } from './dtos/candle.dto'; -import { ChartRepository } from './chart.repository'; +import { RedisRepository } from '@src/redis/redis.repository'; @Injectable() export class ChartService implements OnModuleInit { - private upbitApiQueue; - - constructor( - private readonly httpService: HttpService, - private chartRepository: ChartRepository, - ) {} - onModuleInit() { - this.upbitApiQueue = []; - this.cleanQueue(); - } - async upbitApiDoor(type, coin, to, minute) { - const validMinutes = ['1', '3', '5', '10', '15', '30', '60', '240']; - if (type === 'minutes') { - if (!minute || !validMinutes.includes(minute)) { - throw new BadRequestException(); - } - } - if (!to) { - const now = new Date(); - now.setHours(now.getHours() + 9); - to = now.toISOString().slice(0, 19); - } - const key = await this.getAllKeys(coin, to, type, minute); - const dbcheck = await this.chartRepository.getChartDate(key); - if (dbcheck.length === 200) { - return { - statusCode: 200, - result: dbcheck, - }; - } - - const result = await this.waitForTransactionOrder(key); - if (result) { - return { - statusCode: 200, - result: result, - }; - } - try { - this.upbitApiQueue.push(Date.now()); - console.log(this.upbitApiQueue.length); - const url = - type === 'minutes' - ? `${UPBIT_CANDLE_URL}${type}/${minute}?market=${coin}&count=200&to=${to}` - : `${UPBIT_CANDLE_URL}${type}?market=${coin}&count=200&to=${to}`; - const response = await firstValueFrom(this.httpService.get(url)); - if (response.data.error) console.log(response); - else { - console.log(response.headers['remaining-req']); - } - const candle: CandleDto = response.data; - this.saveChartData(candle, type, minute); - return { - statusCode: 200, - result: candle, - }; - } catch (error) { - console.error('updateApiDoor Error : ' + error); - return error; - } finally { - console.log(this.upbitApiQueue.length); - } - } - async waitForTransactionOrder(key, maxRetries = 100) { - // 10초 타임아웃 - return new Promise(async (resolve, reject) => { - let retryCount = 0; - const check = async () => { - try { - const dbcheck = await this.chartRepository.getChartDate(key); - if (dbcheck.length === 200) { - return resolve(dbcheck); // reject 대신 resolve 사용 - } - const queueSize = this.upbitApiQueue.length; - if ( - queueSize < UPBIT_REQUEST_SIZE || - this.upbitApiQueue[queueSize - 1] - Date.now() < -ONE_SECOND - ) { - return resolve(false); - } - if (retryCount++ >= maxRetries) { - return reject(new Error('Timeout waiting for transaction order')); - } - setTimeout(check, 100); - } catch (error) { - reject(error); - } - }; - check(); - }); - } - async saveChartData(candles, type, minute) { - try { - const savePromises = candles.map((candle) => { - const key = this.getRedisKey( - candle.market, - candle.candle_date_time_kst, - type, - minute, - ); - return this.chartRepository.setChartData(key, JSON.stringify(candle)); - }); - - await Promise.all(savePromises); - } catch (error) { - console.error('saveChartData Error :', error); - throw error; - } - } - - getRedisKey(market, kst, type, minute = null) { - const formattedDateTime = kst.replace(/[-T]/g, ':'); - const parts = formattedDateTime.split(':'); - - const keyFormats = { - years: () => `${market}:${parts[0]}`, - months: () => `${market}:${parts[0]}:${parts[1]}`, - days: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}`, - weeks: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}:W`, - minutes: () => { - return `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${minute}M`; - }, - seconds: () => - `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${parts[5]}`, - }; - - const formatFn = keyFormats[type]; - if (!formatFn) { - throw new Error(`Invalid type: ${type}`); - } - - return formatFn(); - } - - formatNumber(num) { - return String(num).padStart(2, '0'); - } - - formatDate(date, type, market, minute = null) { - const year = date.getFullYear(); - const month = this.formatNumber(date.getMonth() + 1); - const day = this.formatNumber(date.getDate()); - const hours = this.formatNumber(date.getHours()); - const minutes = this.formatNumber(date.getMinutes()); - const seconds = this.formatNumber(date.getSeconds()); - - const formats = { - years: () => `${year}`, - months: () => `${year}:${month}`, - days: () => `${year}:${month}:${day}`, - weeks: () => `${year}:${month}:${day}:W`, - minutes: () => { - return `${year}:${month}:${day}:${hours}:${minutes}:${minute}M`; - }, - seconds: () => `${year}:${month}:${day}:${hours}:${minutes}:${seconds}`, - }; - - if (!formats[type]) { - throw new Error(`Invalid type: ${type}`); - } - - return `${market}:${formats[type]()}`; - } - - decrementDate(date, type) { - const decrementFunctions = { - years: () => date.setFullYear(date.getFullYear() - 1), - months: () => date.setMonth(date.getMonth() - 1), - weeks: () => date.setDate(date.getDate() - 7), - days: () => date.setDate(date.getDate() - 1), - minutes: () => date.setMinutes(date.getMinutes() - 1), - seconds: () => date.setSeconds(date.getSeconds() - 1), - }; - - if (!decrementFunctions[type]) { - throw new Error(`Invalid type: ${type}`); - } - - decrementFunctions[type](); - return date; - } - - getAllKeys(coin, to, type, minute = null, count = 200) { - const result = []; - const currentDate = new Date(to); - currentDate.setHours(currentDate.getHours() + 9); - - for (let i = 0; i < count; i++) { - result.push(this.formatDate(currentDate, type, coin, minute)); - this.decrementDate(currentDate, type); - } - return result; - } - cleanQueue() { - while ( - this.upbitApiQueue.length > 0 && - this.upbitApiQueue[0] - Date.now() < -ONE_SECOND - ) { - this.upbitApiQueue.shift(); - } - setTimeout(() => this.cleanQueue(), 100); - } - makeCandle(coinData) { - const name = coinData.code; - // date와 time을 각각 파싱 - const year = coinData.trade_date.slice(0, 4); - const month = coinData.trade_date.slice(4, 6); - const day = coinData.trade_date.slice(6, 8); - - const hour = coinData.trade_time.slice(0, 2); - const minute = coinData.trade_time.slice(2, 4); - const second = coinData.trade_time.slice(4, 6); - - const tradeDate = new Date( - `${year}-${month}-${day}T${hour}:${minute}:${second}`, - ); - const kstDate = new Date(tradeDate.getTime() + 9 * 60 * 60 * 1000 * 2); - - const price = coinData.trade_price; - const timestamp = coinData.trade_timestamp; - const candle_acc_trade_volume = coinData.trade_volume; - const candle_acc_trade_price = price * candle_acc_trade_volume; - const candle = { - market: name, - candle_date_time_utc: kstDate.toISOString().slice(0, 19), - candle_date_time_kst: kstDate.toISOString().slice(0, 19), - opening_price: price, - high_price: price, - low_price: price, - trade_price: price, - timestamp: timestamp, - candle_acc_trade_price: candle_acc_trade_price, - candle_acc_trade_volume: candle_acc_trade_volume, - prev_closing_price: 0, - change_price: 0, - change_rate: 0, - }; - - const type = ['years', 'months', 'weeks', 'days', 'minutes', 'seconds']; - const minute_type = ['1', '3', '5', '10', '15', '30', '60', '240']; - type.forEach(async (key) => { - if (key === 'minutes') { - const keys = []; - minute_type.forEach((min) => { - keys.push(this.formatDate(kstDate, key, name, min)); - }); - keys.forEach(async (min) => { - const candleData = await this.chartRepository.getSimpleChartData(min); - if (!candleData) { - this.chartRepository.setChartData(min, JSON.stringify(candle)); - } else { - candleData.trade_price = price; - candleData.high_price = - candleData.high_price < price ? price : candleData.high_price; - candleData.low_price = - candleData.low_price > price ? price : candleData.low_price; - candleData.timestamp = timestamp; - candleData.candle_acc_trade_price = candle_acc_trade_price; - candleData.candle_acc_trade_volume += candle_acc_trade_volume; - - this.chartRepository.setChartData(min, JSON.stringify(candleData)); - } - }); - } else { - const redisKey = this.formatDate(kstDate, key, name, null); - const candleData = - await this.chartRepository.getSimpleChartData(redisKey); - if (!candleData) { - this.chartRepository.setChartData(redisKey, JSON.stringify(candle)); - } else { - candleData.trade_price = price; - candleData.high_price = - candleData.high_price < price ? price : candleData.high_price; - candleData.low_price = - candleData.low_price > price ? price : candleData.low_price; - candleData.timestamp = timestamp; - candleData.candle_acc_trade_price = candle_acc_trade_price; - candleData.candle_acc_trade_volume += candle_acc_trade_volume; - - this.chartRepository.setChartData( - redisKey, - JSON.stringify(candleData), - ); - } - } - }); - } + private upbitApiQueue; + + constructor( + private readonly httpService: HttpService, + private redisRepository: RedisRepository, + ) {} + onModuleInit() { + this.upbitApiQueue = []; + this.cleanQueue(); + } + async upbitApiDoor(type, coin, to, minute) { + const validMinutes = ['1', '3', '5', '10', '15', '30', '60', '240']; + if (type === 'minutes') { + if (!minute || !validMinutes.includes(minute)) { + throw new BadRequestException(); + } + } + if (!to) { + const now = new Date(); + now.setHours(now.getHours() + 9); + to = now.toISOString().slice(0, 19); + } + const key = await this.getAllKeys(coin, to, type, minute); + const dbcheck = await this.redisRepository.getChartDate(key); + if (dbcheck.length === 200) { + return { + statusCode: 200, + result: dbcheck, + }; + } + + const result = await this.waitForTransactionOrder(key); + if (result) { + return { + statusCode: 200, + result: result, + }; + } + try { + this.upbitApiQueue.push(Date.now()); + const url = + type === 'minutes' + ? `${UPBIT_CANDLE_URL}${type}/${minute}?market=${coin}&count=200&to=${to}` + : `${UPBIT_CANDLE_URL}${type}?market=${coin}&count=200&to=${to}`; + const response = await firstValueFrom(this.httpService.get(url)); + if (response.data.error) console.error(response); + const candle: CandleDto = response.data; + this.saveChartData(candle, type, minute); + return { + statusCode: 200, + result: candle, + }; + } catch (error) { + console.error('updateApiDoor Error : ' + error); + return error; + } + } + async waitForTransactionOrder(key, maxRetries = 100) { + // 10초 타임아웃 + return new Promise(async (resolve, reject) => { + let retryCount = 0; + const check = async () => { + try { + const dbcheck = await this.redisRepository.getChartDate(key); + if (dbcheck.length === 200) { + return resolve(dbcheck); + } + const queueSize = this.upbitApiQueue.length; + if ( + queueSize < UPBIT_REQUEST_SIZE || + this.upbitApiQueue[queueSize - 1] - Date.now() < -ONE_SECOND + ) { + return resolve(false); + } + if (retryCount++ >= maxRetries) { + return reject(new Error('Timeout waiting for transaction order')); + } + setTimeout(check, 100); + } catch (error) { + reject(error); + } + }; + check(); + }); + } + async saveChartData(candles, type, minute) { + try { + const savePromises = candles.map((candle) => { + const key = this.getRedisKey( + candle.market, + candle.candle_date_time_kst, + type, + minute, + ); + return this.redisRepository.setChartData(key, JSON.stringify(candle)); + }); + + await Promise.all(savePromises); + } catch (error) { + console.error('saveChartData Error :', error); + throw error; + } + } + + getRedisKey(market, kst, type, minute = null) { + const formattedDateTime = kst.replace(/[-T]/g, ':'); + const parts = formattedDateTime.split(':'); + + const keyFormats = { + years: () => `${market}:${parts[0]}`, + months: () => `${market}:${parts[0]}:${parts[1]}`, + days: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}`, + weeks: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}:W`, + minutes: () => { + return `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${minute}M`; + }, + seconds: () => + `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${parts[5]}`, + }; + + const formatFn = keyFormats[type]; + if (!formatFn) { + throw new Error(`Invalid type: ${type}`); + } + + return formatFn(); + } + + formatNumber(num) { + return String(num).padStart(2, '0'); + } + + formatDate(date, type, market, minute = null) { + const year = date.getFullYear(); + const month = this.formatNumber(date.getMonth() + 1); + const day = this.formatNumber(date.getDate()); + const hours = this.formatNumber(date.getHours()); + const minutes = this.formatNumber(date.getMinutes()); + const seconds = this.formatNumber(date.getSeconds()); + + const formats = { + years: () => `${year}`, + months: () => `${year}:${month}`, + days: () => `${year}:${month}:${day}`, + weeks: () => `${year}:${month}:${day}:W`, + minutes: () => { + return `${year}:${month}:${day}:${hours}:${minutes}:${minute}M`; + }, + seconds: () => `${year}:${month}:${day}:${hours}:${minutes}:${seconds}`, + }; + + if (!formats[type]) { + throw new Error(`Invalid type: ${type}`); + } + + return `${market}:${formats[type]()}`; + } + + decrementDate(date, type) { + const decrementFunctions = { + years: () => date.setFullYear(date.getFullYear() - 1), + months: () => date.setMonth(date.getMonth() - 1), + weeks: () => date.setDate(date.getDate() - 7), + days: () => date.setDate(date.getDate() - 1), + minutes: () => date.setMinutes(date.getMinutes() - 1), + seconds: () => date.setSeconds(date.getSeconds() - 1), + }; + + if (!decrementFunctions[type]) { + throw new Error(`Invalid type: ${type}`); + } + + decrementFunctions[type](); + return date; + } + + getAllKeys(coin, to, type, minute = null, count = 200) { + const result = []; + const currentDate = new Date(to); + currentDate.setHours(currentDate.getHours() + 9); + + for (let i = 0; i < count; i++) { + result.push(this.formatDate(currentDate, type, coin, minute)); + this.decrementDate(currentDate, type); + } + return result; + } + cleanQueue() { + while ( + this.upbitApiQueue.length > 0 && + this.upbitApiQueue[0] - Date.now() < -ONE_SECOND + ) { + this.upbitApiQueue.shift(); + } + setTimeout(() => this.cleanQueue(), 100); + } } diff --git a/packages/server/src/upbit/coin-data-updater.service.ts b/packages/server/src/upbit/coin-data-updater.service.ts index ccacb535..d5ce5266 100644 --- a/packages/server/src/upbit/coin-data-updater.service.ts +++ b/packages/server/src/upbit/coin-data-updater.service.ts @@ -54,7 +54,6 @@ export class CoinDataUpdaterService { `${UPBIT_CURRENT_PRICE_URL}markets=${this.coinCodeList.join(',')}`, ), ); - //console.log(response.headers['remaining-req']) this.coinLatestInfo = new Map( response.data.map((coin) => [coin.market, coin]), ); @@ -74,7 +73,6 @@ export class CoinDataUpdaterService { } } - async updateCurrentOrderBook() { try { while (this.coinCodeList.length === 1) @@ -87,7 +85,6 @@ export class CoinDataUpdaterService { this.orderbookLatestInfo = new Map( response.data.map((coin) => [coin.market, coin]), ); - } catch (error) { console.error('getCoinListFromUpbit error:', error); } finally { diff --git a/packages/server/src/upbit/coin-list.service.ts b/packages/server/src/upbit/coin-list.service.ts index 37c4c3e5..bf7129c5 100644 --- a/packages/server/src/upbit/coin-list.service.ts +++ b/packages/server/src/upbit/coin-list.service.ts @@ -4,159 +4,158 @@ import { CoinDataUpdaterService } from './coin-data-updater.service'; @Injectable() export class CoinListService implements OnModuleInit { - constructor( - private readonly coinDataUpdaterService: CoinDataUpdaterService, - ) {} - - onModuleInit() { - this.coinDataUpdaterService.updateCoinList(); - this.coinDataUpdaterService.updateCoinCurrentPrice(); - this.coinDataUpdaterService.updateCurrentOrderBook(); - } - - async getMostTradeCoin() { - let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - while (!krwCoinInfo) { - await new Promise((resolve) => setTimeout(resolve, 100)); - krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - } - return krwCoinInfo - .sort((a, b) => b.acc_trade_price_24h - a.acc_trade_price_24h) - .slice(0, 20) - .map((coin) => { - coin.code = coin.market; - this.convertToCodeCoinDto(coin); - return { - market: coin.code, - image_url: coin.image_url, - korean_name: coin.korean_name, - }; - }); - } - async getSimpleCoin(coins) { - console.log(coins); - let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - while (!krwCoinInfo) { - await new Promise((resolve) => setTimeout(resolve, 100)); - krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - } - - if (!coins.length) return []; - - return krwCoinInfo - .filter((coin) => coins.includes(coin.market)) - .map((coin) => { - coin.code = coin.market; - this.convertToCodeCoinDto(coin); - return { - market: coin.code, - image_url: coin.image_url, - korean_name: coin.korean_name, - }; - }); - } - - getCoinNameList() { - return this.coinDataUpdaterService.getCoinCodeList(); - } - - getAllCoinList() { - return this.coinDataUpdaterService.getAllCoinList(); - } - - getKRWCoinList() { - return this.coinDataUpdaterService - .getAllCoinList() - .filter((coin) => coin.market.startsWith('KRW')); - } - - getBTCCoinList() { - return this.coinDataUpdaterService - .getAllCoinList() - .filter((coin) => coin.market.startsWith('BTC')); - } - - getUSDTCoinList() { - return this.coinDataUpdaterService - .getAllCoinList() - .filter((coin) => coin.market.startsWith('USDT')); - } - - getCoinTickers(coins) { - const coinData = this.coinDataUpdaterService.getCoinLatestInfo(); - - const filteredData = Array.from(coinData.entries()) - .filter(([symbol]) => !coins || coins.includes(symbol)) - .map(([symbol, details]) => ({ - code: symbol, - ...details, - })); - - return filteredData; - } - - convertToCodeCoinDto = (coin) => { - coin.korean_name = this.coinDataUpdaterService - .getCoinNameList() - .get(coin.code); - coin.image_url = this.getCoinImageURL(coin.code); - return coin; - }; - convertToMarketCoinDto = (coin) => { - coin.korean_name = this.coinDataUpdaterService - .getCoinNameList() - .get(coin.market); - coin.image_url = this.getCoinImageURL(coin.market); - coin.type = 'ticker'; - coin.code = coin.market; - - return coin; - }; - - convertToOrderbookDto = (message) => { - const beforeTopPrice = this.coinDataUpdaterService - .getCoinLatestInfo() - .get(message.code).prev_closing_price; - - message.korean_name = this.coinDataUpdaterService - .getCoinNameList() - .get(message.code); - message.image_url = this.getCoinImageURL(message.code); - - message.orderbook_units.map((unit) => { - const askRateChange = - ((unit.ask_price - beforeTopPrice) / beforeTopPrice) * 100; - const bidRateChange = - ((unit.bid_price - beforeTopPrice) / beforeTopPrice) * 100; - - unit.ask_rate = - (askRateChange >= 0 - ? `+${askRateChange.toFixed(2)}` - : `${askRateChange.toFixed(2)}`) + '%'; - unit.bid_rate = - (bidRateChange >= 0 - ? `+${bidRateChange.toFixed(2)}` - : `${bidRateChange.toFixed(2)}`) + '%'; - }); - - return message; - }; - - convertToTickerDto = (message) => { - const data = message; - return { - korean_name: this.coinDataUpdaterService.getCoinNameList().get(data.code), - code: data.code, - coin_img_url: this.getCoinImageURL(data.code), - signed_change_price: data.signed_change_price, - opening_price: data.opening_price, - signed_change_rate: data.signed_change_rate, - trade_price: data.trade_price, - }; - }; - - private getCoinImageURL(code: string) { - const logoName = code.split('-')[1]; - return `${UPBIT_IMAGE_URL}${logoName}.png`; - } + constructor( + private readonly coinDataUpdaterService: CoinDataUpdaterService, + ) {} + + onModuleInit() { + this.coinDataUpdaterService.updateCoinList(); + this.coinDataUpdaterService.updateCoinCurrentPrice(); + this.coinDataUpdaterService.updateCurrentOrderBook(); + } + + async getMostTradeCoin() { + let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); + while (!krwCoinInfo) { + await new Promise((resolve) => setTimeout(resolve, 100)); + krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); + } + return krwCoinInfo + .sort((a, b) => b.acc_trade_price_24h - a.acc_trade_price_24h) + .slice(0, 20) + .map((coin) => { + coin.code = coin.market; + this.convertToCodeCoinDto(coin); + return { + market: coin.code, + image_url: coin.image_url, + korean_name: coin.korean_name, + }; + }); + } + async getSimpleCoin(coins) { + let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); + while (!krwCoinInfo) { + await new Promise((resolve) => setTimeout(resolve, 100)); + krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); + } + + if (!coins.length) return []; + + return krwCoinInfo + .filter((coin) => coins.includes(coin.market)) + .map((coin) => { + coin.code = coin.market; + this.convertToCodeCoinDto(coin); + return { + market: coin.code, + image_url: coin.image_url, + korean_name: coin.korean_name, + }; + }); + } + + getCoinNameList() { + return this.coinDataUpdaterService.getCoinCodeList(); + } + + getAllCoinList() { + return this.coinDataUpdaterService.getAllCoinList(); + } + + getKRWCoinList() { + return this.coinDataUpdaterService + .getAllCoinList() + .filter((coin) => coin.market.startsWith('KRW')); + } + + getBTCCoinList() { + return this.coinDataUpdaterService + .getAllCoinList() + .filter((coin) => coin.market.startsWith('BTC')); + } + + getUSDTCoinList() { + return this.coinDataUpdaterService + .getAllCoinList() + .filter((coin) => coin.market.startsWith('USDT')); + } + + getCoinTickers(coins) { + const coinData = this.coinDataUpdaterService.getCoinLatestInfo(); + + const filteredData = Array.from(coinData.entries()) + .filter(([symbol]) => !coins || coins.includes(symbol)) + .map(([symbol, details]) => ({ + code: symbol, + ...details, + })); + + return filteredData; + } + + convertToCodeCoinDto = (coin) => { + coin.korean_name = this.coinDataUpdaterService + .getCoinNameList() + .get(coin.code); + coin.image_url = this.getCoinImageURL(coin.code); + return coin; + }; + convertToMarketCoinDto = (coin) => { + coin.korean_name = this.coinDataUpdaterService + .getCoinNameList() + .get(coin.market); + coin.image_url = this.getCoinImageURL(coin.market); + coin.type = 'ticker'; + coin.code = coin.market; + + return coin; + }; + + convertToOrderbookDto = (message) => { + const beforeTopPrice = this.coinDataUpdaterService + .getCoinLatestInfo() + .get(message.code).prev_closing_price; + + message.korean_name = this.coinDataUpdaterService + .getCoinNameList() + .get(message.code); + message.image_url = this.getCoinImageURL(message.code); + + message.orderbook_units.map((unit) => { + const askRateChange = + ((unit.ask_price - beforeTopPrice) / beforeTopPrice) * 100; + const bidRateChange = + ((unit.bid_price - beforeTopPrice) / beforeTopPrice) * 100; + + unit.ask_rate = + (askRateChange >= 0 + ? `+${askRateChange.toFixed(2)}` + : `${askRateChange.toFixed(2)}`) + '%'; + unit.bid_rate = + (bidRateChange >= 0 + ? `+${bidRateChange.toFixed(2)}` + : `${bidRateChange.toFixed(2)}`) + '%'; + }); + + return message; + }; + + convertToTickerDto = (message) => { + const data = message; + return { + korean_name: this.coinDataUpdaterService.getCoinNameList().get(data.code), + code: data.code, + coin_img_url: this.getCoinImageURL(data.code), + signed_change_price: data.signed_change_price, + opening_price: data.opening_price, + signed_change_rate: data.signed_change_rate, + trade_price: data.trade_price, + }; + }; + + private getCoinImageURL(code: string) { + const logoName = code.split('-')[1]; + return `${UPBIT_IMAGE_URL}${logoName}.png`; + } } diff --git a/packages/server/src/upbit/coin-ticker-websocket.service.ts b/packages/server/src/upbit/coin-ticker-websocket.service.ts index 0bddb94f..3dde2f5b 100644 --- a/packages/server/src/upbit/coin-ticker-websocket.service.ts +++ b/packages/server/src/upbit/coin-ticker-websocket.service.ts @@ -11,14 +11,14 @@ import { ChartService } from './chart.service'; @Injectable() export class CoinTickerService implements OnModuleInit { - private websocket: WebSocket; - private sending: boolean = false; - private timeoutId: NodeJS.Timeout | null = null; - constructor( - private readonly coinListService: CoinListService, - private readonly sseService: SseService, - private readonly chartService: ChartService, - ) {} + private websocket: WebSocket; + private sending: boolean = false; + private timeoutId: NodeJS.Timeout | null = null; + constructor( + private readonly coinListService: CoinListService, + private readonly sseService: SseService, + private readonly chartService: ChartService, + ) {} onModuleInit() { this.connectWebSocket(); diff --git a/packages/server/src/upbit/dtos/candle.dto.ts b/packages/server/src/upbit/dtos/candle.dto.ts index eb7adae0..163ee9de 100644 --- a/packages/server/src/upbit/dtos/candle.dto.ts +++ b/packages/server/src/upbit/dtos/candle.dto.ts @@ -1,27 +1,27 @@ -import { IsDateString, IsNumber, IsString } from "class-validator"; - -export class CandleDto{ - @IsString() - market: string; // 종목 코드 - - @IsDateString() - candle_date_time_utc: string; // 캔들 기준 시각(UTC 기준) - - @IsDateString() - candle_date_time_kst: string; // 캔들 기준 시각(KST 기준) - - @IsNumber() - opening_price: number; // 시가 - - @IsNumber() - high_price: number; // 고가 - - @IsNumber() - low_price: number; // 저가 - - @IsNumber() - trade_price: number; // 종가 - - @IsNumber() - timestamp: number; // 해당 캔들에서 마지막 틱이 저장된 시각 -} \ No newline at end of file +import { IsDateString, IsNumber, IsString } from 'class-validator'; + +export class CandleDto { + @IsString() + market: string; // 종목 코드 + + @IsDateString() + candle_date_time_utc: string; // 캔들 기준 시각(UTC 기준) + + @IsDateString() + candle_date_time_kst: string; // 캔들 기준 시각(KST 기준) + + @IsNumber() + opening_price: number; // 시가 + + @IsNumber() + high_price: number; // 고가 + + @IsNumber() + low_price: number; // 저가 + + @IsNumber() + trade_price: number; // 종가 + + @IsNumber() + timestamp: number; // 해당 캔들에서 마지막 틱이 저장된 시각 +} diff --git a/packages/server/src/upbit/sse.service.ts b/packages/server/src/upbit/sse.service.ts index e2073deb..23a80ce8 100644 --- a/packages/server/src/upbit/sse.service.ts +++ b/packages/server/src/upbit/sse.service.ts @@ -21,7 +21,7 @@ export class SseService implements OnModuleDestroy { this.orderbookStream$.next(data); } - initPriceStream(coins, dto: Function) { + initPriceStream(coins, dto) { const events: MessageEvent[] = []; if (coins && typeof coins === 'string') { coins = [coins]; @@ -47,7 +47,7 @@ export class SseService implements OnModuleDestroy { return events; } - getPriceUpdatesStream(coins, dto: Function): Observable { + getPriceUpdatesStream(coins, dto): Observable { return this.coinTickerStream$.asObservable().pipe( takeUntil(this.coinTickerDestroy$), filter((data) => coins.includes(data.code)), @@ -60,7 +60,7 @@ export class SseService implements OnModuleDestroy { ); } - getOrderbookUpdatesStream(coins, dto: Function): Observable { + getOrderbookUpdatesStream(coins, dto): Observable { return this.orderbookStream$.asObservable().pipe( takeUntil(this.orderBookDestroy$), filter((data) => coins.includes(data.code)), diff --git a/packages/server/src/upbit/upbit.controller.ts b/packages/server/src/upbit/upbit.controller.ts index 422ae9e5..de675a04 100644 --- a/packages/server/src/upbit/upbit.controller.ts +++ b/packages/server/src/upbit/upbit.controller.ts @@ -8,96 +8,92 @@ import { ApiQuery } from '@nestjs/swagger'; @Controller('upbit') export class UpbitController { - constructor( - private readonly sseService: SseService, - private readonly coinListService: CoinListService, - private readonly chartService: ChartService, - ) {} + constructor( + private readonly sseService: SseService, + private readonly coinListService: CoinListService, + private readonly chartService: ChartService, + ) {} - @Sse('price-updates') - priceUpdates(@Query('coins') coins: string[]): Observable { - coins = coins || []; - const initData = this.sseService.initPriceStream( - coins, - this.coinListService.convertToMarketCoinDto, - ); - return concat( - initData, - this.sseService.getPriceUpdatesStream( - coins, - this.coinListService.convertToCodeCoinDto, - ), - ); - } - @Sse('orderbook') - orderbookUpdates(@Query('coins') coins: string[]): Observable { - coins = coins || []; - return this.sseService.getOrderbookUpdatesStream( - coins, - this.coinListService.convertToOrderbookDto, - ); - } + @Sse('price-updates') + priceUpdates(@Query('coins') coins: string[]): Observable { + coins = coins || []; + const initData = this.sseService.initPriceStream( + coins, + this.coinListService.convertToMarketCoinDto, + ); + return concat( + initData, + this.sseService.getPriceUpdatesStream( + coins, + this.coinListService.convertToCodeCoinDto, + ), + ); + } + @Sse('orderbook') + orderbookUpdates(@Query('coins') coins: string[]): Observable { + coins = coins || []; + return this.sseService.getOrderbookUpdatesStream( + coins, + this.coinListService.convertToOrderbookDto, + ); + } - @Get('market/all') - getAllMarkets() { - return this.coinListService.getAllCoinList(); - } - @Get('market/krw') - getKRWMarkets() { - return this.coinListService.getKRWCoinList(); - } - @Get('market/btc') - getBTCMarkets() { - return this.coinListService.getBTCCoinList(); - } - @Get('market/usdt') - getUSDTMarkets() { - return this.coinListService.getUSDTCoinList(); - } + @Get('market/all') + getAllMarkets() { + return this.coinListService.getAllCoinList(); + } + @Get('market/krw') + getKRWMarkets() { + return this.coinListService.getKRWCoinList(); + } + @Get('market/btc') + getBTCMarkets() { + return this.coinListService.getBTCCoinList(); + } + @Get('market/usdt') + getUSDTMarkets() { + return this.coinListService.getUSDTCoinList(); + } - @Get('market/top20-trade/krw') - getTop20TradeKRW() { - return this.coinListService.getMostTradeCoin(); - } - @Get('market/simplelist/krw') - getSomeKRW(@Query('market') market: string[]) { - const marketList = market || []; - return this.coinListService.getSimpleCoin(marketList); - } - @Get('market/tickers') - @ApiQuery({ name: 'coins', required: false, type: String }) - getCoinTickers(@Query('coins') coins?: string) { - return this.coinListService.getCoinTickers(coins); - } + @Get('market/top20-trade/krw') + getTop20TradeKRW() { + return this.coinListService.getMostTradeCoin(); + } + @Get('market/simplelist/krw') + getSomeKRW(@Query('market') market: string[]) { + const marketList = market || []; + return this.coinListService.getSimpleCoin(marketList); + } + @Get('market/tickers') + @ApiQuery({ name: 'coins', required: false, type: String }) + getCoinTickers(@Query('coins') coins?: string) { + return this.coinListService.getCoinTickers(coins); + } - @Get('candle/:type/:minute?') - @ApiQuery({ name: 'minute', required: false, type: String }) - async getCandle( - @Res() res: Response, - @Param('type') type: string, - @Query('market') market: string, - @Query('to') to: string, - @Param('minute') minute?: string, - ) { - try { - console.log('type : ' + type); - console.log('market : ' + market); - console.log('minute : ' + minute); - console.log('to : ' + to); - const response = await this.chartService.upbitApiDoor( - type, - market, - to, - minute, - ); + @Get('candle/:type/:minute?') + @ApiQuery({ name: 'minute', required: false, type: String }) + async getCandle( + @Res() res: Response, + @Param('type') type: string, + @Query('market') market: string, + @Query('to') to: string, + @Param('minute') minute?: string, + ) { + try { + const response = await this.chartService.upbitApiDoor( + type, + market, + to, + minute, + ); - return res.status(response.statusCode).json(response); - } catch (error) { - console.error('error' + error); - return res.status(error.status).json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - }); - } - } + return res.status(response.statusCode).json(response); + } catch (error) { + console.error('error' + error); + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); + } + } } diff --git a/packages/server/src/upbit/upbit.module.ts b/packages/server/src/upbit/upbit.module.ts index 48a21931..030b3c4d 100644 --- a/packages/server/src/upbit/upbit.module.ts +++ b/packages/server/src/upbit/upbit.module.ts @@ -6,36 +6,20 @@ import { HttpModule } from '@nestjs/axios'; import { SseService } from './sse.service'; import { OrderbookService } from './orderbook-websocket.service'; import { CoinDataUpdaterService } from './coin-data-updater.service'; -import { getRedisConfig } from '@src/configs/redis.config'; -import { Redis } from 'ioredis'; -import { ChartRepository } from './chart.repository'; import { ChartService } from './chart.service'; @Global() @Module({ - imports: [HttpModule], - providers: [ - { - provide: 'CHART_REDIS_CLIENT', // Auth용 Redis 클라이언트 - useFactory: () => { - const config = getRedisConfig(); - const client = new Redis({ ...config, db: 3 }); // DB 2로 설정 - client.on('connect', () => console.log('Chart용 Redis 연결 성공')); - client.on('error', (error) => - console.error('Auth용 Redis 연결 실패:', error), - ); - return client; - }, - }, - ChartRepository, - CoinTickerService, - CoinListService, - SseService, - OrderbookService, - CoinDataUpdaterService, - ChartService - ], - controllers: [UpbitController], - exports: [CoinDataUpdaterService], + imports: [HttpModule], + providers: [ + CoinTickerService, + CoinListService, + SseService, + OrderbookService, + CoinDataUpdaterService, + ChartService, + ], + controllers: [UpbitController], + exports: [CoinDataUpdaterService], }) export class UpbitModule {} From b80033f5bec264ebb38c11806158861f3783cf01 Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Mon, 25 Nov 2024 21:21:08 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=EB=AF=B8=EC=B2=B4=EA=B2=B0=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=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 Co-authored-by: 이승관 --- .../server/src/trade/trade-ask.service.ts | 4 +- .../server/src/trade/trade-bid.service.ts | 527 +++++++++--------- packages/server/src/trade/trade.controller.ts | 30 +- packages/server/src/trade/trade.repository.ts | 247 ++++---- packages/server/src/trade/trade.service.ts | 300 +++++++--- 5 files changed, 632 insertions(+), 476 deletions(-) diff --git a/packages/server/src/trade/trade-ask.service.ts b/packages/server/src/trade/trade-ask.service.ts index cdd78bfe..21197377 100644 --- a/packages/server/src/trade/trade-ask.service.ts +++ b/packages/server/src/trade/trade-ask.service.ts @@ -47,7 +47,7 @@ export class AskService implements OnModuleInit { return parseFloat((asset.quantity * (percent / 100)).toFixed(8)); } async createAskTrade(user, askDto) { - if (askDto.receivedAmount * askDto.receivedPrice < 5000) + if (askDto.receivedAmount * askDto.receivedPrice < 0.00000001) throw new BadRequestException(); if (this.transactionCreateAsk) await this.waitForTransactionCreate(); this.transactionCreateAsk = true; @@ -302,7 +302,7 @@ export class AskService implements OnModuleInit { receivedPrice: trade.price, //건네받을 통화 가격 receivedAmount: trade.quantity, //건네 받을 통화 갯수 tradeId: trade.tradeId, - krw: another / krw, + krw: krw / another, }; this.askTradeService(askDto); }); diff --git a/packages/server/src/trade/trade-bid.service.ts b/packages/server/src/trade/trade-bid.service.ts index 12705add..6bd150aa 100644 --- a/packages/server/src/trade/trade-bid.service.ts +++ b/packages/server/src/trade/trade-bid.service.ts @@ -1,9 +1,9 @@ import { - BadRequestException, - Injectable, - InternalServerErrorException, - OnModuleInit, - UnprocessableEntityException, + BadRequestException, + Injectable, + InternalServerErrorException, + OnModuleInit, + UnprocessableEntityException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AccountRepository } from 'src/account/account.repository'; @@ -16,276 +16,281 @@ import { UserRepository } from '@src/auth/user.repository'; @Injectable() export class BidService implements OnModuleInit { - private transactionBuy: boolean = false; - private transactionCreateBid: boolean = false; - private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; + private transactionBuy: boolean = false; + private transactionCreateBid: boolean = false; + private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; - constructor( - private accountRepository: AccountRepository, - private assetRepository: AssetRepository, - private tradeRepository: TradeRepository, - private coinDataUpdaterService: CoinDataUpdaterService, - private userRepository: UserRepository, - private readonly dataSource: DataSource, - private tradeHistoryRepository: TradeHistoryRepository, - ) {} + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private tradeRepository: TradeRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + private userRepository: UserRepository, + private readonly dataSource: DataSource, + private tradeHistoryRepository: TradeHistoryRepository, + ) {} - onModuleInit() { - this.matchPendingTrades(); - } + onModuleInit() { + this.matchPendingTrades(); + } - async calculatePercentBuy(user, moneyType: string, percent: number) { - const money = await this.accountRepository.getMyMoney(user, moneyType); + async calculatePercentBuy(user, moneyType: string, percent: number) { + const money = await this.accountRepository.getMyMoney(user, moneyType); - return parseFloat((money * (percent / 100)).toFixed(8)); - } - async createBidTrade(user, bidDto) { - if (bidDto.receivedAmount * bidDto.receivedPrice < 5000) - throw new BadRequestException(); - if (this.transactionCreateBid) await this.waitForTransactionOrderBid(); - this.transactionCreateBid = true; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - try { - if (bidDto.receivedAmount <= 0) throw new BadRequestException(); - const userAccount = await this.accountRepository.findOne({ - where: { - user: { id: user.userId }, - }, - }); - if (!userAccount) { - throw new UnprocessableEntityException({ - message: '유저가 존재하지 않습니다.', - statusCode: 422, - }); - } - const accountBalance = await this.checkCurrency(user, bidDto); - await this.accountRepository.updateAccountCurrency( - bidDto.typeGiven, - parseFloat(accountBalance.toFixed(8)), - userAccount.id, - queryRunner, - ); - await this.tradeRepository.createTrade( - bidDto, - user.userId, - 'buy', - queryRunner, - ); - await queryRunner.commitTransaction(); - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - console.log(error); - await queryRunner.rollbackTransaction(); - if (error instanceof UnprocessableEntityException || BadRequestException) - throw error; - return new InternalServerErrorException({ - statusCode: 500, - message: '거래 등록에 실패했습니다.', - }); - } finally { - await queryRunner.release(); - this.transactionCreateBid = false; - } - } - async checkCurrency(user, bidDto) { - const { typeGiven, receivedPrice, receivedAmount } = bidDto; - const givenAmount = parseFloat((receivedPrice * receivedAmount).toFixed(8)); - const userAccount = await this.accountRepository.findOne({ - where: { - user: { id: user.userId }, - }, - }); - const accountBalance = userAccount[typeGiven]; - const accountResult = accountBalance - givenAmount; + return parseFloat((money * (percent / 100)).toFixed(8)); + } + async createBidTrade(user, bidDto) { + if (bidDto.receivedAmount * bidDto.receivedPrice < 0.00000001) + throw new BadRequestException(); + if (this.transactionCreateBid) await this.waitForTransactionOrderBid(); + this.transactionCreateBid = true; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + try { + if (bidDto.receivedAmount <= 0) throw new BadRequestException(); + const userAccount = await this.accountRepository.findOne({ + where: { + user: { id: user.userId }, + }, + }); + if (!userAccount) { + throw new UnprocessableEntityException({ + message: '유저가 존재하지 않습니다.', + statusCode: 422, + }); + } + const accountBalance = await this.checkCurrency(user, bidDto); + await this.accountRepository.updateAccountCurrency( + bidDto.typeGiven, + parseFloat(accountBalance.toFixed(8)), + userAccount.id, + queryRunner, + ); + await this.tradeRepository.createTrade( + bidDto, + user.userId, + 'buy', + queryRunner, + ); + await queryRunner.commitTransaction(); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + console.log(error); + await queryRunner.rollbackTransaction(); + if (error instanceof UnprocessableEntityException || BadRequestException) + throw error; + return new InternalServerErrorException({ + statusCode: 500, + message: '거래 등록에 실패했습니다.', + }); + } finally { + await queryRunner.release(); + this.transactionCreateBid = false; + } + } + async checkCurrency(user, bidDto) { + const { typeGiven, receivedPrice, receivedAmount } = bidDto; + const givenAmount = parseFloat((receivedPrice * receivedAmount).toFixed(8)); + const userAccount = await this.accountRepository.findOne({ + where: { + user: { id: user.userId }, + }, + }); + const accountBalance = userAccount[typeGiven]; + const accountResult = accountBalance - givenAmount; - if (accountResult < 0) { - throw new UnprocessableEntityException({ - message: '자산이 부족합니다.', - statusCode: 422, - }); - } - return accountResult; - } - async bidTradeService(bidDto) { - if (this.transactionBuy) await this.waitForTransactionOrder(); - this.transactionBuy = true; - const { tradeId, typeGiven, receivedPrice, userId } = bidDto; - try { - const currentCoinOrderbook = - this.coinDataUpdaterService.getCoinOrderbookByBid(bidDto); - for (const order of currentCoinOrderbook) { - if (order.ask_price > receivedPrice) break; - const account = await this.accountRepository.findOne({ - where: { - user: { id: userId }, - }, - }); - bidDto.accountBalance = account[typeGiven]; - bidDto.account = account; - const tradeData = await this.tradeRepository.findOne({ - where: { tradeId: tradeId }, - }); - if (!tradeData) break; - const result = await this.executeTrade(bidDto, order, tradeData); - if (!result) break; - } + if (accountResult < 0) { + throw new UnprocessableEntityException({ + message: '자산이 부족합니다.', + statusCode: 422, + }); + } + return accountResult; + } + async bidTradeService(bidDto) { + if (this.transactionBuy) await this.waitForTransactionOrder(); + this.transactionBuy = true; + const { tradeId, typeGiven, receivedPrice, userId } = bidDto; + try { + const currentCoinOrderbook = + this.coinDataUpdaterService.getCoinOrderbookByBid(bidDto); + for (const order of currentCoinOrderbook) { + if (order.ask_price > receivedPrice) break; + const account = await this.accountRepository.findOne({ + where: { + user: { id: userId }, + }, + }); + bidDto.accountBalance = account[typeGiven]; + bidDto.account = account; + // const tradeData = await this.tradeRepository.findOne({ + // where: { tradeId: tradeId }, + // }); + // if (!tradeData) break; + const result = await this.executeTrade(bidDto, order); + if (!result) break; + } - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - throw error; - } finally { - this.transactionBuy = false; - } - } - async executeTrade(bidDto, order, tradeData) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - const { ask_price, ask_size } = order; - const { userId, account, typeGiven, typeReceived, tradeId, krw } = bidDto; - let result = false; - try { - const buyData = { ...tradeData }; - buyData.quantity = - buyData.quantity >= ask_size - ? parseFloat(ask_size.toFixed(8)) - : parseFloat(buyData.quantity.toFixed(8)); - if (buyData.quantity < 0.00000001) { - await queryRunner.commitTransaction(); - return true; - } - buyData.price = parseFloat((ask_price * krw).toFixed(8)); - const user = await this.userRepository.getUser(userId); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + throw error; + } finally { + this.transactionBuy = false; + } + } + async executeTrade(bidDto, order) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); - await this.tradeHistoryRepository.createTradeHistory( - user, - buyData, - queryRunner, - ); + const { ask_price, ask_size } = order; + const { userId, account, typeGiven, typeReceived, krw, tradeId} = bidDto; - const asset = await this.assetRepository.findOne({ - where: { account: { id: account.id }, assetName: typeReceived }, - }); + const tradeData = await this.tradeRepository.getTradeFindOne(tradeId, queryRunner) + if (!tradeData) return false; + + let result = false; + try { + const buyData = { ...tradeData }; + buyData.quantity = + buyData.quantity >= ask_size + ? parseFloat(ask_size.toFixed(8)) + : parseFloat(buyData.quantity.toFixed(8)); + if (buyData.quantity < 0.00000001) { + await queryRunner.commitTransaction(); + return true; + } + buyData.price = parseFloat((ask_price * krw).toFixed(8)); + const user = await this.userRepository.getUser(userId); - if (asset) { - asset.price = parseFloat( - (asset.price + buyData.price * buyData.quantity).toFixed(8), - ); - asset.quantity += parseFloat(buyData.quantity.toFixed(8)); - await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); - } else { - await this.assetRepository.createAsset( - bidDto, - parseFloat((buyData.price * buyData.quantity).toFixed(8)), - buyData.quantity, - queryRunner, - ); - } + await this.tradeHistoryRepository.createTradeHistory( + user, + buyData, + queryRunner, + ); - tradeData.quantity -= parseFloat(buyData.quantity.toFixed(8)); + const asset = await this.assetRepository.findOne({ + where: { account: { id: account.id }, assetName: typeReceived }, + }); + + if (asset) { + asset.price = parseFloat( + (asset.price + buyData.price * buyData.quantity).toFixed(8), + ); + asset.quantity += parseFloat(buyData.quantity.toFixed(8)); + await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); + } else { + await this.assetRepository.createAsset( + bidDto, + parseFloat((buyData.price * buyData.quantity).toFixed(8)), + buyData.quantity, + queryRunner, + ); + } - if (tradeData.quantity <= 0.00000001) { - await this.tradeRepository.deleteTrade(tradeId, queryRunner); - } else - await this.tradeRepository.updateTradeTransaction( - tradeData, - queryRunner, - ); + tradeData.quantity -= parseFloat(buyData.quantity.toFixed(8)); - const change = (tradeData.price - buyData.price) * buyData.quantity; - const returnChange = parseFloat((change + account[typeGiven]).toFixed(8)); - const new_asset = await this.assetRepository.findOne({ - where: { account: { id: account.id }, assetName: 'BTC' }, - }); + if (tradeData.quantity <= 0.00000001) { + await this.tradeRepository.deleteTrade(tradeId, queryRunner); + } else { + await this.tradeRepository.updateTradeTransaction( + tradeData, + queryRunner, + ); + } + const change = (tradeData.price - buyData.price) * buyData.quantity; + const returnChange = parseFloat((change + account[typeGiven]).toFixed(8)); + const new_asset = await this.assetRepository.findOne({ + where: { account: { id: account.id }, assetName: 'BTC' }, + }); - if (typeReceived === 'BTC') { - const BTC_QUANTITY = new_asset ? asset.quantity : buyData.quantity; - await this.accountRepository.updateAccountBTC( - account.id, - BTC_QUANTITY, - queryRunner, - ); - } + if (typeReceived === 'BTC') { + const BTC_QUANTITY = new_asset ? asset.quantity : buyData.quantity; + await this.accountRepository.updateAccountBTC( + account.id, + BTC_QUANTITY, + queryRunner, + ); + } - await this.accountRepository.updateAccountCurrency( - typeGiven, - returnChange, - account.id, - queryRunner, - ); + await this.accountRepository.updateAccountCurrency( + typeGiven, + returnChange, + account.id, + queryRunner, + ); - await queryRunner.commitTransaction(); - result = true; - } catch (error) { - await queryRunner.rollbackTransaction(); - console.log(error); - } finally { - await queryRunner.release(); - return result; - } - } + await queryRunner.commitTransaction(); + result = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + console.log(error); + } finally { + await queryRunner.release(); + return result; + } + } - async waitForTransactionOrder() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionBuy) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async waitForTransactionOrderBid() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionCreateBid) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async matchPendingTrades() { - try { - const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); - if (coinLatestInfo.size === 0) return; - const coinPrice = []; - coinLatestInfo.forEach((value, key) => { - const price = value.trade_price; - const [give, receive] = key.split('-'); - coinPrice.push({ give: give, receive: receive, price: price }); - }); - const availableTrades = - await this.tradeRepository.searchBuyTrade(coinPrice); - availableTrades.forEach((trade) => { - const krw = coinLatestInfo.get( - ['KRW', trade.assetName].join('-'), - ).trade_price; - const another = coinLatestInfo.get( - [trade.tradeCurrency, trade.assetName].join('-'), - ).trade_price; - const bidDto = { - userId: trade.user.id, - typeGiven: trade.tradeCurrency, //건네주는 통화 - typeReceived: trade.assetName, //건네받을 통화 타입 - receivedPrice: trade.price, //건네받을 통화 가격 - receivedAmount: trade.quantity, //건네 받을 통화 갯수 - tradeId: trade.tradeId, - krw: another / krw, - }; - this.bidTradeService(bidDto); - }); - } catch (error) { - console.error('미체결 거래 처리 오류:', error); - } finally { - console.log(`미체결 거래 처리 완료: ${Date()}`); - setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); - } - } + async waitForTransactionOrder() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionBuy) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async waitForTransactionOrderBid() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionCreateBid) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async matchPendingTrades() { + try { + const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); + if (coinLatestInfo.size === 0) return; + const coinPrice = []; + coinLatestInfo.forEach((value, key) => { + const price = value.trade_price; + const [give, receive] = key.split('-'); + coinPrice.push({ give: give, receive: receive, price: price }); + }); + const availableTrades = + await this.tradeRepository.searchBuyTrade(coinPrice); + availableTrades.forEach((trade) => { + const krw = coinLatestInfo.get( + ['KRW', trade.assetName].join('-'), + ).trade_price; + const another = coinLatestInfo.get( + [trade.tradeCurrency, trade.assetName].join('-'), + ).trade_price; + const bidDto = { + userId: trade.user.id, + typeGiven: trade.tradeCurrency, //건네주는 통화 + typeReceived: trade.assetName, //건네받을 통화 타입 + receivedPrice: trade.price, //건네받을 통화 가격 + receivedAmount: trade.quantity, //건네 받을 통화 갯수 + tradeId: trade.tradeId, + krw: krw / another, + }; + this.bidTradeService(bidDto); + }); + } catch (error) { + console.error('미체결 거래 처리 오류:', error); + } finally { + console.log(`미체결 거래 처리 완료: ${Date()}`); + setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); + } + } } diff --git a/packages/server/src/trade/trade.controller.ts b/packages/server/src/trade/trade.controller.ts index 7bff784d..54984c9f 100644 --- a/packages/server/src/trade/trade.controller.ts +++ b/packages/server/src/trade/trade.controller.ts @@ -7,11 +7,12 @@ import { Param, Request, UseGuards, + Delete, Res, } from '@nestjs/common'; import { BidService } from './trade-bid.service'; import { AuthGuard } from 'src/auth/auth.guard'; -import { ApiBearerAuth, ApiSecurity, ApiBody } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiSecurity, ApiBody, ApiQuery } from '@nestjs/swagger'; import { AskService } from './trade-ask.service'; import { TradeDto } from './dtos/trade.dto'; import { TradeService } from './trade.service'; @@ -106,6 +107,7 @@ export class TradeController { @ApiBearerAuth('access-token') @ApiSecurity('access-token') + @ApiQuery({ name: 'coin', required: false, type: String }) @UseGuards(AuthGuard) @Get('tradeData/:coin?') async getMyTradeData( @@ -116,4 +118,30 @@ export class TradeController { const response = await this.tradeService.getMyTradeData(req.user, coin); return res.status(response.statusCode).json(response); } + + @ApiBearerAuth('access-token') + @ApiSecurity('access-token') + @UseGuards(AuthGuard) + @Delete('tradeData') + async deleteMyTrade( + @Request() req, + @Res() res: Response, + @Query('tradeId') tradeId: Number, + @Query('tradeType') tradeType: string, + ){ + try { + console.log("tradeId : "+tradeId) + console.log("tradeType : "+tradeType) + let response; + if(tradeType === 'buy') response = await this.tradeService.deleteMyBidTrade(req.user, tradeId); + else response = await this.tradeService.deleteMyAskTrade(req.user, tradeId); + + return res.status(response.statusCode).json(response); + } catch (error) { + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); + } + } } diff --git a/packages/server/src/trade/trade.repository.ts b/packages/server/src/trade/trade.repository.ts index e47f077a..896bdcf5 100644 --- a/packages/server/src/trade/trade.repository.ts +++ b/packages/server/src/trade/trade.repository.ts @@ -5,130 +5,137 @@ import { UserRepository } from 'src/auth/user.repository'; @Injectable() export class TradeRepository extends Repository { - constructor( - private dataSource: DataSource, - private userRepository: UserRepository, - ) { - super(Trade, dataSource.createEntityManager()); - } - async createTrade( - buyDto: any, - userId, - tradeType, - queryRunner, - ): Promise { - try { - const { typeGiven, typeReceived, receivedPrice, receivedAmount } = buyDto; + constructor( + private dataSource: DataSource, + private userRepository: UserRepository, + ) { + super(Trade, dataSource.createEntityManager()); + } + async createTrade( + buyDto: any, + userId, + tradeType, + queryRunner, + ): Promise { + try { + const { typeGiven, typeReceived, receivedPrice, receivedAmount } = buyDto; - const user = await this.userRepository.getUser(userId); + const user = await this.userRepository.getUser(userId); - if (!user) { - throw new UnprocessableEntityException({ - response: { - message: '유저가 존재하지 않습니다.', - statusCode: 422, - }, - }); - } - const trade = new Trade(); - trade.tradeType = tradeType; - trade.tradeCurrency = typeGiven; - trade.assetName = typeReceived; - trade.price = receivedPrice; - trade.quantity = receivedAmount; - trade.user = user; + if (!user) { + throw new UnprocessableEntityException({ + response: { + message: '유저가 존재하지 않습니다.', + statusCode: 422, + }, + }); + } + const trade = new Trade(); + trade.tradeType = tradeType; + trade.tradeCurrency = typeGiven; + trade.assetName = typeReceived; + trade.price = receivedPrice; + trade.quantity = receivedAmount; + trade.user = user; - const savedTrade = await queryRunner.manager.save(Trade, trade); + const savedTrade = await queryRunner.manager.save(Trade, trade); - return savedTrade.tradeId; - } catch (e) { - console.log(e); - } - } - async updateTradeTransaction(tradeData, queryRunner) { - await queryRunner.manager - .createQueryBuilder() - .update(Trade) - .set({ - quantity: tradeData.quantity, - }) - .where('tradeId = :tradeId', { tradeId: tradeData.tradeId }) - .execute(); - } - async deleteTrade(tradeId: number, queryRunner: QueryRunner): Promise { - try { - const trade = await queryRunner.manager.findOne(Trade, { - where: { tradeId }, - }); + return savedTrade.tradeId; + } catch (e) { + console.log(e); + } + } + async updateTradeTransaction(tradeData, queryRunner) { + await queryRunner.manager + .createQueryBuilder() + .update(Trade) + .set({ + quantity: tradeData.quantity, + }) + .where('tradeId = :tradeId', { tradeId: tradeData.tradeId }) + .execute(); + } + async deleteTrade(tradeId: number, queryRunner: QueryRunner): Promise { + try { + const trade = await queryRunner.manager.findOne(Trade, { + where: { tradeId }, + }); - await queryRunner.manager.delete(Trade, tradeId); + await queryRunner.manager.delete(Trade, tradeId); - return trade; - } catch (e) { - console.log(e); - throw e; - } - } - async searchBuyTrade(coinPrice) { - try { - const queryBuilder = this.createQueryBuilder('trade').leftJoinAndSelect( - 'trade.user', - 'user', - ); - coinPrice.forEach(({ give, receive, price }, index) => { - const params = { - [`give${index}`]: give, - [`receive${index}`]: receive, - [`price${index}`]: price, - [`type${index}`]: 'buy', - }; - if (index === 0) { - queryBuilder.where( - 'trade.tradeCurrency = :give0 AND trade.assetName = :receive0 AND trade.price >= :price0 AND trade.tradeType = :type0', - params, - ); - } else { - queryBuilder.orWhere( - `trade.tradeCurrency = :give${index} AND trade.assetName = :receive${index} AND trade.price >= :price${index} AND trade.tradeType = :type${index}`, - params, - ); - } - }); - const trades = await queryBuilder.getMany(); - return trades; - } catch (e) { - console.log(e); - } - } - async searchSellTrade(coinPrice) { - try { - const queryBuilder = this.createQueryBuilder('trade').leftJoinAndSelect( - 'trade.user', - 'user', - ); - coinPrice.forEach(({ give, receive, price }, index) => { - const params = { - [`give${index}`]: give, - [`receive${index}`]: receive, - [`price${index}`]: price, - [`type${index}`]: 'sell', - }; - if (index === 0) { - queryBuilder.where( - 'trade.tradeCurrency = :give0 AND trade.assetName = :receive0 AND trade.price <= :price0 AND trade.tradeType = :type0', - params, - ); - } else { - queryBuilder.orWhere( - `trade.tradeCurrency = :give${index} AND trade.assetName = :receive${index} AND trade.price <= :price${index} AND trade.tradeType = :type${index}`, - params, - ); - } - }); - const trades = await queryBuilder.getMany(); - return trades; - } catch (e) { - console.log(e); - } - } + return trade; + } catch (e) { + console.log(e); + throw e; + } + } + async searchBuyTrade(coinPrice) { + try { + const queryBuilder = this.createQueryBuilder('trade').leftJoinAndSelect( + 'trade.user', + 'user', + ); + coinPrice.forEach(({ give, receive, price }, index) => { + const params = { + [`give${index}`]: give, + [`receive${index}`]: receive, + [`price${index}`]: price, + [`type${index}`]: 'buy', + }; + if (index === 0) { + queryBuilder.where( + 'trade.tradeCurrency = :give0 AND trade.assetName = :receive0 AND trade.price >= :price0 AND trade.tradeType = :type0', + params, + ); + } else { + queryBuilder.orWhere( + `trade.tradeCurrency = :give${index} AND trade.assetName = :receive${index} AND trade.price >= :price${index} AND trade.tradeType = :type${index}`, + params, + ); + } + }); + const trades = await queryBuilder.getMany(); + return trades; + } catch (e) { + console.log(e); + } + } + async searchSellTrade(coinPrice) { + try { + const queryBuilder = this.createQueryBuilder('trade').leftJoinAndSelect( + 'trade.user', + 'user', + ); + coinPrice.forEach(({ give, receive, price }, index) => { + const params = { + [`give${index}`]: give, + [`receive${index}`]: receive, + [`price${index}`]: price, + [`type${index}`]: 'sell', + }; + if (index === 0) { + queryBuilder.where( + 'trade.tradeCurrency = :give0 AND trade.assetName = :receive0 AND trade.price <= :price0 AND trade.tradeType = :type0', + params, + ); + } else { + queryBuilder.orWhere( + `trade.tradeCurrency = :give${index} AND trade.assetName = :receive${index} AND trade.price <= :price${index} AND trade.tradeType = :type${index}`, + params, + ); + } + }); + const trades = await queryBuilder.getMany(); + return trades; + } catch (e) { + console.log(e); + } + } + async getTradeFindOne(tradeId, queryRunner) { + const tradeData = await queryRunner.manager.findOne(Trade, { + where: { tradeId: tradeId }, + lock: { mode: 'pessimistic_read' }, + }); + return tradeData + } } diff --git a/packages/server/src/trade/trade.service.ts b/packages/server/src/trade/trade.service.ts index 71e787f6..dbdc4447 100644 --- a/packages/server/src/trade/trade.service.ts +++ b/packages/server/src/trade/trade.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AccountRepository } from 'src/account/account.repository'; import { AssetRepository } from 'src/asset/asset.repository'; @@ -8,95 +8,211 @@ import { UPBIT_IMAGE_URL } from 'common/upbit'; import { TradeDataDto } from './dtos/tradeData.dto'; @Injectable() export class TradeService { - constructor( - private accountRepository: AccountRepository, - private assetRepository: AssetRepository, - private tradeRepository: TradeRepository, - private coinDataUpdaterService: CoinDataUpdaterService, - private readonly dataSource: DataSource, - ) {} - async checkMyCoinData(user, coin) { - const account = await this.accountRepository.findOne({ - where: { user: { id: user.userId } }, - }); - if (!account) { - return { - statusCode: 400, - message: '등록되지 않은 사용자입니다.', - }; - } - const coinData = await this.assetRepository.findOne({ - where: { - account: { id: account.id }, - assetName: coin, - }, - }); - if (coinData) { - return { - statusCode: 200, - message: '보유하고 계신 코인입니다.', - own: true, - }; - } else { - return { - statusCode: 201, - message: '보유하지 않은 코인입니다.', - own: false, - }; - } - } - async getMyTradeData(user, coin) { - try { - const result = []; - let tradeData = await this.tradeRepository.find({ - where: { user: { id: user.userId } }, - }); - - if (tradeData.length === 0) { - return { - statusCode: 201, - message: '미체결 데이터가 없습니다.', - result: [], - }; - } - const coinNameData = this.coinDataUpdaterService.getCoinNameList(); - if (coin) { - const [assetName, tradeCurrency] = coin.split('-'); - tradeData = tradeData.filter( - ({ assetName: a, tradeCurrency: t }) => - (a === assetName && t === tradeCurrency) || - (a === tradeCurrency && t === assetName), - ); - } - tradeData.forEach((trade) => { - const name = - trade.tradeType === 'buy' ? trade.tradeCurrency : trade.assetName; - const tradeType = trade.tradeType; - const tradedata: TradeDataDto = { - img_url: `${UPBIT_IMAGE_URL}${name}.png`, - koreanName: - coinNameData.get(`${trade.assetName}-${trade.tradeCurrency}`) || - coinNameData.get(`${trade.tradeCurrency}-${trade.assetName}`), - coin: tradeType === 'buy' ? trade.assetName : trade.tradeCurrency, - market: tradeType === 'sell' ? trade.assetName : trade.tradeCurrency, - tradeId: trade.tradeId, - tradeType: tradeType, - price: trade.price, - quantity: trade.quantity, - createdAt: trade.createdAt, - userId: user.userId, - }; - - result.push(tradedata); - }); - return { - statusCode: 200, - message: '미체결 데이터가 있습니다.', - result, - }; - } catch (error) { - console.error(error); - return error; - } - } + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private tradeRepository: TradeRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + private readonly dataSource: DataSource, + ) {} + async checkMyCoinData(user, coin) { + const account = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + if (!account) { + return { + statusCode: 400, + message: '등록되지 않은 사용자입니다.', + }; + } + const coinData = await this.assetRepository.findOne({ + where: { + account: { id: account.id }, + assetName: coin, + }, + }); + if (coinData) { + return { + statusCode: 200, + message: '보유하고 계신 코인입니다.', + own: true, + }; + } else { + return { + statusCode: 201, + message: '보유하지 않은 코인입니다.', + own: false, + }; + } + } + async getMyTradeData(user, coin) { + try { + const result = []; + let tradeData = await this.tradeRepository.find({ + where: { user: { id: user.userId } }, + }); + + if (tradeData.length === 0) { + return { + statusCode: 201, + message: '미체결 데이터가 없습니다.', + result: [], + }; + } + const coinNameData = this.coinDataUpdaterService.getCoinNameList(); + if (coin) { + const [assetName, tradeCurrency] = coin.split('-'); + tradeData = tradeData.filter( + ({ assetName: a, tradeCurrency: t }) => + (a === assetName && t === tradeCurrency) || + (a === tradeCurrency && t === assetName), + ); + } + tradeData.forEach((trade) => { + const name = + trade.tradeType === 'buy' ? trade.tradeCurrency : trade.assetName; + const tradeType = trade.tradeType; + const tradedata: TradeDataDto = { + img_url: `${UPBIT_IMAGE_URL}${name}.png`, + koreanName: + coinNameData.get(`${trade.assetName}-${trade.tradeCurrency}`) || + coinNameData.get(`${trade.tradeCurrency}-${trade.assetName}`), + coin: tradeType === 'buy' ? trade.assetName : trade.tradeCurrency, + market: tradeType === 'sell' ? trade.assetName : trade.tradeCurrency, + tradeId: trade.tradeId, + tradeType: tradeType, + price: trade.price, + quantity: trade.quantity, + createdAt: trade.createdAt, + userId: user.userId, + }; + + result.push(tradedata); + }); + return { + statusCode: 200, + message: '미체결 데이터가 있습니다.', + result, + }; + } catch (error) { + console.error(error); + return error; + } + } + + async deleteMyBidTrade(user, tradeId) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + + try { + // 미체결 거래를 검색 + const trade = await this.tradeRepository.findOne({ + where: { + tradeId, + user: { id: user.userId }, + }, + }); + if (!trade) { + throw new UnprocessableEntityException({ + statusCode: 422, + message: '해당 미체결 거래를 찾을 수 없습니다.', + }); + } + + await this.tradeRepository.deleteTrade(tradeId, queryRunner); + + const userAccount = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + const accountBalance = parseFloat( + ( + trade.price * trade.quantity + + userAccount[trade.tradeCurrency] + ).toFixed(8), + ); + await this.accountRepository.updateAccountCurrency( + trade.tradeCurrency, + accountBalance, + userAccount.id, + queryRunner, + ); + + await queryRunner.commitTransaction(); + + return { + statusCode: 200, + message: '거래가 성공적으로 취소되었습니다.', + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw new UnprocessableEntityException({ + statusCode: 422, + message: '해당 미체결 거래를 찾을 수 없습니다.', + }); + } finally { + await queryRunner.release(); + } + } + async deleteMyAskTrade(user, tradeId) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + + try { + // 미체결 거래를 검색 + const trade = await this.tradeRepository.findOne({ + where: { + tradeId, + user: { id: user.userId }, + }, + }); + if (!trade) { + throw new UnprocessableEntityException({ + statusCode: 422, + message: '해당 미체결 거래를 찾을 수 없습니다.', + }); + } + + await this.tradeRepository.deleteTrade(tradeId, queryRunner); + + const userAccount = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + + const userAsset = await this.assetRepository.findOne({ + where: { + account: { id: userAccount.id }, + assetName: trade.tradeCurrency, + }, + }); + + userAsset.quantity = parseFloat( + (userAsset.quantity + trade.quantity).toFixed(8), + ); + userAsset.price = parseFloat( + (userAsset.price + trade.quantity * trade.price).toFixed(8), + ); + + await this.assetRepository.updateAssetQuantityPrice( + userAsset, + queryRunner, + ); + + await queryRunner.commitTransaction(); + + return { + statusCode: 200, + message: '거래가 성공적으로 취소되었습니다.', + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw new UnprocessableEntityException({ + statusCode: 422, + message: '해당 미체결 거래를 찾을 수 없습니다.', + }); + } finally { + await queryRunner.release(); + } + } } From 0e5bfe97ca99c6e2b2b0a0d96ac8d087ea550ca7 Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Mon, 25 Nov 2024 21:39:55 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=EB=A7=A4=EB=8F=84=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/trade/trade-ask.service.ts | 555 +++++++++--------- packages/server/src/trade/trade.service.ts | 23 - 2 files changed, 269 insertions(+), 309 deletions(-) diff --git a/packages/server/src/trade/trade-ask.service.ts b/packages/server/src/trade/trade-ask.service.ts index 21197377..c8c82e50 100644 --- a/packages/server/src/trade/trade-ask.service.ts +++ b/packages/server/src/trade/trade-ask.service.ts @@ -1,8 +1,8 @@ import { - BadRequestException, - Injectable, - OnModuleInit, - UnprocessableEntityException, + BadRequestException, + Injectable, + OnModuleInit, + UnprocessableEntityException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AccountRepository } from 'src/account/account.repository'; @@ -15,302 +15,285 @@ import { UserRepository } from '@src/auth/user.repository'; @Injectable() export class AskService implements OnModuleInit { - private transactionAsk: boolean = false; - private transactionCreateAsk: boolean = false; - private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; + private transactionAsk: boolean = false; + private transactionCreateAsk: boolean = false; + private matchPendingTradesTimeoutId: NodeJS.Timeout | null = null; - constructor( - private accountRepository: AccountRepository, - private assetRepository: AssetRepository, - private tradeRepository: TradeRepository, - private coinDataUpdaterService: CoinDataUpdaterService, - private userRepository: UserRepository, - private readonly dataSource: DataSource, - private tradeHistoryRepository: TradeHistoryRepository, - ) {} + constructor( + private accountRepository: AccountRepository, + private assetRepository: AssetRepository, + private tradeRepository: TradeRepository, + private coinDataUpdaterService: CoinDataUpdaterService, + private userRepository: UserRepository, + private readonly dataSource: DataSource, + private tradeHistoryRepository: TradeHistoryRepository, + ) {} - onModuleInit() { - this.matchPendingTrades(); - } + onModuleInit() { + this.matchPendingTrades(); + } - async calculatePercentBuy(user, moneyType: string, percent: number) { - const account = await this.accountRepository.findOne({ - where: { user: { id: user.userId } }, - }); - const asset = await this.assetRepository.findOne({ - where: { - account: { id: account.id }, - assetName: moneyType, - }, - }); - if (!asset) return 0; - return parseFloat((asset.quantity * (percent / 100)).toFixed(8)); - } - async createAskTrade(user, askDto) { - if (askDto.receivedAmount * askDto.receivedPrice < 0.00000001) - throw new BadRequestException(); - if (this.transactionCreateAsk) await this.waitForTransactionCreate(); - this.transactionCreateAsk = true; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - try { - if (askDto.receivedAmount <= 0) throw new BadRequestException(); - const userAccount = await this.accountRepository.findOne({ - where: { - user: { id: user.userId }, - }, - }); - if (!userAccount) { - throw new UnprocessableEntityException({ - message: '유저가 존재하지 않습니다.', - statusCode: 422, - }); - } - const userAsset = await this.checkCurrency( - askDto, - userAccount, - queryRunner, - ); - const assetBalance = parseFloat( - (userAsset.quantity - askDto.receivedAmount).toFixed(8), - ); - if (assetBalance <= 0) { - await this.assetRepository.delete({ - assetId: userAsset.assetId, - }); - } else { - userAsset.quantity = assetBalance; - userAsset.price -= - parseFloat(askDto.receivedPrice.toFixed(8)) * - parseFloat(askDto.receivedAmount.toFixed(8)); - this.assetRepository.updateAssetPrice(userAsset, queryRunner); - } - await this.tradeRepository.createTrade( - askDto, - user.userId, - 'sell', - queryRunner, - ); - await queryRunner.commitTransaction(); + async calculatePercentBuy(user, moneyType: string, percent: number) { + const account = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + const asset = await this.assetRepository.findOne({ + where: { + account: { id: account.id }, + assetName: moneyType, + }, + }); + if (!asset) return 0; + return parseFloat((asset.quantity * (percent / 100)).toFixed(8)); + } + async createAskTrade(user, askDto) { + if (askDto.receivedAmount * askDto.receivedPrice < 0.00000001) + throw new BadRequestException(); + if (this.transactionCreateAsk) await this.waitForTransactionCreate(); + this.transactionCreateAsk = true; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + try { + if (askDto.receivedAmount <= 0) throw new BadRequestException(); + const userAccount = await this.accountRepository.findOne({ + where: { + user: { id: user.userId }, + }, + }); + if (!userAccount) { + throw new UnprocessableEntityException({ + message: '유저가 존재하지 않습니다.', + statusCode: 422, + }); + } - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - console.log(error); - await queryRunner.rollbackTransaction(); - if (error instanceof UnprocessableEntityException || BadRequestException) - throw error; - return new UnprocessableEntityException({ - statusCode: 422, - message: '거래 등록에 실패했습니다.', - }); - } finally { - await queryRunner.release(); - this.transactionCreateAsk = false; - } - } - async checkCurrency(askDto, account, queryRunner) { - const { typeGiven, receivedAmount } = askDto; - const userAsset = await this.assetRepository.getAsset( - account.id, - typeGiven, - queryRunner, - ); - if (!userAsset) { - throw new UnprocessableEntityException({ - message: '자산이 부족합니다.', - statusCode: 422, - }); - } - const accountBalance = userAsset.quantity; - const accountResult = accountBalance - receivedAmount; - if (accountResult < 0) - throw new UnprocessableEntityException({ - message: '자산이 부족합니다.', - statusCode: 422, - }); - return userAsset; - } - async askTradeService(askDto) { - if (this.transactionAsk) await this.waitForTransactionOrder(); - this.transactionAsk = true; - const { tradeId, typeGiven, receivedPrice, userId } = askDto; - try { - const account = await this.accountRepository.findOne({ - where: { user: { id: userId } }, - }); - const userAsset = await this.assetRepository.findOne({ - where: { - account: { id: account.id }, - assetName: typeGiven, - }, - }); - if (userAsset) { - askDto.assetBalance = userAsset.quantity; - askDto.asset = userAsset; - } - const currentCoinOrderbook = - this.coinDataUpdaterService.getCoinOrderbookByAsk(askDto); - for (const order of currentCoinOrderbook) { - if (order.bid_price < receivedPrice) break; - const tradeData = await this.tradeRepository.findOne({ - where: { tradeId: tradeId }, - }); - if (!tradeData) break; - const result = await this.executeTrade(askDto, order, tradeData); - if (!result) break; - } + await this.tradeRepository.createTrade( + askDto, + user.userId, + 'sell', + queryRunner, + ); + await queryRunner.commitTransaction(); - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - } catch (error) { - throw error; - } finally { - this.transactionAsk = false; - } - } - async executeTrade(askDto, order, tradeData) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction('READ COMMITTED'); - const { bid_price, bid_size } = order; - const { userId, tradeId, asset, typeGiven, typeReceived, krw } = askDto; - let result = false; - try { - const buyData = { ...tradeData }; - buyData.quantity = - tradeData.quantity >= bid_size - ? parseFloat(bid_size.toFixed(8)) - : parseFloat(tradeData.quantity.toFixed(8)); - buyData.price = parseFloat((bid_price * krw).toFixed(8)); - if (buyData.quantity < 0.00000001) { - await queryRunner.commitTransaction(); - return true; - } - const user = await this.userRepository.getUser(userId); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + console.log(error); + await queryRunner.rollbackTransaction(); + if (error instanceof UnprocessableEntityException || BadRequestException) + throw error; + return new UnprocessableEntityException({ + statusCode: 422, + message: '거래 등록에 실패했습니다.', + }); + } finally { + await queryRunner.release(); + this.transactionCreateAsk = false; + } + } + async checkCurrency(askDto, account, queryRunner) { + const { typeGiven, receivedAmount } = askDto; + const userAsset = await this.assetRepository.getAsset( + account.id, + typeGiven, + queryRunner, + ); + if (!userAsset) { + throw new UnprocessableEntityException({ + message: '자산이 부족합니다.', + statusCode: 422, + }); + } + const accountBalance = userAsset.quantity; + const accountResult = accountBalance - receivedAmount; + if (accountResult < 0) + throw new UnprocessableEntityException({ + message: '자산이 부족합니다.', + statusCode: 422, + }); + return userAsset; + } + async askTradeService(askDto) { + if (this.transactionAsk) await this.waitForTransactionOrder(); + this.transactionAsk = true; + const { tradeId, typeGiven, receivedPrice, userId } = askDto; + try { + const account = await this.accountRepository.findOne({ + where: { user: { id: userId } }, + }); + const userAsset = await this.assetRepository.findOne({ + where: { + account: { id: account.id }, + assetName: typeGiven, + }, + }); + if (userAsset) { + askDto.assetBalance = userAsset.quantity; + askDto.asset = userAsset; + } + const currentCoinOrderbook = + this.coinDataUpdaterService.getCoinOrderbookByAsk(askDto); + for (const order of currentCoinOrderbook) { + if (order.bid_price < receivedPrice) break; + const tradeData = await this.tradeRepository.findOne({ + where: { tradeId: tradeId }, + }); + if (!tradeData) break; + const result = await this.executeTrade(askDto, order, tradeData); + if (!result) break; + } - const assetName = buyData.assetName; - buyData.assetName = buyData.tradeCurrency; - buyData.tradeCurrency = assetName; + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + } catch (error) { + throw error; + } finally { + this.transactionAsk = false; + } + } + async executeTrade(askDto, order, tradeData) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('READ COMMITTED'); + const { bid_price, bid_size } = order; + const { userId, tradeId, asset, typeGiven, typeReceived, krw } = askDto; + let result = false; + try { + const buyData = { ...tradeData }; + buyData.quantity = + tradeData.quantity >= bid_size + ? parseFloat(bid_size.toFixed(8)) + : parseFloat(tradeData.quantity.toFixed(8)); + buyData.price = parseFloat((bid_price * krw).toFixed(8)); + if (buyData.quantity < 0.00000001) { + await queryRunner.commitTransaction(); + return true; + } + const user = await this.userRepository.getUser(userId); - await this.tradeHistoryRepository.createTradeHistory( - user, - buyData, - queryRunner, - ); + const assetName = buyData.assetName; + buyData.assetName = buyData.tradeCurrency; + buyData.tradeCurrency = assetName; - if (!asset && tradeData.price > buyData.price) { - asset.price = parseFloat( - ( - asset.price + - (tradeData.price - buyData.price) * buyData.quantity - ).toFixed(8), - ); + await this.tradeHistoryRepository.createTradeHistory( + user, + buyData, + queryRunner, + ); + asset.quantity = parseFloat((asset.quantity - buyData.quantity).toFixed(8)); + asset.price = parseFloat( + (asset.price - buyData.price * buyData.quantity).toFixed(8), + ); + if(asset.quantity < 0.00000001){ + await this.assetRepository.delete({ + assetId :asset.assetId + }) + }else { await this.assetRepository.updateAssetPrice(asset, queryRunner); } - const account = await this.accountRepository.findOne({ - where: { user: { id: userId } }, - }); + const account = await this.accountRepository.findOne({ + where: { user: { id: userId } }, + }); - if (typeGiven === 'BTC') { - const BTC_QUANTITY = account.BTC - buyData.quantity; - await this.accountRepository.updateAccountBTC( - account.id, - BTC_QUANTITY, - queryRunner, - ); - } - const change = parseFloat( - (account[typeReceived] + buyData.price * buyData.quantity).toFixed(8), - ); - await this.accountRepository.updateAccountCurrency( - typeReceived, - change, - account.id, - queryRunner, - ); + if (typeGiven === 'BTC') { + const BTC_QUANTITY = account.BTC - buyData.quantity; + await this.accountRepository.updateAccountBTC( + account.id, + BTC_QUANTITY, + queryRunner, + ); + } + const change = parseFloat( + (account[typeReceived] + buyData.price * buyData.quantity).toFixed(8), + ); + await this.accountRepository.updateAccountCurrency( + typeReceived, + change, + account.id, + queryRunner, + ); - tradeData.quantity -= buyData.quantity; + tradeData.quantity -= buyData.quantity; - if (tradeData.quantity <= 0.00000001) { - await this.tradeRepository.deleteTrade(tradeId, queryRunner); - } else { - await this.tradeRepository.updateTradeTransaction( - tradeData, - queryRunner, - ); - } - await queryRunner.commitTransaction(); - result = true; - } catch (error) { - await queryRunner.rollbackTransaction(); - console.log(error); - } finally { - await queryRunner.release(); - return result; - } - } + if (tradeData.quantity <= 0.00000001) { + await this.tradeRepository.deleteTrade(tradeId, queryRunner); + } else { + await this.tradeRepository.updateTradeTransaction( + tradeData, + queryRunner, + ); + } + await queryRunner.commitTransaction(); + result = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + console.log(error); + } finally { + await queryRunner.release(); + return result; + } + } - async waitForTransactionOrder() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionAsk) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async waitForTransactionCreate() { - return new Promise((resolve) => { - const check = () => { - if (!this.transactionCreateAsk) resolve(); - else setTimeout(check, 100); - }; - check(); - }); - } - async matchPendingTrades() { - try { - const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); - if (coinLatestInfo.size === 0) return; - const coinPrice = []; - coinLatestInfo.forEach((value, key) => { - const price = value.trade_price; - const [give, receive] = key.split('-'); - coinPrice.push({ give: receive, receive: give, price: price }); - }); - const availableTrades = - await this.tradeRepository.searchSellTrade(coinPrice); - availableTrades.forEach((trade) => { - const krw = coinLatestInfo.get( - ['KRW', trade.tradeCurrency].join('-'), - ).trade_price; - const another = coinLatestInfo.get( - [trade.assetName, trade.tradeCurrency].join('-'), - ).trade_price; - const askDto = { - userId: trade.user.id, - typeGiven: trade.tradeCurrency, //건네주는 통화 - typeReceived: trade.assetName, //건네받을 통화 타입 - receivedPrice: trade.price, //건네받을 통화 가격 - receivedAmount: trade.quantity, //건네 받을 통화 갯수 - tradeId: trade.tradeId, - krw: krw / another, - }; - this.askTradeService(askDto); - }); - } catch (error) { - console.error('미체결 거래 처리 오류:', error); - } finally { - console.log(`미체결 거래 처리 완료: ${Date()}`); - setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); - } - } + async waitForTransactionOrder() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionAsk) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async waitForTransactionCreate() { + return new Promise((resolve) => { + const check = () => { + if (!this.transactionCreateAsk) resolve(); + else setTimeout(check, 100); + }; + check(); + }); + } + async matchPendingTrades() { + try { + const coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); + if (coinLatestInfo.size === 0) return; + const coinPrice = []; + coinLatestInfo.forEach((value, key) => { + const price = value.trade_price; + const [give, receive] = key.split('-'); + coinPrice.push({ give: receive, receive: give, price: price }); + }); + const availableTrades = + await this.tradeRepository.searchSellTrade(coinPrice); + availableTrades.forEach((trade) => { + const krw = coinLatestInfo.get( + ['KRW', trade.tradeCurrency].join('-'), + ).trade_price; + const another = coinLatestInfo.get( + [trade.assetName, trade.tradeCurrency].join('-'), + ).trade_price; + const askDto = { + userId: trade.user.id, + typeGiven: trade.tradeCurrency, //건네주는 통화 + typeReceived: trade.assetName, //건네받을 통화 타입 + receivedPrice: trade.price, //건네받을 통화 가격 + receivedAmount: trade.quantity, //건네 받을 통화 갯수 + tradeId: trade.tradeId, + krw: krw / another, + }; + this.askTradeService(askDto); + }); + } catch (error) { + console.error('미체결 거래 처리 오류:', error); + } finally { + console.log(`미체결 거래 처리 완료: ${Date()}`); + setTimeout(() => this.matchPendingTrades(), UPBIT_UPDATED_COIN_INFO_TIME); + } + } } diff --git a/packages/server/src/trade/trade.service.ts b/packages/server/src/trade/trade.service.ts index dbdc4447..76cbb4a2 100644 --- a/packages/server/src/trade/trade.service.ts +++ b/packages/server/src/trade/trade.service.ts @@ -176,29 +176,6 @@ export class TradeService { await this.tradeRepository.deleteTrade(tradeId, queryRunner); - const userAccount = await this.accountRepository.findOne({ - where: { user: { id: user.userId } }, - }); - - const userAsset = await this.assetRepository.findOne({ - where: { - account: { id: userAccount.id }, - assetName: trade.tradeCurrency, - }, - }); - - userAsset.quantity = parseFloat( - (userAsset.quantity + trade.quantity).toFixed(8), - ); - userAsset.price = parseFloat( - (userAsset.price + trade.quantity * trade.price).toFixed(8), - ); - - await this.assetRepository.updateAssetQuantityPrice( - userAsset, - queryRunner, - ); - await queryRunner.commitTransaction(); return { From cbaacae14f3cd36bea006f94350bee6dc3e3fb5b Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Tue, 26 Nov 2024 12:45:33 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20asset=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=A7=A4=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/account/account.service.ts | 1 + packages/server/src/asset/asset.entity.ts | 3 + packages/server/src/asset/asset.repository.ts | 156 ++++++------ .../server/src/trade/trade-ask.service.ts | 33 ++- .../server/src/trade/trade-bid.service.ts | 1 + packages/server/src/trade/trade.controller.ts | 234 ++++++++---------- packages/server/src/trade/trade.service.ts | 18 ++ 7 files changed, 240 insertions(+), 206 deletions(-) diff --git a/packages/server/src/account/account.service.ts b/packages/server/src/account/account.service.ts index 08b99f99..7234c838 100644 --- a/packages/server/src/account/account.service.ts +++ b/packages/server/src/account/account.service.ts @@ -43,6 +43,7 @@ export class AccountService { coinNameData.get(`USDT-${name}`), market: name, quantity: myCoin.quantity, + availableQuantity: myCoin.availableQuantity, price: myCoin.price, averagePrice: myCoin.price / myCoin.quantity, }; diff --git a/packages/server/src/asset/asset.entity.ts b/packages/server/src/asset/asset.entity.ts index f2d125c7..adb9e815 100644 --- a/packages/server/src/asset/asset.entity.ts +++ b/packages/server/src/asset/asset.entity.ts @@ -22,6 +22,9 @@ export class Asset { @Column('double') quantity: number; + @Column('double') + availableQuantity: number; + @CreateDateColumn({ type: 'timestamp' }) created: Date; diff --git a/packages/server/src/asset/asset.repository.ts b/packages/server/src/asset/asset.repository.ts index 3e3d88e9..fc3834b9 100644 --- a/packages/server/src/asset/asset.repository.ts +++ b/packages/server/src/asset/asset.repository.ts @@ -4,75 +4,89 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AssetRepository extends Repository { - constructor(private dataSource: DataSource) { - super(Asset, dataSource.createEntityManager()); - } - async createAsset(buyDto, price, quantity, queryRunner) { - try { - const { typeReceived, account } = buyDto; - const asset = new Asset(); - asset.assetName = typeReceived; - asset.price = price; - asset.quantity = quantity; - asset.account = account; - await queryRunner.manager.save(Asset, asset); - } catch (e) { - console.log(e); - } - } - async updateAssetQuantityPrice(asset, queryRunner) { - try { - await queryRunner.manager - .createQueryBuilder() - .update(Asset) - .set({ - quantity: asset.quantity, - price: asset.price, - }) - .where('assetId = :assetId', { assetId: asset.assetId }) - .execute(); - } catch (e) { - console.log(e); - } - } - async updateAssetQuantity(asset, queryRunner) { - try { - await queryRunner.manager - .createQueryBuilder() - .update(Asset) - .set({ quantity: asset.quantity }) - .where('assetId = :assetId', { assetId: asset.assetId }) - .execute(); - } catch (e) { - console.log(e); - } - } - async updateAssetPrice(asset, queryRunner) { - try { - await queryRunner.manager - .createQueryBuilder() - .update(Asset) - .set({ - price: asset.price, - quantity: asset.quantity, - }) - .where('assetId = :assetId', { assetId: asset.assetId }) - .execute(); - } catch (e) { - console.log(e); - } - } - async getAsset(id, assetName, queryRunner) { - try { - return await queryRunner.manager.findOne(Asset, { - where: { - account: { id: id }, - assetName: assetName, - }, - }); - } catch (error) { - console.error('Error fetching asset:', error); - throw new Error('Failed to fetch asset'); - } - } + constructor(private dataSource: DataSource) { + super(Asset, dataSource.createEntityManager()); + } + async createAsset(buyDto, price, quantity, queryRunner) { + try { + const { typeReceived, account } = buyDto; + const asset = new Asset(); + asset.assetName = typeReceived; + asset.price = price; + asset.quantity = quantity; + asset.availableQuantity = quantity; + asset.account = account; + await queryRunner.manager.save(Asset, asset); + } catch (e) { + console.log(e); + } + } + async updateAssetQuantityPrice(asset, queryRunner) { + try { + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ + quantity: asset.quantity, + price: asset.price, + }) + .where('assetId = :assetId', { assetId: asset.assetId }) + .execute(); + } catch (e) { + console.log(e); + } + } + async updateAssetQuantity(asset, queryRunner) { + try { + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ quantity: asset.quantity }) + .where('assetId = :assetId', { assetId: asset.assetId }) + .execute(); + } catch (e) { + console.log(e); + } + } + + async updateAssetAvailableQuantity(asset, queryRunner) { + try { + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ availableQuantity: asset.availableQuantity }) + .where('assetId = :assetId', { assetId: asset.assetId }) + .execute(); + } catch (e) { + console.log(e); + } + } + async updateAssetPrice(asset, queryRunner) { + try { + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ + price: asset.price, + quantity: asset.quantity, + }) + .where('assetId = :assetId', { assetId: asset.assetId }) + .execute(); + } catch (e) { + console.log(e); + } + } + async getAsset(id, assetName, queryRunner) { + try { + return await queryRunner.manager.findOne(Asset, { + where: { + account: { id: id }, + assetName: assetName, + }, + }); + } catch (error) { + console.error('Error fetching asset:', error); + throw new Error('Failed to fetch asset'); + } + } } diff --git a/packages/server/src/trade/trade-ask.service.ts b/packages/server/src/trade/trade-ask.service.ts index c8c82e50..11799948 100644 --- a/packages/server/src/trade/trade-ask.service.ts +++ b/packages/server/src/trade/trade-ask.service.ts @@ -74,6 +74,19 @@ export class AskService implements OnModuleInit { 'sell', queryRunner, ); + + const userAsset = await this.checkCurrency( + askDto, + userAccount, + queryRunner, + ); + + userAsset.availableQuantity = parseFloat( + (userAsset.availableQuantity - askDto.receivedAmount).toFixed(8), + ); + + this.assetRepository.updateAssetAvailableQuantity(userAsset, queryRunner); + await queryRunner.commitTransaction(); return { @@ -107,7 +120,7 @@ export class AskService implements OnModuleInit { statusCode: 422, }); } - const accountBalance = userAsset.quantity; + const accountBalance = userAsset.availableQuantity; const accountResult = accountBalance - receivedAmount; if (accountResult < 0) throw new UnprocessableEntityException({ @@ -186,17 +199,19 @@ export class AskService implements OnModuleInit { queryRunner, ); - asset.quantity = parseFloat((asset.quantity - buyData.quantity).toFixed(8)); + asset.quantity = parseFloat( + (asset.quantity - buyData.quantity).toFixed(8), + ); asset.price = parseFloat( (asset.price - buyData.price * buyData.quantity).toFixed(8), ); - if(asset.quantity < 0.00000001){ - await this.assetRepository.delete({ - assetId :asset.assetId - }) - }else { - await this.assetRepository.updateAssetPrice(asset, queryRunner); - } + if (asset.quantity < 0.00000001) { + await this.assetRepository.delete({ + assetId: asset.assetId, + }); + } else { + await this.assetRepository.updateAssetPrice(asset, queryRunner); + } const account = await this.accountRepository.findOne({ where: { user: { id: userId } }, diff --git a/packages/server/src/trade/trade-bid.service.ts b/packages/server/src/trade/trade-bid.service.ts index 6bd150aa..804ddbd3 100644 --- a/packages/server/src/trade/trade-bid.service.ts +++ b/packages/server/src/trade/trade-bid.service.ts @@ -185,6 +185,7 @@ export class BidService implements OnModuleInit { (asset.price + buyData.price * buyData.quantity).toFixed(8), ); asset.quantity += parseFloat(buyData.quantity.toFixed(8)); + asset.availableQuantity += parseFloat(buyData.quantity.toFixed(8)); await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); } else { await this.assetRepository.createAsset( diff --git a/packages/server/src/trade/trade.controller.ts b/packages/server/src/trade/trade.controller.ts index 54984c9f..27135617 100644 --- a/packages/server/src/trade/trade.controller.ts +++ b/packages/server/src/trade/trade.controller.ts @@ -1,14 +1,14 @@ import { - Body, - Controller, - Get, - Post, - Query, - Param, - Request, - UseGuards, - Delete, - Res, + Body, + Controller, + Get, + Post, + Query, + Param, + Request, + UseGuards, + Delete, + Res, } from '@nestjs/common'; import { BidService } from './trade-bid.service'; import { AuthGuard } from 'src/auth/auth.guard'; @@ -18,130 +18,112 @@ import { TradeDto } from './dtos/trade.dto'; import { TradeService } from './trade.service'; import { Response } from 'express'; +@ApiBearerAuth('access-token') +@ApiSecurity('access-token') +@UseGuards(AuthGuard) @Controller('trade') export class TradeController { - constructor( - private bidService: BidService, - private askService: AskService, - private tradeService: TradeService, - ) {} + constructor( + private bidService: BidService, + private askService: AskService, + private tradeService: TradeService, + ) {} - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Get('calculate-percentage-bid/:moneyType') - calculatePercentBid( - @Request() req, - @Param('moneyType') moneyType: string, - @Query('percent') percent: number, - ) { - return this.bidService.calculatePercentBuy(req.user, moneyType, percent); - } + @Get('calculate-percentage-bid/:moneyType') + calculatePercentBid( + @Request() req, + @Param('moneyType') moneyType: string, + @Query('percent') percent: number, + ) { + return this.bidService.calculatePercentBuy(req.user, moneyType, percent); + } - @ApiBody({ type: TradeDto }) - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Post('bid') - async bidTrade( - @Request() req, - @Body() bidDto: Record, - @Res() res: Response, - ) { - try { - const response = await this.bidService.createBidTrade(req.user, bidDto); - return res.status(200).json(response); - } catch (error) { - return res.status(error.status).json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - }); - } - } + @ApiBody({ type: TradeDto }) + @Post('bid') + async bidTrade( + @Request() req, + @Body() bidDto: Record, + @Res() res: Response, + ) { + try { + const response = await this.bidService.createBidTrade(req.user, bidDto); + return res.status(200).json(response); + } catch (error) { + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); + } + } - @ApiBody({ type: TradeDto }) - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Post('ask') - async askTrade( - @Request() req, - @Body() askDto: Record, - @Res() res: Response, - ) { - try { - const response = await this.askService.createAskTrade(req.user, askDto); - return res.status(200).json(response); - } catch (error) { - return res.status(error.status).json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - }); - } - } + @ApiBody({ type: TradeDto }) + @Post('ask') + async askTrade( + @Request() req, + @Body() askDto: Record, + @Res() res: Response, + ) { + try { + const response = await this.askService.createAskTrade(req.user, askDto); + return res.status(200).json(response); + } catch (error) { + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); + } + } - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Get('calculate-percentage-ask/:moneyType') - calculatePercentAsk( - @Request() req, - @Param('moneyType') moneyType: string, - @Query('percent') percent: number, - ) { - return this.askService.calculatePercentBuy(req.user, moneyType, percent); - } + @Get('calculate-percentage-ask/:moneyType') + calculatePercentAsk( + @Request() req, + @Param('moneyType') moneyType: string, + @Query('percent') percent: number, + ) { + return this.askService.calculatePercentBuy(req.user, moneyType, percent); + } - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Get('check-coindata/:coin') - async getMyCoinData( - @Request() req, - @Param('coin') coin: string, - @Res() res: Response, - ) { - const response = await this.tradeService.checkMyCoinData(req.user, coin); - return res.status(response.statusCode).json(response); - } + @Get('check-coindata/:coin') + async getMyCoinData( + @Request() req, + @Param('coin') coin: string, + @Res() res: Response, + ) { + const response = await this.tradeService.checkMyCoinData(req.user, coin); + return res.status(response.statusCode).json(response); + } - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @ApiQuery({ name: 'coin', required: false, type: String }) - @UseGuards(AuthGuard) - @Get('tradeData/:coin?') - async getMyTradeData( - @Request() req, - @Res() res: Response, - @Param('coin') coin?: string, - ) { - const response = await this.tradeService.getMyTradeData(req.user, coin); - return res.status(response.statusCode).json(response); - } + @ApiQuery({ name: 'coin', required: false, type: String }) + @Get('tradeData/:coin?') + async getMyTradeData( + @Request() req, + @Res() res: Response, + @Param('coin') coin?: string, + ) { + const response = await this.tradeService.getMyTradeData(req.user, coin); + return res.status(response.statusCode).json(response); + } - @ApiBearerAuth('access-token') - @ApiSecurity('access-token') - @UseGuards(AuthGuard) - @Delete('tradeData') - async deleteMyTrade( - @Request() req, - @Res() res: Response, - @Query('tradeId') tradeId: Number, - @Query('tradeType') tradeType: string, - ){ - try { - console.log("tradeId : "+tradeId) - console.log("tradeType : "+tradeType) - let response; - if(tradeType === 'buy') response = await this.tradeService.deleteMyBidTrade(req.user, tradeId); - else response = await this.tradeService.deleteMyAskTrade(req.user, tradeId); + @Delete('tradeData') + async deleteMyTrade( + @Request() req, + @Res() res: Response, + @Query('tradeId') tradeId: Number, + @Query('tradeType') tradeType: string, + ) { + try { + let response; + if (tradeType === 'buy') + response = await this.tradeService.deleteMyBidTrade(req.user, tradeId); + else + response = await this.tradeService.deleteMyAskTrade(req.user, tradeId); - return res.status(response.statusCode).json(response); - } catch (error) { - return res.status(error.status).json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - }); - } - } + return res.status(response.statusCode).json(response); + } catch (error) { + return res.status(error.status).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); + } + } } diff --git a/packages/server/src/trade/trade.service.ts b/packages/server/src/trade/trade.service.ts index 76cbb4a2..bc1393db 100644 --- a/packages/server/src/trade/trade.service.ts +++ b/packages/server/src/trade/trade.service.ts @@ -176,6 +176,23 @@ export class TradeService { await this.tradeRepository.deleteTrade(tradeId, queryRunner); + const userAccount = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + + const userAsset = await this.assetRepository.findOne({ + where: { + account: { id: userAccount.id }, + assetName: trade.tradeCurrency, + }, + }); + + userAsset.availableQuantity = parseFloat( + (userAsset.availableQuantity + trade.quantity).toFixed(8), + ); + + this.assetRepository.updateAssetAvailableQuantity(userAsset, queryRunner); + await queryRunner.commitTransaction(); return { @@ -183,6 +200,7 @@ export class TradeService { message: '거래가 성공적으로 취소되었습니다.', }; } catch (error) { + console.log(error); await queryRunner.rollbackTransaction(); throw new UnprocessableEntityException({ statusCode: 422, From 1a2c9e31c168e731ea1c6c614bcb5c5453dd3b38 Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Tue, 26 Nov 2024 13:38:03 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20lock=20timeout=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/trade/trade-bid.service.ts | 5 ----- packages/server/src/trade/trade.repository.ts | 9 ++++++--- packages/server/src/trade/trade.service.ts | 9 +-------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/server/src/trade/trade-bid.service.ts b/packages/server/src/trade/trade-bid.service.ts index 804ddbd3..5ff71ca0 100644 --- a/packages/server/src/trade/trade-bid.service.ts +++ b/packages/server/src/trade/trade-bid.service.ts @@ -127,10 +127,6 @@ export class BidService implements OnModuleInit { }); bidDto.accountBalance = account[typeGiven]; bidDto.account = account; - // const tradeData = await this.tradeRepository.findOne({ - // where: { tradeId: tradeId }, - // }); - // if (!tradeData) break; const result = await this.executeTrade(bidDto, order); if (!result) break; } @@ -175,7 +171,6 @@ export class BidService implements OnModuleInit { buyData, queryRunner, ); - const asset = await this.assetRepository.findOne({ where: { account: { id: account.id }, assetName: typeReceived }, }); diff --git a/packages/server/src/trade/trade.repository.ts b/packages/server/src/trade/trade.repository.ts index 896bdcf5..dc5e4a46 100644 --- a/packages/server/src/trade/trade.repository.ts +++ b/packages/server/src/trade/trade.repository.ts @@ -55,7 +55,7 @@ export class TradeRepository extends Repository { .where('tradeId = :tradeId', { tradeId: tradeData.tradeId }) .execute(); } - async deleteTrade(tradeId: number, queryRunner: QueryRunner): Promise { + async deleteTrade(tradeId: number, queryRunner){ try { const trade = await queryRunner.manager.findOne(Trade, { where: { tradeId }, @@ -134,8 +134,11 @@ export class TradeRepository extends Repository { async getTradeFindOne(tradeId, queryRunner) { const tradeData = await queryRunner.manager.findOne(Trade, { where: { tradeId: tradeId }, - lock: { mode: 'pessimistic_read' }, + lock: { + mode: 'pessimistic_write', + timeout: 0, + } }); - return tradeData + return tradeData } } diff --git a/packages/server/src/trade/trade.service.ts b/packages/server/src/trade/trade.service.ts index bc1393db..37424d60 100644 --- a/packages/server/src/trade/trade.service.ts +++ b/packages/server/src/trade/trade.service.ts @@ -106,13 +106,7 @@ export class TradeService { await queryRunner.startTransaction('READ COMMITTED'); try { - // 미체결 거래를 검색 - const trade = await this.tradeRepository.findOne({ - where: { - tradeId, - user: { id: user.userId }, - }, - }); + const trade = await this.tradeRepository.getTradeFindOne(tradeId,queryRunner) if (!trade) { throw new UnprocessableEntityException({ statusCode: 422, @@ -137,7 +131,6 @@ export class TradeService { userAccount.id, queryRunner, ); - await queryRunner.commitTransaction(); return { From 691de70cb7b576d51e5ce22f5cc4ed8211565e28 Mon Sep 17 00:00:00 2001 From: SeungGwa123 Date: Tue, 26 Nov 2024 14:24:01 +0900 Subject: [PATCH 08/13] log: winston logger --- package.json | 1 + yarn.lock | 437 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 435 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 17d9fd3b..59b266a6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "client": "yarn workspace client" }, "devDependencies": { + "@types/winston": "^2.4.4", "eslint": "^8.0.0", "eslint-config-prettier": "^9.1.0", "prettier": "^3.3.3", diff --git a/yarn.lock b/yarn.lock index 18f5f62b..5be556a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -477,6 +477,13 @@ __metadata: languageName: node linkType: hard +"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0": + version: 1.6.0 + resolution: "@colors/colors@npm:1.6.0" + checksum: 10c0/9328a0778a5b0db243af54455b79a69e3fb21122d6c15ef9e9fcc94881d8d17352d8b2b2590f9bdd46fac5c2d6c1636dcfc14358a20c70e22daf89e1a759b629 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -486,6 +493,17 @@ __metadata: languageName: node linkType: hard +"@dabh/diagnostics@npm:^2.0.2": + version: 2.0.3 + resolution: "@dabh/diagnostics@npm:2.0.3" + dependencies: + colorspace: "npm:1.1.x" + enabled: "npm:2.0.x" + kuler: "npm:^2.0.0" + checksum: 10c0/a5133df8492802465ed01f2f0a5784585241a1030c362d54a602ed1839816d6c93d71dde05cf2ddb4fd0796238c19774406bd62fa2564b637907b495f52425fe + languageName: node + linkType: hard + "@emotion/is-prop-valid@npm:^0.8.2": version: 0.8.8 resolution: "@emotion/is-prop-valid@npm:0.8.8" @@ -1445,6 +1463,16 @@ __metadata: languageName: node linkType: hard +"@nestjs/passport@npm:^10.0.3": + version: 10.0.3 + resolution: "@nestjs/passport@npm:10.0.3" + peerDependencies: + "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + checksum: 10c0/9e8a6103407852951625e75d0abd82a0f9786d4f27fc7036731ccbac39cbdb4e597a7313e53a266bb1fe1ec36c5193365abeb3264f5d285ba0aaeb23ee8e3f1b + languageName: node + linkType: hard + "@nestjs/platform-express@npm:^10.0.0": version: 10.4.8 resolution: "@nestjs/platform-express@npm:10.4.8" @@ -2387,6 +2415,65 @@ __metadata: languageName: node linkType: hard +"@types/oauth@npm:*": + version: 0.9.6 + resolution: "@types/oauth@npm:0.9.6" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/2f3e4ee1059fd28fc2cb6dd9d0973365a0630ea1fa305ac5455ea9666220b73d8ac42e5bee42367a0f12a1041ef103a16c55bf7803d0a82898319c3e32095b4a + languageName: node + linkType: hard + +"@types/passport-google-oauth20@npm:^2": + version: 2.0.16 + resolution: "@types/passport-google-oauth20@npm:2.0.16" + dependencies: + "@types/express": "npm:*" + "@types/passport": "npm:*" + "@types/passport-oauth2": "npm:*" + checksum: 10c0/59b044d4227e5481972ec8734dd826e1cf88673905e14e09fb21e061764796eb543b070e679a2077f69ae3e35ede265b91ae23f9975cad6085b72ae4272ae928 + languageName: node + linkType: hard + +"@types/passport-kakao@npm:^1": + version: 1.0.3 + resolution: "@types/passport-kakao@npm:1.0.3" + dependencies: + "@types/express": "npm:*" + "@types/passport": "npm:*" + checksum: 10c0/35eed8f4a46bdbf4fee2494b6b813c15fb7a6ab833f65fbd15779e5581348e9baf38713155713905f3c4514ecf279cfcbddeaaa2d893941350e932ccec3d36ad + languageName: node + linkType: hard + +"@types/passport-oauth2@npm:*": + version: 1.4.17 + resolution: "@types/passport-oauth2@npm:1.4.17" + dependencies: + "@types/express": "npm:*" + "@types/oauth": "npm:*" + "@types/passport": "npm:*" + checksum: 10c0/f00c671f93c66c07f871f12257280a1eaf9f95f119694018d03005c3dd35ac44aff2854c65b6cd5d3ea9e19658a27dcf5e9ad43d0742a1237a7876658a94584e + languageName: node + linkType: hard + +"@types/passport@npm:*": + version: 1.0.17 + resolution: "@types/passport@npm:1.0.17" + dependencies: + "@types/express": "npm:*" + checksum: 10c0/09039429a9178117a80880c4e7d437abc83216eac5e0c97bc6f14a03a59193386cff484931dc880693f8b13a512c366ef7a51ecd8cc1a63f17366be68161f633 + languageName: node + linkType: hard + +"@types/passport@npm:^0": + version: 0.4.7 + resolution: "@types/passport@npm:0.4.7" + dependencies: + "@types/express": "npm:*" + checksum: 10c0/58ca21800b7910385961b7a3dc9071fc9db6223242b96ff88d16c9d004ce7173524e7c17c63363595565af3f85ad932ee301efc8cc3e378bea172eebc9e07703 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.13 resolution: "@types/prop-types@npm:15.7.13" @@ -2537,6 +2624,13 @@ __metadata: languageName: node linkType: hard +"@types/triple-beam@npm:^1.3.2": + version: 1.3.5 + resolution: "@types/triple-beam@npm:1.3.5" + checksum: 10c0/d5d7f25da612f6d79266f4f1bb9c1ef8f1684e9f60abab251e1261170631062b656ba26ff22631f2760caeafd372abc41e64867cde27fba54fafb73a35b9056a + languageName: node + linkType: hard + "@types/validator@npm:^13.11.8": version: 13.12.2 resolution: "@types/validator@npm:13.12.2" @@ -2544,6 +2638,15 @@ __metadata: languageName: node linkType: hard +"@types/winston@npm:^2.4.4": + version: 2.4.4 + resolution: "@types/winston@npm:2.4.4" + dependencies: + winston: "npm:*" + checksum: 10c0/8b967c089ba71773cca671b76aba931b2849afb3a6c03cbc5ef04d19e44092e0957573c730cdfbdda71eeb44984d3554e7edb88391c2a3c1aa36cafadc8f2b87 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.13": version: 8.5.13 resolution: "@types/ws@npm:8.5.13" @@ -3422,6 +3525,13 @@ __metadata: languageName: node linkType: hard +"base64url@npm:3.x.x": + version: 3.0.1 + resolution: "base64url@npm:3.0.1" + checksum: 10c0/5ca9d6064e9440a2a45749558dddd2549ca439a305793d4f14a900b7256b5f4438ef1b7a494e1addc66ced5d20f5c010716d353ed267e4b769e6c78074991241 + languageName: node + linkType: hard + "bcrypt-pbkdf@npm:^1.0.2": version: 1.0.2 resolution: "bcrypt-pbkdf@npm:1.0.2" @@ -3987,6 +4097,15 @@ __metadata: languageName: node linkType: hard +"color-convert@npm:^1.9.3": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -3996,13 +4115,50 @@ __metadata: languageName: node linkType: hard -"color-name@npm:~1.1.4": +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 + languageName: node + linkType: hard + +"color-name@npm:^1.0.0, color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 languageName: node linkType: hard +"color-string@npm:^1.6.0": + version: 1.9.1 + resolution: "color-string@npm:1.9.1" + dependencies: + color-name: "npm:^1.0.0" + simple-swizzle: "npm:^0.2.2" + checksum: 10c0/b0bfd74c03b1f837f543898b512f5ea353f71630ccdd0d66f83028d1f0924a7d4272deb278b9aef376cacf1289b522ac3fb175e99895283645a2dc3a33af2404 + languageName: node + linkType: hard + +"color@npm:^3.1.3": + version: 3.2.1 + resolution: "color@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.3" + color-string: "npm:^1.6.0" + checksum: 10c0/39345d55825884c32a88b95127d417a2c24681d8b57069413596d9fcbb721459ef9d9ec24ce3e65527b5373ce171b73e38dbcd9c830a52a6487e7f37bf00e83c + languageName: node + linkType: hard + +"colorspace@npm:1.1.x": + version: 1.1.4 + resolution: "colorspace@npm:1.1.4" + dependencies: + color: "npm:^3.1.3" + text-hex: "npm:1.0.x" + checksum: 10c0/af5f91ff7f8e146b96e439ac20ed79b197210193bde721b47380a75b21751d90fa56390c773bb67c0aedd34ff85091883a437ab56861c779bd507d639ba7e123 + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -4514,6 +4670,13 @@ __metadata: languageName: node linkType: hard +"enabled@npm:2.0.x": + version: 2.0.0 + resolution: "enabled@npm:2.0.0" + checksum: 10c0/3b2c2af9bc7f8b9e291610f2dde4a75cf6ee52a68f4dd585482fbdf9a55d65388940e024e56d40bb03e05ef6671f5f53021fa8b72a20e954d7066ec28166713f + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -5205,6 +5368,13 @@ __metadata: languageName: node linkType: hard +"fecha@npm:^4.2.0": + version: 4.2.3 + resolution: "fecha@npm:4.2.3" + checksum: 10c0/0e895965959cf6a22bb7b00f0bf546f2783836310f510ddf63f463e1518d4c96dec61ab33fdfd8e79a71b4856a7c865478ce2ee8498d560fe125947703c9b1cf + languageName: node + linkType: hard + "figures@npm:^3.0.0, figures@npm:^3.2.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -5232,6 +5402,15 @@ __metadata: languageName: node linkType: hard +"file-stream-rotator@npm:^0.6.1": + version: 0.6.1 + resolution: "file-stream-rotator@npm:0.6.1" + dependencies: + moment: "npm:^2.29.1" + checksum: 10c0/ebb53cc22a33b0b57457c49df96ac96d8f7bace5e495f19577b37c4d87712b5fbe3539724de384852f2f6221aa0f2045e81e1f09a991fcf190f8954ef83caca1 + languageName: node + linkType: hard + "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -5313,6 +5492,13 @@ __metadata: languageName: node linkType: hard +"fn.name@npm:1.x.x": + version: 1.1.0 + resolution: "fn.name@npm:1.1.0" + checksum: 10c0/8ad62aa2d4f0b2a76d09dba36cfec61c540c13a0fd72e5d94164e430f987a7ce6a743112bbeb14877c810ef500d1f73d7f56e76d029d2e3413f20d79e3460a9a + languageName: node + linkType: hard + "follow-redirects@npm:^1.15.6": version: 1.15.9 resolution: "follow-redirects@npm:1.15.9" @@ -6010,6 +6196,13 @@ __metadata: languageName: node linkType: hard +"is-arrayish@npm:^0.3.1": + version: 0.3.2 + resolution: "is-arrayish@npm:0.3.2" + checksum: 10c0/f59b43dc1d129edb6f0e282595e56477f98c40278a2acdc8b0a5c57097c9eff8fe55470493df5775478cf32a4dc8eaf6d3a749f07ceee5bc263a78b2434f6a54 + languageName: node + linkType: hard + "is-binary-path@npm:~2.1.0": version: 2.1.0 resolution: "is-binary-path@npm:2.1.0" @@ -6858,6 +7051,13 @@ __metadata: languageName: node linkType: hard +"kuler@npm:^2.0.0": + version: 2.0.0 + resolution: "kuler@npm:2.0.0" + checksum: 10c0/0a4e99d92ca373f8f74d1dc37931909c4d0d82aebc94cf2ba265771160fc12c8df34eaaac80805efbda367e2795cb1f1dd4c3d404b6b1cf38aec94035b503d2d + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -7031,6 +7231,20 @@ __metadata: languageName: node linkType: hard +"logform@npm:^2.7.0": + version: 2.7.0 + resolution: "logform@npm:2.7.0" + dependencies: + "@colors/colors": "npm:1.6.0" + "@types/triple-beam": "npm:^1.3.2" + fecha: "npm:^4.2.0" + ms: "npm:^2.1.1" + safe-stable-stringify: "npm:^2.3.1" + triple-beam: "npm:^1.3.0" + checksum: 10c0/4789b4b37413c731d1835734cb799240d31b865afde6b7b3e06051d6a4127bfda9e88c99cfbf296d084a315ccbed2647796e6a56b66e725bcb268c586f57558f + languageName: node + linkType: hard + "long@npm:^5.2.1": version: 5.2.3 resolution: "long@npm:5.2.3" @@ -7438,6 +7652,13 @@ __metadata: languageName: node linkType: hard +"moment@npm:^2.29.1": + version: 2.30.1 + resolution: "moment@npm:2.30.1" + checksum: 10c0/865e4279418c6de666fca7786607705fd0189d8a7b7624e2e56be99290ac846f90878a6f602e34b4e0455c549b85385b1baf9966845962b313699e7cb847543a + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -7564,6 +7785,18 @@ __metadata: languageName: node linkType: hard +"nest-winston@npm:1.9.7": + version: 1.9.7 + resolution: "nest-winston@npm:1.9.7" + dependencies: + fast-safe-stringify: "npm:^2.1.1" + peerDependencies: + "@nestjs/common": ^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + winston: ^3.0.0 + checksum: 10c0/8d1da46a9baf6028c703db1e6b3ede5848ec56c92bfa26799ca4176807223dfd63831ecaf0aa77c427daee56f8d99112272f801f476a99461eebc8057bbe11d7 + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -7679,6 +7912,20 @@ __metadata: languageName: node linkType: hard +"oauth@npm:0.10.x": + version: 0.10.0 + resolution: "oauth@npm:0.10.0" + checksum: 10c0/76f3e186cfd76cb33e5d5d442861c86680a5c3b71b2db1b854212087532c265a69de1a2ab9db683e6c6df733e17cfc67476527b81b224a19c1917de2bc3f75fa + languageName: node + linkType: hard + +"oauth@npm:0.9.x": + version: 0.9.15 + resolution: "oauth@npm:0.9.15" + checksum: 10c0/52204f2a082850efca7e8406e6c6085d89318dc8a85f5a8d6c5594921da36149eb6228bba324af8e2fd9019f084d814ddf835ace6b697ced2b4be0d75f91fb30 + languageName: node + linkType: hard + "object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -7718,6 +7965,15 @@ __metadata: languageName: node linkType: hard +"one-time@npm:^1.0.0": + version: 1.0.0 + resolution: "one-time@npm:1.0.0" + dependencies: + fn.name: "npm:1.x.x" + checksum: 10c0/6e4887b331edbb954f4e915831cbec0a7b9956c36f4feb5f6de98c448ac02ff881fd8d9b55a6b1b55030af184c6b648f340a76eb211812f4ad8c9b4b8692fdaa + languageName: node + linkType: hard + "onetime@npm:^5.1.0, onetime@npm:^5.1.2": version: 5.1.2 resolution: "onetime@npm:5.1.2" @@ -7882,6 +8138,67 @@ __metadata: languageName: node linkType: hard +"passport-google-oauth20@npm:^2.0.0": + version: 2.0.0 + resolution: "passport-google-oauth20@npm:2.0.0" + dependencies: + passport-oauth2: "npm:1.x.x" + checksum: 10c0/158930bb97a48431aa0dcff453c3b698742ed51e2d590c362cb5d4ae7715cfb4fb1feae31b007aef0bc8435edc8ff678853c044b139da827756f3b5f3b597c7f + languageName: node + linkType: hard + +"passport-kakao@npm:^1.0.1": + version: 1.0.1 + resolution: "passport-kakao@npm:1.0.1" + dependencies: + passport-oauth2: "npm:~1.1.2" + pkginfo: "npm:~0.3.0" + checksum: 10c0/f9336c7fd6f82bd41c4653d3b562e2ee601899c32a9e365b5a0f76ff6e36012ad1427031807c9f2ac3f4994f312b39f843999d01f267b2eec1c28da0cea73c87 + languageName: node + linkType: hard + +"passport-oauth2@npm:1.x.x": + version: 1.8.0 + resolution: "passport-oauth2@npm:1.8.0" + dependencies: + base64url: "npm:3.x.x" + oauth: "npm:0.10.x" + passport-strategy: "npm:1.x.x" + uid2: "npm:0.0.x" + utils-merge: "npm:1.x.x" + checksum: 10c0/16b431bd856b84dfe0c9c913dcbea6ff54875befac1035171b0dce1c77f79072dc5e26d785b13c2e62c034c8174a1a47571751d1066bdbcdb9108de217c0b19b + languageName: node + linkType: hard + +"passport-oauth2@npm:~1.1.2": + version: 1.1.2 + resolution: "passport-oauth2@npm:1.1.2" + dependencies: + oauth: "npm:0.9.x" + passport-strategy: "npm:1.x.x" + uid2: "npm:0.0.x" + checksum: 10c0/0e7666988a86e0dd53458aa091bb126fd5ed99ae7db680e3a4a5e48bdd954f777e65a7a18661149088f1f049749e63f3a833208f861ad0ba4abc9a4fd93a47e8 + languageName: node + linkType: hard + +"passport-strategy@npm:1.x.x": + version: 1.0.0 + resolution: "passport-strategy@npm:1.0.0" + checksum: 10c0/cf4cd32e1bf2538a239651581292fbb91ccc83973cde47089f00d2014c24bed63d3e65af21da8ddef649a8896e089eb9c3ac9ca639f36c797654ae9ee4ed65e1 + languageName: node + linkType: hard + +"passport@npm:^0.7.0": + version: 0.7.0 + resolution: "passport@npm:0.7.0" + dependencies: + passport-strategy: "npm:1.x.x" + pause: "npm:0.0.1" + utils-merge: "npm:^1.0.1" + checksum: 10c0/08c940b86e4adbfe43e753f8097300a5a9d1ce9a3aa002d7b12d27770943a1a87202c54597c0f04dbfd4117d67de76303433577512fc19c7e364fec37b0d3fc5 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -7941,6 +8258,13 @@ __metadata: languageName: node linkType: hard +"pause@npm:0.0.1": + version: 0.0.1 + resolution: "pause@npm:0.0.1" + checksum: 10c0/f362655dfa7f44b946302c5a033148852ed5d05f744bd848b1c7eae6a543f743e79c7751ee896ba519fd802affdf239a358bb2ea5ca1b1c1e4e916279f83ab75 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -7992,6 +8316,13 @@ __metadata: languageName: node linkType: hard +"pkginfo@npm:~0.3.0": + version: 0.3.1 + resolution: "pkginfo@npm:0.3.1" + checksum: 10c0/ff3757f4e4866e9c200da7c7693893c19011c8ca0e4eddc82344ad01451b7674d069fa97cf771279ff877b3f363035f6ea7c849ff4758e44a4503867484b55fb + languageName: node + linkType: hard + "pluralize@npm:8.0.0": version: 8.0.0 resolution: "pluralize@npm:8.0.0" @@ -8420,7 +8751,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.4.0": +"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -8706,6 +9037,13 @@ __metadata: languageName: node linkType: hard +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -8809,6 +9147,7 @@ __metadata: "@nestjs/common": "npm:^10.0.0" "@nestjs/core": "npm:^10.0.0" "@nestjs/jwt": "npm:^10.2.0" + "@nestjs/passport": "npm:^10.0.3" "@nestjs/platform-express": "npm:^10.0.0" "@nestjs/platform-socket.io": "npm:^10.4.7" "@nestjs/schedule": "npm:^4.1.1" @@ -8823,9 +9162,13 @@ __metadata: "@types/jest": "npm:^29.5.2" "@types/js-yaml": "npm:^4" "@types/node": "npm:^20.3.1" + "@types/passport": "npm:^0" + "@types/passport-google-oauth20": "npm:^2" + "@types/passport-kakao": "npm:^1" "@types/socket.io": "npm:^3.0.2" "@types/supertest": "npm:^6.0.0" "@types/swagger-ui-express": "npm:^4.1.7" + "@types/winston": "npm:^2.4.4" "@types/ws": "npm:^8.5.13" "@typescript-eslint/eslint-plugin": "npm:^8.0.0" "@typescript-eslint/parser": "npm:^8.0.0" @@ -8843,6 +9186,10 @@ __metadata: jest: "npm:^29.5.0" js-yaml: "npm:^4.1.0" mysql2: "npm:^3.11.3" + nest-winston: "npm:1.9.7" + passport: "npm:^0.7.0" + passport-google-oauth20: "npm:^2.0.0" + passport-kakao: "npm:^1.0.1" prettier: "npm:^3.0.0" reflect-metadata: "npm:^0.2.0" rxjs: "npm:^7.8.1" @@ -8856,6 +9203,8 @@ __metadata: typeorm: "npm:^0.3.20" typescript: "npm:^5.6.3" uuid: "npm:^11.0.3" + winston: "npm:^3.17.0" + winston-daily-rotate-file: "npm:^5.0.0" ws: "npm:^8.18.0" languageName: unknown linkType: soft @@ -8935,6 +9284,15 @@ __metadata: languageName: node linkType: hard +"simple-swizzle@npm:^0.2.2": + version: 0.2.2 + resolution: "simple-swizzle@npm:0.2.2" + dependencies: + is-arrayish: "npm:^0.3.1" + checksum: 10c0/df5e4662a8c750bdba69af4e8263c5d96fe4cd0f9fe4bdfa3cbdeb45d2e869dff640beaaeb1ef0e99db4d8d2ec92f85508c269f50c972174851bc1ae5bd64308 + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -9125,6 +9483,13 @@ __metadata: languageName: node linkType: hard +"stack-trace@npm:0.0.x": + version: 0.0.10 + resolution: "stack-trace@npm:0.0.10" + checksum: 10c0/9ff3dabfad4049b635a85456f927a075c9d0c210e3ea336412d18220b2a86cbb9b13ec46d6c37b70a302a4ea4d49e30e5d4944dd60ae784073f1cde778ac8f4b + languageName: node + linkType: hard + "stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" @@ -9479,6 +9844,13 @@ __metadata: languageName: node linkType: hard +"text-hex@npm:1.0.x": + version: 1.0.0 + resolution: "text-hex@npm:1.0.0" + checksum: 10c0/57d8d320d92c79d7c03ffb8339b825bb9637c2cbccf14304309f51d8950015c44464b6fd1b6820a3d4821241c68825634f09f5a2d9d501e84f7c6fd14376860d + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -9573,6 +9945,13 @@ __metadata: languageName: node linkType: hard +"triple-beam@npm:^1.3.0, triple-beam@npm:^1.4.1": + version: 1.4.1 + resolution: "triple-beam@npm:1.4.1" + checksum: 10c0/4bf1db71e14fe3ff1c3adbe3c302f1fdb553b74d7591a37323a7badb32dc8e9c290738996cbb64f8b10dc5a3833645b5d8c26221aaaaa12e50d1251c9aba2fea + languageName: node + linkType: hard + "ts-api-utils@npm:^1.3.0": version: 1.4.0 resolution: "ts-api-utils@npm:1.4.0" @@ -9930,6 +10309,13 @@ __metadata: languageName: node linkType: hard +"uid2@npm:0.0.x": + version: 0.0.4 + resolution: "uid2@npm:0.0.4" + checksum: 10c0/c3ed69da75d117214891f4743a1d8521db823d7a2f57644c1a9ae8b3bf25f0ba666d893264bf7e22be3dbbaa292d35a23d71d06ce7283458a65e8dd137c5c362 + languageName: node + linkType: hard + "uid@npm:2.0.2": version: 2.0.2 resolution: "uid@npm:2.0.2" @@ -10025,7 +10411,7 @@ __metadata: languageName: node linkType: hard -"utils-merge@npm:1.0.1": +"utils-merge@npm:1.0.1, utils-merge@npm:1.x.x, utils-merge@npm:^1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" checksum: 10c0/02ba649de1b7ca8854bfe20a82f1dfbdda3fb57a22ab4a8972a63a34553cf7aa51bc9081cf7e001b035b88186d23689d69e71b510e610a09a4c66f68aa95b672 @@ -10182,6 +10568,7 @@ __metadata: "@floating-ui/dom": "npm:^1.6.12" "@nestjs/platform-socket.io": "npm:^10.4.7" "@nestjs/websockets": "npm:^10.4.7" + "@types/winston": "npm:^2.4.4" class-transformer: "npm:^0.5.1" class-validator: "npm:^0.14.1" eslint: "npm:^8.0.0" @@ -10281,6 +10668,50 @@ __metadata: languageName: node linkType: hard +"winston-daily-rotate-file@npm:^5.0.0": + version: 5.0.0 + resolution: "winston-daily-rotate-file@npm:5.0.0" + dependencies: + file-stream-rotator: "npm:^0.6.1" + object-hash: "npm:^3.0.0" + triple-beam: "npm:^1.4.1" + winston-transport: "npm:^4.7.0" + peerDependencies: + winston: ^3 + checksum: 10c0/c6dfd8ceff8d3801ca702efabaf696bb82919a62c4fbeded8cbf7d2cb188960872eb7a412547af86f12f7257061088a25bac9e318af8932719a2ccba215d7d3f + languageName: node + linkType: hard + +"winston-transport@npm:^4.7.0, winston-transport@npm:^4.9.0": + version: 4.9.0 + resolution: "winston-transport@npm:4.9.0" + dependencies: + logform: "npm:^2.7.0" + readable-stream: "npm:^3.6.2" + triple-beam: "npm:^1.3.0" + checksum: 10c0/e2990a172e754dbf27e7823772214a22dc8312f7ec9cfba831e5ef30a5d5528792e5ea8f083c7387ccfc5b2af20e3691f64738546c8869086110a26f98671095 + languageName: node + linkType: hard + +"winston@npm:*, winston@npm:^3.17.0": + version: 3.17.0 + resolution: "winston@npm:3.17.0" + dependencies: + "@colors/colors": "npm:^1.6.0" + "@dabh/diagnostics": "npm:^2.0.2" + async: "npm:^3.2.3" + is-stream: "npm:^2.0.0" + logform: "npm:^2.7.0" + one-time: "npm:^1.0.0" + readable-stream: "npm:^3.4.0" + safe-stable-stringify: "npm:^2.3.1" + stack-trace: "npm:0.0.x" + triple-beam: "npm:^1.3.0" + winston-transport: "npm:^4.9.0" + checksum: 10c0/ec8eaeac9a72b2598aedbff50b7dac82ce374a400ed92e7e705d7274426b48edcb25507d78cff318187c4fb27d642a0e2a39c57b6badc9af8e09d4a40636a5f7 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" From baeb5691615bd2db53252f1334ec9986543f38c2 Mon Sep 17 00:00:00 2001 From: SeungGwa123 Date: Tue, 26 Nov 2024 14:26:11 +0900 Subject: [PATCH 09/13] log: winston log --- packages/server/src/app.module.ts | 7 +- packages/server/src/configs/winston.config.ts | 92 +++++++++++++++++++ packages/server/src/main.ts | 6 +- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/configs/winston.config.ts diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 7b82d60f..59da362b 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -13,10 +13,12 @@ import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from './schedule/schedule.module'; import { TradehistoryModule } from './trade-history/trade-history.module'; import { FavoriteModule } from './favorite/favorite.module'; +import { winstonConfig } from './configs/winston.config'; +import { WinstonModule } from 'nest-winston'; @Module({ - imports: [ - AuthModule, + imports: [ + AuthModule, HealthModule, AccountModule, TradeModule, @@ -31,6 +33,7 @@ import { FavoriteModule } from './favorite/favorite.module'; ScheduleModule, TradehistoryModule, FavoriteModule, + WinstonModule.forRoot(winstonConfig), ], controllers: [AppController], providers: [AppService], diff --git a/packages/server/src/configs/winston.config.ts b/packages/server/src/configs/winston.config.ts new file mode 100644 index 00000000..233f1b83 --- /dev/null +++ b/packages/server/src/configs/winston.config.ts @@ -0,0 +1,92 @@ +import { utilities, WinstonModule } from 'nest-winston'; +import * as winston from 'winston'; +import * as winstonDaily from 'winston-daily-rotate-file'; +import { join } from 'path'; + +const { combine, timestamp, printf, colorize } = winston.format; + +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +} + +const colors = { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'blue', +} + +winston.addColors(colors); + +// 로그 저장 경로 +const logDir = join(__dirname, '../../logs'); + +// 로그 포맷 정의 +const logFormat = printf(({ level, message, timestamp, stack }) => { + return `${timestamp} ${level}: ${message} ${stack || ''}`; +}); + +export const winstonConfig = { + levels, + transports: [ + // 콘솔 출력 + new winston.transports.Console({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: combine( + colorize({ all: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + utilities.format.nestLike('MyApp', { + prettyPrint: true, + colors: true, + }), + ), + }), + + // info 레벨 로그 파일 + new winstonDaily({ + level: 'info', + datePattern: 'YYYY-MM-DD', + dirname: join(logDir, 'info'), + filename: `%DATE%.log`, + maxFiles: 30, + zippedArchive: true, + format: combine( + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat, + ), + }), + + // warn 레벨 로그 파일 + new winstonDaily({ + level: 'warn', + datePattern: 'YYYY-MM-DD', + dirname: join(logDir, 'warn'), + filename: `%DATE%.warn.log`, + maxFiles: 30, + zippedArchive: true, + format: combine( + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat, + ), + }), + + // error 레벨 로그 파일 + new winstonDaily({ + level: 'error', + datePattern: 'YYYY-MM-DD', + dirname: join(logDir, 'error'), + filename: `%DATE%.error.log`, + maxFiles: 30, + zippedArchive: true, + format: combine( + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat, + ), + }), + ], +}; \ No newline at end of file diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index c52f5ea2..c0b62e07 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -7,12 +7,16 @@ import { import { config } from 'dotenv'; import { setupSshTunnel } from './configs/ssh-tunnel'; import { AllExceptionsFilter } from 'common/all-exceptions.filter'; +import { WinstonModule } from 'nest-winston'; +import { winstonConfig } from './configs/winston.config'; config(); async function bootstrap() { await setupSshTunnel(); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + logger: WinstonModule.createLogger(winstonConfig), + }); app.enableCors({ origin: true, methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', From 08f5cb90ee7e39a80dd55f3d1109b99b080f4cde Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Tue, 26 Nov 2024 14:58:29 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=EB=A7=A4=EB=8F=84=20=EB=B2=84?= =?UTF-8?q?=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 --- .github/workflows/CICD.yml | 2 +- packages/server/src/asset/asset.repository.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index a5202e6f..562d5880 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -6,7 +6,7 @@ on: - main - dev - dev-be - - hotfix-be-#37 + - hotfix-be-#114 jobs: build_and_deploy: runs-on: ubuntu-latest diff --git a/packages/server/src/asset/asset.repository.ts b/packages/server/src/asset/asset.repository.ts index fc3834b9..ec4340e1 100644 --- a/packages/server/src/asset/asset.repository.ts +++ b/packages/server/src/asset/asset.repository.ts @@ -28,6 +28,7 @@ export class AssetRepository extends Repository { .update(Asset) .set({ quantity: asset.quantity, + availableQuantity: asset.availableQuantity, price: asset.price, }) .where('assetId = :assetId', { assetId: asset.assetId }) From 9a16ffd463967e0c6e2ce553b9e25419cc113eb1 Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Tue, 26 Nov 2024 15:02:59 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20package.json?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/package.json b/packages/server/package.json index cecfd906..57dc330a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -42,6 +42,7 @@ "ioredis": "^5.4.1", "js-yaml": "^4.1.0", "mysql2": "^3.11.3", + "nest-winston": "1.9.7", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-kakao": "^1.0.1", @@ -50,6 +51,7 @@ "tunnel-ssh": "^5.1.2", "typeorm": "^0.3.20", "uuid": "^11.0.3", + "winston": "^3.17.0", "ws": "^8.18.0" }, "devDependencies": { From 6f0518f286a6f1466a643a81946f7b6e328e9cec Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Tue, 26 Nov 2024 15:06:19 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/package.json b/packages/server/package.json index 57dc330a..0d0244e0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -52,6 +52,7 @@ "typeorm": "^0.3.20", "uuid": "^11.0.3", "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0", "ws": "^8.18.0" }, "devDependencies": { From 1ad5fd2055d1d52982fd272a71ddc7d5a774fe69 Mon Sep 17 00:00:00 2001 From: Seonghyeon0409 Date: Tue, 26 Nov 2024 20:06:09 +0900 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20upbit=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B1=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CICD.yml | 1 - .../server/src/account/account.service.ts | 2 +- packages/server/src/configs/winston.config.ts | 130 +++--- .../server/src/trade/trade-ask.service.ts | 2 +- .../server/src/trade/trade-bid.service.ts | 2 +- packages/server/src/trade/trade.service.ts | 2 +- .../src/upbit/SSE/base-web-socket.service.ts | 60 +++ .../SSE/coin-ticker-websocket.service.ts | 50 +++ .../upbit/SSE/orderbook-websocket.service.ts | 50 +++ packages/server/src/upbit/SSE/sse.service.ts | 71 +++ packages/server/src/upbit/chart.service.ts | 418 +++++++++--------- .../src/upbit/coin-data-updater.service.ts | 281 +++++++----- .../server/src/upbit/coin-list.service.ts | 305 +++++++------ .../coin-ticker-websocket.service.spec.ts | 18 - .../upbit/coin-ticker-websocket.service.ts | 88 ---- .../upbit.ts => src/upbit/constants.ts} | 0 .../server/src/upbit/dtos/coin-ticker.dto.ts | 2 +- .../upbit/orderbook-websocket.service.spec.ts | 18 - .../src/upbit/orderbook-websocket.service.ts | 87 ---- packages/server/src/upbit/sse.service.ts | 79 ---- .../server/src/upbit/upbit.controller.spec.ts | 18 - packages/server/src/upbit/upbit.controller.ts | 218 +++++---- packages/server/src/upbit/upbit.module.ts | 13 +- packages/server/src/upbit/utils/validation.ts | 5 + 24 files changed, 970 insertions(+), 950 deletions(-) create mode 100644 packages/server/src/upbit/SSE/base-web-socket.service.ts create mode 100644 packages/server/src/upbit/SSE/coin-ticker-websocket.service.ts create mode 100644 packages/server/src/upbit/SSE/orderbook-websocket.service.ts create mode 100644 packages/server/src/upbit/SSE/sse.service.ts delete mode 100644 packages/server/src/upbit/coin-ticker-websocket.service.spec.ts delete mode 100644 packages/server/src/upbit/coin-ticker-websocket.service.ts rename packages/server/{common/upbit.ts => src/upbit/constants.ts} (100%) delete mode 100644 packages/server/src/upbit/orderbook-websocket.service.spec.ts delete mode 100644 packages/server/src/upbit/orderbook-websocket.service.ts delete mode 100644 packages/server/src/upbit/sse.service.ts delete mode 100644 packages/server/src/upbit/upbit.controller.spec.ts create mode 100644 packages/server/src/upbit/utils/validation.ts diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 562d5880..623f5d8b 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -6,7 +6,6 @@ on: - main - dev - dev-be - - hotfix-be-#114 jobs: build_and_deploy: runs-on: ubuntu-latest diff --git a/packages/server/src/account/account.service.ts b/packages/server/src/account/account.service.ts index 7234c838..3bca664f 100644 --- a/packages/server/src/account/account.service.ts +++ b/packages/server/src/account/account.service.ts @@ -1,6 +1,6 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { AssetRepository } from '@src/asset/asset.repository'; -import { UPBIT_IMAGE_URL } from 'common/upbit'; +import { UPBIT_IMAGE_URL } from '@src/upbit/constants'; import { AccountRepository } from 'src/account/account.repository'; import { MyAccountDto } from './dtos/myAccount.dto'; import { CoinDataUpdaterService } from '@src/upbit/coin-data-updater.service'; diff --git a/packages/server/src/configs/winston.config.ts b/packages/server/src/configs/winston.config.ts index 233f1b83..cf34db26 100644 --- a/packages/server/src/configs/winston.config.ts +++ b/packages/server/src/configs/winston.config.ts @@ -6,87 +6,79 @@ import { join } from 'path'; const { combine, timestamp, printf, colorize } = winston.format; const levels = { - error: 0, - warn: 1, - info: 2, - http: 3, - debug: 4, -} + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +}; const colors = { - error: 'red', - warn: 'yellow', - info: 'green', - http: 'magenta', - debug: 'blue', -} + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'blue', +}; winston.addColors(colors); // 로그 저장 경로 const logDir = join(__dirname, '../../logs'); - // 로그 포맷 정의 const logFormat = printf(({ level, message, timestamp, stack }) => { - return `${timestamp} ${level}: ${message} ${stack || ''}`; + return `${timestamp} ${level}: ${message} ${stack || ''}`; +}); + +const consoleFormat = printf(({ level, message, timestamp, context }) => { + const contextColored = context ? `\x1b[33m[${context}]\x1b[0m` : '[App]'; + return `[Nest] ${process.pid} - ${timestamp} ${level} ${contextColored} ${message}`; }); export const winstonConfig = { - levels, - transports: [ - // 콘솔 출력 - new winston.transports.Console({ - level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', - format: combine( - colorize({ all: true }), - timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - utilities.format.nestLike('MyApp', { - prettyPrint: true, - colors: true, - }), - ), - }), + levels, + transports: [ + // 콘솔 출력 + new winston.transports.Console({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: combine( + colorize({ all: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + consoleFormat + ), + }), - // info 레벨 로그 파일 - new winstonDaily({ - level: 'info', - datePattern: 'YYYY-MM-DD', - dirname: join(logDir, 'info'), - filename: `%DATE%.log`, - maxFiles: 30, - zippedArchive: true, - format: combine( - timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - logFormat, - ), - }), + // info 레벨 로그 파일 + new winstonDaily({ + level: 'info', + datePattern: 'YYYY-MM-DD', + dirname: join(logDir, 'info'), + filename: `%DATE%.log`, + maxFiles: 30, + zippedArchive: true, + format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat), + }), - // warn 레벨 로그 파일 - new winstonDaily({ - level: 'warn', - datePattern: 'YYYY-MM-DD', - dirname: join(logDir, 'warn'), - filename: `%DATE%.warn.log`, - maxFiles: 30, - zippedArchive: true, - format: combine( - timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - logFormat, - ), - }), + // warn 레벨 로그 파일 + new winstonDaily({ + level: 'warn', + datePattern: 'YYYY-MM-DD', + dirname: join(logDir, 'warn'), + filename: `%DATE%.warn.log`, + maxFiles: 30, + zippedArchive: true, + format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat), + }), - // error 레벨 로그 파일 - new winstonDaily({ - level: 'error', - datePattern: 'YYYY-MM-DD', - dirname: join(logDir, 'error'), - filename: `%DATE%.error.log`, - maxFiles: 30, - zippedArchive: true, - format: combine( - timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - logFormat, - ), - }), - ], -}; \ No newline at end of file + // error 레벨 로그 파일 + new winstonDaily({ + level: 'error', + datePattern: 'YYYY-MM-DD', + dirname: join(logDir, 'error'), + filename: `%DATE%.error.log`, + maxFiles: 30, + zippedArchive: true, + format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat), + }), + ], +}; diff --git a/packages/server/src/trade/trade-ask.service.ts b/packages/server/src/trade/trade-ask.service.ts index 11799948..1702c29b 100644 --- a/packages/server/src/trade/trade-ask.service.ts +++ b/packages/server/src/trade/trade-ask.service.ts @@ -10,7 +10,7 @@ import { AssetRepository } from 'src/asset/asset.repository'; import { TradeRepository } from './trade.repository'; import { CoinDataUpdaterService } from 'src/upbit/coin-data-updater.service'; import { TradeHistoryRepository } from '../trade-history/trade-history.repository'; -import { UPBIT_UPDATED_COIN_INFO_TIME } from 'common/upbit'; +import { UPBIT_UPDATED_COIN_INFO_TIME } from '@src/upbit/constants'; import { UserRepository } from '@src/auth/user.repository'; @Injectable() diff --git a/packages/server/src/trade/trade-bid.service.ts b/packages/server/src/trade/trade-bid.service.ts index 5ff71ca0..04258ecd 100644 --- a/packages/server/src/trade/trade-bid.service.ts +++ b/packages/server/src/trade/trade-bid.service.ts @@ -11,7 +11,7 @@ import { AssetRepository } from 'src/asset/asset.repository'; import { TradeRepository } from './trade.repository'; import { CoinDataUpdaterService } from 'src/upbit/coin-data-updater.service'; import { TradeHistoryRepository } from '../trade-history/trade-history.repository'; -import { UPBIT_UPDATED_COIN_INFO_TIME } from 'common/upbit'; +import { UPBIT_UPDATED_COIN_INFO_TIME } from '@src/upbit/constants'; import { UserRepository } from '@src/auth/user.repository'; @Injectable() diff --git a/packages/server/src/trade/trade.service.ts b/packages/server/src/trade/trade.service.ts index 37424d60..bfaf60c7 100644 --- a/packages/server/src/trade/trade.service.ts +++ b/packages/server/src/trade/trade.service.ts @@ -4,7 +4,7 @@ import { AccountRepository } from 'src/account/account.repository'; import { AssetRepository } from 'src/asset/asset.repository'; import { TradeRepository } from './trade.repository'; import { CoinDataUpdaterService } from 'src/upbit/coin-data-updater.service'; -import { UPBIT_IMAGE_URL } from 'common/upbit'; +import { UPBIT_IMAGE_URL } from '@src/upbit/constants'; import { TradeDataDto } from './dtos/tradeData.dto'; @Injectable() export class TradeService { diff --git a/packages/server/src/upbit/SSE/base-web-socket.service.ts b/packages/server/src/upbit/SSE/base-web-socket.service.ts new file mode 100644 index 00000000..cd50b648 --- /dev/null +++ b/packages/server/src/upbit/SSE/base-web-socket.service.ts @@ -0,0 +1,60 @@ +import { Logger } from '@nestjs/common'; +import * as WebSocket from 'ws'; + +export abstract class BaseWebSocketService { + private websocket: WebSocket; + private sending: boolean = false; + protected readonly logger: Logger; + + constructor(context: string) { + this.logger = new Logger(context); // 동적으로 context 설정 + } + + protected abstract handleMessage(data: any): void; + + protected connectWebSocket(websocketUrl: string, reconnectInterval: number): void { + this.websocket = new WebSocket(websocketUrl); + + this.websocket.on('open', () => { + this.logger.log('WebSocket 연결이 열렸습니다.'); + this.sendWebSocket(); + }); + + this.websocket.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.handleMessage(message); + } catch (error) { + this.logger.error('WebSocket 메시지 처리 중 오류:', error); + } + }); + + this.websocket.on('close', () => { + this.logger.warn('WebSocket 연결이 닫혔습니다. 재연결 시도 중...'); + setTimeout(() => this.connectWebSocket(websocketUrl, reconnectInterval), reconnectInterval); + }); + + this.websocket.on('error', (error) => { + this.logger.error('WebSocket 오류:', error); + }); + } + + protected async sendWebSocket(): Promise { + if (this.sending) return; + this.sending = true; + + try { + if (this.websocket.readyState !== WebSocket.OPEN) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const message = this.getSubscribeMessage(); + this.websocket.send(message); + } catch (error) { + this.logger.error('WebSocket 메시지 전송 중 오류:', error); + } finally { + this.sending = false; + } + } + + protected abstract getSubscribeMessage(): string; +} diff --git a/packages/server/src/upbit/SSE/coin-ticker-websocket.service.ts b/packages/server/src/upbit/SSE/coin-ticker-websocket.service.ts new file mode 100644 index 00000000..a599833b --- /dev/null +++ b/packages/server/src/upbit/SSE/coin-ticker-websocket.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { SseService } from './sse.service'; +import { CoinListService } from '../coin-list.service'; +import { + UPBIT_WEBSOCKET_URL, + UPBIT_WEBSOCKET_CONNECTION_TIME, +} from '@src/upbit/constants'; +import { BaseWebSocketService } from './base-web-socket.service'; +import { CoinDataUpdaterService } from '../coin-data-updater.service'; + +@Injectable() +export class CoinTickerService + extends BaseWebSocketService + implements OnModuleInit +{ + constructor( + private readonly coinListService: CoinListService, + private readonly coinDataUpdaterService: CoinDataUpdaterService, + private readonly sseService: SseService, + ) { + super(CoinTickerService.name); + } + + async onModuleInit() { + await this.ensureCoinDataInitialized(); + this.connectWebSocket(UPBIT_WEBSOCKET_URL, UPBIT_WEBSOCKET_CONNECTION_TIME); + } + + private async ensureCoinDataInitialized(): Promise { + if (this.coinListService.getCoinNameList().length === 1) { + await this.coinDataUpdaterService.updateCoinList(); + } + } + + protected handleMessage(data: any) { + if (data.error) { + console.error('CoinTicker WebSocket 오류:', data); + return; + } + this.sseService.sendEvent('price', data); + } + + protected getSubscribeMessage(): string { + const coinList = this.coinListService.getCoinNameList(); + return JSON.stringify([ + { ticket: 'test' }, + { type: 'ticker', codes: coinList }, + ]); + } +} diff --git a/packages/server/src/upbit/SSE/orderbook-websocket.service.ts b/packages/server/src/upbit/SSE/orderbook-websocket.service.ts new file mode 100644 index 00000000..e572fb90 --- /dev/null +++ b/packages/server/src/upbit/SSE/orderbook-websocket.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { SseService } from './sse.service'; +import { CoinListService } from '../coin-list.service'; +import { + UPBIT_WEBSOCKET_URL, + UPBIT_WEBSOCKET_CONNECTION_TIME, +} from '@src/upbit/constants'; +import { BaseWebSocketService } from './base-web-socket.service'; +import { CoinDataUpdaterService } from '../coin-data-updater.service'; + +@Injectable() +export class OrderbookService + extends BaseWebSocketService + implements OnModuleInit +{ + constructor( + private readonly coinListService: CoinListService, + private readonly coinDataUpdaterService: CoinDataUpdaterService, + private readonly sseService: SseService, + ) { + super(OrderbookService.name); + } + + async onModuleInit() { + await this.ensureCoinDataInitialized(); + this.connectWebSocket(UPBIT_WEBSOCKET_URL, UPBIT_WEBSOCKET_CONNECTION_TIME); + } + + private async ensureCoinDataInitialized(): Promise { + if (this.coinListService.getCoinNameList().length === 1) { + await this.coinDataUpdaterService.updateCoinList(); + } + } + + protected handleMessage(data: any) { + if (data.error) { + this.logger.error('Orderbook WebSocket 오류:', data); + return; + } + this.sseService.sendEvent('orderbook', data); + } + + protected getSubscribeMessage(): string { + const coinList = this.coinListService.getCoinNameList(); + return JSON.stringify([ + { ticket: 'test' }, + { type: 'orderbook', codes: coinList }, + ]); + } +} diff --git a/packages/server/src/upbit/SSE/sse.service.ts b/packages/server/src/upbit/SSE/sse.service.ts new file mode 100644 index 00000000..dce134a8 --- /dev/null +++ b/packages/server/src/upbit/SSE/sse.service.ts @@ -0,0 +1,71 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { Subject, Observable } from 'rxjs'; +import { map, takeUntil, filter } from 'rxjs/operators'; +import { CoinDataUpdaterService } from '../coin-data-updater.service'; + +@Injectable() +export class SseService implements OnModuleDestroy { + private streams = new Map>(); + private destroy$ = new Subject(); + + constructor( + private readonly coinDataUpdaterService: CoinDataUpdaterService, + ) { + this.streams.set('price', new Subject()); + this.streams.set('orderbook', new Subject()); + } + + sendEvent(type: 'price' | 'orderbook', data: any) { + const stream = this.streams.get(type); + if (stream) { + stream.next(data); + } + } + + async initStream(coins: string[], dto: (data: any) => any): Promise { + const coinData = await Promise.all( + coins.map(async (coin) => { + let coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); + + while (!coinLatestInfo.size || !coinLatestInfo.get(coin)) { + await new Promise((resolve) => setTimeout(resolve, 100)); + coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); + } + + const initData = coinLatestInfo.get(coin); + return new MessageEvent('price-update', { + data: JSON.stringify(dto(initData)), + }); + }), + ); + + return coinData; + } + + getUpdatesStream( + type: 'price' | 'orderbook', + coins: string[], + dto: (data: any) => any, + ): Observable { + const stream = this.streams.get(type); + if (!stream) { + throw new Error(`Stream for type ${type} not found`); + } + + return stream.asObservable().pipe( + takeUntil(this.destroy$), + filter((data) => coins.includes(data.code)), + map((data) => { + return new MessageEvent(`${type}-update`, { + data: JSON.stringify(dto(data)), + }); + }), + ); + } + + onModuleDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + this.streams.forEach((stream) => stream.complete()); + } +} diff --git a/packages/server/src/upbit/chart.service.ts b/packages/server/src/upbit/chart.service.ts index 8fb24b90..9eac9f76 100644 --- a/packages/server/src/upbit/chart.service.ts +++ b/packages/server/src/upbit/chart.service.ts @@ -1,206 +1,228 @@ -import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; import { HttpService } from '@nestjs/axios'; -import { ONE_SECOND, UPBIT_CANDLE_URL, UPBIT_REQUEST_SIZE } from 'common/upbit'; +import { ONE_SECOND, UPBIT_CANDLE_URL, UPBIT_REQUEST_SIZE } from '@src/upbit/constants'; import { CandleDto } from './dtos/candle.dto'; import { RedisRepository } from '@src/redis/redis.repository'; +import { isValidMinute } from './utils/validation'; @Injectable() export class ChartService implements OnModuleInit { - private upbitApiQueue; - - constructor( - private readonly httpService: HttpService, - private redisRepository: RedisRepository, - ) {} - onModuleInit() { - this.upbitApiQueue = []; - this.cleanQueue(); - } - async upbitApiDoor(type, coin, to, minute) { - const validMinutes = ['1', '3', '5', '10', '15', '30', '60', '240']; - if (type === 'minutes') { - if (!minute || !validMinutes.includes(minute)) { - throw new BadRequestException(); - } - } - if (!to) { - const now = new Date(); - now.setHours(now.getHours() + 9); - to = now.toISOString().slice(0, 19); - } - const key = await this.getAllKeys(coin, to, type, minute); - const dbcheck = await this.redisRepository.getChartDate(key); - if (dbcheck.length === 200) { - return { - statusCode: 200, - result: dbcheck, - }; - } - - const result = await this.waitForTransactionOrder(key); - if (result) { - return { - statusCode: 200, - result: result, - }; - } - try { - this.upbitApiQueue.push(Date.now()); - const url = - type === 'minutes' - ? `${UPBIT_CANDLE_URL}${type}/${minute}?market=${coin}&count=200&to=${to}` - : `${UPBIT_CANDLE_URL}${type}?market=${coin}&count=200&to=${to}`; - const response = await firstValueFrom(this.httpService.get(url)); - if (response.data.error) console.error(response); - const candle: CandleDto = response.data; - this.saveChartData(candle, type, minute); - return { - statusCode: 200, - result: candle, - }; - } catch (error) { - console.error('updateApiDoor Error : ' + error); - return error; - } - } - async waitForTransactionOrder(key, maxRetries = 100) { - // 10초 타임아웃 - return new Promise(async (resolve, reject) => { - let retryCount = 0; - const check = async () => { - try { - const dbcheck = await this.redisRepository.getChartDate(key); - if (dbcheck.length === 200) { - return resolve(dbcheck); - } - const queueSize = this.upbitApiQueue.length; - if ( - queueSize < UPBIT_REQUEST_SIZE || - this.upbitApiQueue[queueSize - 1] - Date.now() < -ONE_SECOND - ) { - return resolve(false); - } - if (retryCount++ >= maxRetries) { - return reject(new Error('Timeout waiting for transaction order')); - } - setTimeout(check, 100); - } catch (error) { - reject(error); - } - }; - check(); - }); - } - async saveChartData(candles, type, minute) { - try { - const savePromises = candles.map((candle) => { - const key = this.getRedisKey( - candle.market, - candle.candle_date_time_kst, - type, - minute, - ); - return this.redisRepository.setChartData(key, JSON.stringify(candle)); - }); - - await Promise.all(savePromises); - } catch (error) { - console.error('saveChartData Error :', error); - throw error; - } - } - - getRedisKey(market, kst, type, minute = null) { - const formattedDateTime = kst.replace(/[-T]/g, ':'); - const parts = formattedDateTime.split(':'); - - const keyFormats = { - years: () => `${market}:${parts[0]}`, - months: () => `${market}:${parts[0]}:${parts[1]}`, - days: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}`, - weeks: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}:W`, - minutes: () => { - return `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${minute}M`; - }, - seconds: () => - `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${parts[5]}`, - }; - - const formatFn = keyFormats[type]; - if (!formatFn) { - throw new Error(`Invalid type: ${type}`); - } - - return formatFn(); - } - - formatNumber(num) { - return String(num).padStart(2, '0'); - } - - formatDate(date, type, market, minute = null) { - const year = date.getFullYear(); - const month = this.formatNumber(date.getMonth() + 1); - const day = this.formatNumber(date.getDate()); - const hours = this.formatNumber(date.getHours()); - const minutes = this.formatNumber(date.getMinutes()); - const seconds = this.formatNumber(date.getSeconds()); - - const formats = { - years: () => `${year}`, - months: () => `${year}:${month}`, - days: () => `${year}:${month}:${day}`, - weeks: () => `${year}:${month}:${day}:W`, - minutes: () => { - return `${year}:${month}:${day}:${hours}:${minutes}:${minute}M`; - }, - seconds: () => `${year}:${month}:${day}:${hours}:${minutes}:${seconds}`, - }; - - if (!formats[type]) { - throw new Error(`Invalid type: ${type}`); - } - - return `${market}:${formats[type]()}`; - } - - decrementDate(date, type) { - const decrementFunctions = { - years: () => date.setFullYear(date.getFullYear() - 1), - months: () => date.setMonth(date.getMonth() - 1), - weeks: () => date.setDate(date.getDate() - 7), - days: () => date.setDate(date.getDate() - 1), - minutes: () => date.setMinutes(date.getMinutes() - 1), - seconds: () => date.setSeconds(date.getSeconds() - 1), - }; - - if (!decrementFunctions[type]) { - throw new Error(`Invalid type: ${type}`); - } - - decrementFunctions[type](); - return date; - } - - getAllKeys(coin, to, type, minute = null, count = 200) { - const result = []; - const currentDate = new Date(to); - currentDate.setHours(currentDate.getHours() + 9); - - for (let i = 0; i < count; i++) { - result.push(this.formatDate(currentDate, type, coin, minute)); - this.decrementDate(currentDate, type); - } - return result; - } - cleanQueue() { - while ( - this.upbitApiQueue.length > 0 && - this.upbitApiQueue[0] - Date.now() < -ONE_SECOND - ) { - this.upbitApiQueue.shift(); - } - setTimeout(() => this.cleanQueue(), 100); - } + private upbitApiQueue: number[] = []; + private readonly logger = new Logger(ChartService.name); + + constructor( + private readonly httpService: HttpService, + private redisRepository: RedisRepository, + ) {} + + onModuleInit() { + this.cleanQueue(); + } + + async upbitApiDoor(type: string, coin: string, to?: string, minute?: string) { + if (type === 'minutes' && (!minute || !isValidMinute(minute))) { + throw new BadRequestException('유효하지 않은 분봉 값입니다.'); + } + + to = to || this.formatCurrentTime(); + + const key = await this.getAllKeys(coin, to, type, minute); + const dbData = await this.redisRepository.getChartDate(key); + + if (dbData.length === 200) { + return this.buildResponse(200, dbData); + } + + const result = await this.waitForTransactionOrder(key); + + if (result) { + return this.buildResponse(200, result); + } + + return this.fetchAndSaveUpbitData(type, coin, to, minute); + } + + private formatCurrentTime(): string { + const now = new Date(); + now.setHours(now.getHours() + 9); + return now.toISOString().slice(0, 19); + } + + private buildResponse(statusCode: number, result: any) { + return { statusCode, result }; + } + + private async fetchAndSaveUpbitData( + type: string, + coin: string, + to: string, + minute?: string, + ) { + try { + this.upbitApiQueue.push(Date.now()); + const url = this.buildUpbitUrl(type, coin, to, minute); + const response = await firstValueFrom(this.httpService.get(url)); + const candles: CandleDto[] = response.data; + + await this.saveChartData(candles, type, minute); + return this.buildResponse(200, candles); + } catch (error) { + this.logger.error('Error in fetchAndSaveUpbitData:', error); + throw error; + } + } + + private buildUpbitUrl( + type: string, + coin: string, + to: string, + minute?: string, + ): string { + const baseUrl = `${UPBIT_CANDLE_URL}${type}`; + const query = `market=${coin}&count=200&to=${to}`; + return type === 'minutes' + ? `${baseUrl}/${minute}?${query}` + : `${baseUrl}?${query}`; + } + + async waitForTransactionOrder(key, maxRetries = 100): Promise { + let retryCount = 0; + + return new Promise(async (resolve, reject) => { + const check = async () => { + try { + const dbData = await this.redisRepository.getChartDate(key); + if (dbData.length === 200) { + return resolve(dbData); + } + const queueSize = this.upbitApiQueue.length; + if ( + queueSize < UPBIT_REQUEST_SIZE || + this.upbitApiQueue[queueSize - 1] - Date.now() < -ONE_SECOND + ) { + return resolve(false); + } + if (retryCount++ >= maxRetries) { + return reject(new Error('Timeout waiting for transaction order')); + } + setTimeout(check, 100); + } catch (error) { + reject(error); + } + }; + check(); + }); + } + async saveChartData(candles, type, minute) { + try { + const savePromises = candles.map((candle) => { + const key = this.getRedisKey( + candle.market, + candle.candle_date_time_kst, + type, + minute, + ); + return this.redisRepository.setChartData(key, JSON.stringify(candle)); + }); + + await Promise.all(savePromises); + } catch (error) { + this.logger.error('saveChartData Error :', error); + throw error; + } + } + + getRedisKey(market, kst, type, minute = null) { + const formattedDateTime = kst.replace(/[-T]/g, ':'); + const parts = formattedDateTime.split(':'); + + const keyFormats = { + years: () => `${market}:${parts[0]}`, + months: () => `${market}:${parts[0]}:${parts[1]}`, + days: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}`, + weeks: () => `${market}:${parts[0]}:${parts[1]}:${parts[2]}:W`, + minutes: () => { + return `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${minute}M`; + }, + seconds: () => + `${market}:${parts[0]}:${parts[1]}:${parts[2]}:${parts[3]}:${parts[4]}:${parts[5]}`, + }; + + const formatFn = keyFormats[type]; + if (!formatFn) { + throw new Error(`Invalid type: ${type}`); + } + + return formatFn(); + } + + formatNumber(num) { + return String(num).padStart(2, '0'); + } + + formatDate(date, type, market, minute = null) { + const year = date.getFullYear(); + const month = this.formatNumber(date.getMonth() + 1); + const day = this.formatNumber(date.getDate()); + const hours = this.formatNumber(date.getHours()); + const minutes = this.formatNumber(date.getMinutes()); + const seconds = this.formatNumber(date.getSeconds()); + + const formats = { + years: () => `${year}`, + months: () => `${year}:${month}`, + days: () => `${year}:${month}:${day}`, + weeks: () => `${year}:${month}:${day}:W`, + minutes: () => { + return `${year}:${month}:${day}:${hours}:${minutes}:${minute}M`; + }, + seconds: () => `${year}:${month}:${day}:${hours}:${minutes}:${seconds}`, + }; + + if (!formats[type]) { + throw new Error(`Invalid type: ${type}`); + } + + return `${market}:${formats[type]()}`; + } + + decrementDate(date, type) { + const decrementFunctions = { + years: () => date.setFullYear(date.getFullYear() - 1), + months: () => date.setMonth(date.getMonth() - 1), + weeks: () => date.setDate(date.getDate() - 7), + days: () => date.setDate(date.getDate() - 1), + minutes: () => date.setMinutes(date.getMinutes() - 1), + seconds: () => date.setSeconds(date.getSeconds() - 1), + }; + + if (!decrementFunctions[type]) { + throw new Error(`Invalid type: ${type}`); + } + + decrementFunctions[type](); + return date; + } + + getAllKeys(coin, to, type, minute = null, count = 200) { + const result = []; + const currentDate = new Date(to); + currentDate.setHours(currentDate.getHours() + 9); + + for (let i = 0; i < count; i++) { + result.push(this.formatDate(currentDate, type, coin, minute)); + this.decrementDate(currentDate, type); + } + return result; + } + cleanQueue() { + while ( + this.upbitApiQueue.length > 0 && + this.upbitApiQueue[0] - Date.now() < -ONE_SECOND + ) { + this.upbitApiQueue.shift(); + } + setTimeout(() => this.cleanQueue(), 100); + } } diff --git a/packages/server/src/upbit/coin-data-updater.service.ts b/packages/server/src/upbit/coin-data-updater.service.ts index d5ce5266..481332d3 100644 --- a/packages/server/src/upbit/coin-data-updater.service.ts +++ b/packages/server/src/upbit/coin-data-updater.service.ts @@ -1,138 +1,183 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; import { - UPBIT_CURRENT_ORDERBOOK_URL, - UPBIT_CURRENT_PRICE_URL, - UPBIT_RESTAPI_URL, - UPBIT_UPDATED_COIN_INFO_TIME, - UPBIT_UPDATED_COIN_LIST_TIME, -} from 'common/upbit'; + UPBIT_CURRENT_ORDERBOOK_URL, + UPBIT_CURRENT_PRICE_URL, + UPBIT_RESTAPI_URL, + UPBIT_UPDATED_COIN_INFO_TIME, + UPBIT_UPDATED_COIN_LIST_TIME, +} from '@src/upbit/constants'; @Injectable() -export class CoinDataUpdaterService { - private coinRawList: any; - private coinCodeList: string[] = ['KRW-BTC']; - private coinNameList: Map; - private coinListTimeoutId: NodeJS.Timeout | null = null; - private coinCurrentPriceTimeoutId: NodeJS.Timeout | null = null; - private coinCurrentOrderBookTimeoutId: NodeJS.Timeout | null = null; - private coinLatestInfo = new Map(); - private krwCoinInfo: any[] = []; - private orderbookLatestInfo = new Map(); - - constructor(private readonly httpService: HttpService) {} - - async updateCoinList() { - try { - const response = await firstValueFrom( - this.httpService.get(UPBIT_RESTAPI_URL), - ); - this.coinCodeList = response.data.map((coin) => coin.market); - this.coinNameList = new Map( - response.data.map((coin) => [coin.market, coin.korean_name]), - ); - this.coinRawList = response.data; - } catch (error) { - console.error('getCoinListFromUpbit error:', error); - } finally { - console.log(`코인 목록 최신화: ${Date()}`); - if (this.coinListTimeoutId) clearTimeout(this.coinListTimeoutId); - this.coinListTimeoutId = setTimeout( - () => this.updateCoinList(), - UPBIT_UPDATED_COIN_LIST_TIME, - ); - } +export class CoinDataUpdaterService implements OnModuleInit { + + private readonly logger = new Logger(CoinDataUpdaterService.name); + + private coinRawList: any; + private coinCodeList: string[] = ['KRW-BTC']; + private coinNameList: Map; + private coinLatestInfo = new Map(); + private krwCoinInfo: any[] = []; + private orderbookLatestInfo = new Map(); + + private timeouts = { + coinList: null as NodeJS.Timeout | null, + currentPrice: null as NodeJS.Timeout | null, + currentOrderBook: null as NodeJS.Timeout | null, + }; + + constructor(private readonly httpService: HttpService) {} + + async onModuleInit() { + await this.initializeCoinData(); } + + private async initializeCoinData(): Promise { + try { + await Promise.all([ + this.updateCoinList(), + this.updateCoinCurrentPrice(), + this.updateCurrentOrderBook(), + ]); + } catch (error) { + this.logger.error('Failed to initialize coin data', error); + } + } + + async updateCoinList() { + try { + const response = await this.fetchData(UPBIT_RESTAPI_URL); + this.processCoinList(response.data); + } catch (error) { + this.logger.error('Failed to update coin list:', error); + } finally { + this.scheduleNextUpdate( + 'coinList', + UPBIT_UPDATED_COIN_LIST_TIME, + this.updateCoinList.bind(this), + ); + } + } - async updateCoinCurrentPrice() { - try { - while (this.coinCodeList.length === 1) - await new Promise((resolve) => setTimeout(resolve, 100)); - const response = await firstValueFrom( - this.httpService.get( - `${UPBIT_CURRENT_PRICE_URL}markets=${this.coinCodeList.join(',')}`, - ), - ); - this.coinLatestInfo = new Map( - response.data.map((coin) => [coin.market, coin]), - ); - this.krwCoinInfo = response.data.filter((coin) => - coin.market.startsWith('KRW'), - ); + async updateCoinCurrentPrice() { + try { + await this.ensureCoinCodeListIsLoaded(); + const url = `${UPBIT_CURRENT_PRICE_URL}markets=${this.coinCodeList.join(',')}`; + const response = await this.fetchData(url); + this.processCurrentPrice(response.data); + } catch (error) { + this.logger.error('Failed to update current prices:', error); + } finally { + this.scheduleNextUpdate( + 'currentPrice', + UPBIT_UPDATED_COIN_INFO_TIME, + this.updateCoinCurrentPrice.bind(this), + ); + } + } + + async updateCurrentOrderBook() { + try { + await this.ensureCoinCodeListIsLoaded(); + const url = `${UPBIT_CURRENT_ORDERBOOK_URL}markets=${this.coinCodeList.join(',')}`; + const response = await this.fetchData(url); + this.processOrderBook(response.data); + } catch (error) { + this.logger.error('Failed to update order book:', error); + } finally { + this.scheduleNextUpdate( + 'currentOrderBook', + UPBIT_UPDATED_COIN_INFO_TIME, + this.updateCurrentOrderBook.bind(this), + ); + } + } + + private async fetchData(url: string): Promise { + try { + return firstValueFrom(this.httpService.get(url)); } catch (error) { - console.error('getCoinListFromUpbit error:', error); - } finally { - console.log(`코인 현재가 정보 최신화: ${Date()}`); - if (this.coinCurrentPriceTimeoutId) - clearTimeout(this.coinCurrentPriceTimeoutId); - this.coinCurrentPriceTimeoutId = setTimeout( - () => this.updateCoinCurrentPrice(), - UPBIT_UPDATED_COIN_INFO_TIME, - ); + this.logger.error(`Failed to fetch data from ${url}:`, error); + throw error; } - } + } - async updateCurrentOrderBook() { - try { - while (this.coinCodeList.length === 1) - await new Promise((resolve) => setTimeout(resolve, 100)); - const response = await firstValueFrom( - this.httpService.get( - `${UPBIT_CURRENT_ORDERBOOK_URL}markets=${this.coinCodeList.join(',')}`, - ), - ); - this.orderbookLatestInfo = new Map( - response.data.map((coin) => [coin.market, coin]), - ); - } catch (error) { - console.error('getCoinListFromUpbit error:', error); - } finally { - console.log(`코인 호가 정보 최신화: ${Date()}`); - if (this.coinCurrentOrderBookTimeoutId) - clearTimeout(this.coinCurrentOrderBookTimeoutId); - this.coinCurrentOrderBookTimeoutId = setTimeout( - () => this.updateCurrentOrderBook(), - UPBIT_UPDATED_COIN_INFO_TIME, - ); + private processCoinList(data: any[]) { + this.coinCodeList = data.map((coin) => coin.market); + this.coinNameList = new Map( + data.map((coin) => [coin.market, coin.korean_name]), + ); + this.coinRawList = data; + this.logger.log(`Updated coin list`); + } + + private processCurrentPrice(data: any[]) { + this.coinLatestInfo = new Map(data.map((coin) => [coin.market, coin])); + this.krwCoinInfo = data.filter((coin) => coin.market.startsWith('KRW')); + this.logger.log(`Updated current prices`); + } + + private processOrderBook(data: any[]) { + this.orderbookLatestInfo = new Map(data.map((coin) => [coin.market, coin])); + this.logger.log(`Updated order book`); + } + + private scheduleNextUpdate( + key: keyof typeof this.timeouts, + interval: number, + callback: () => void, + ) { + if (this.timeouts[key]) clearTimeout(this.timeouts[key]); + this.timeouts[key] = setTimeout(callback, interval); + } + + private async ensureCoinCodeListIsLoaded() { + const maxAttempts = 50; // Prevent infinite waiting + let attempts = 0; + + while (this.coinCodeList.length <= 1 && attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 100)); + attempts++; } - } - getCoinCodeList() { - return this.coinCodeList; - } - getCoinNameList() { - return this.coinNameList; - } + if (attempts === maxAttempts) { + this.logger.warn('Coin code list loading timed out'); + } + } - getAllCoinList() { - return this.coinRawList; - } + getCoinCodeList() { + return this.coinCodeList; + } - getKrwCoinInfo() { - return this.krwCoinInfo; - } + getCoinNameList() { + return this.coinNameList; + } - getCoinLatestInfo() { - return this.coinLatestInfo; - } + getAllCoinList() { + return this.coinRawList; + } - getCoinOrderbookInfo() { - return this.orderbookLatestInfo; - } + getKrwCoinInfo() { + return this.krwCoinInfo; + } - getCoinOrderbookByBid(buyDto) { - const { typeGiven, typeReceived } = buyDto; - const code = [typeGiven, typeReceived].join('-'); + getCoinLatestInfo() { + return this.coinLatestInfo; + } - const coinOrderbook = this.orderbookLatestInfo.get(code).orderbook_units; - return coinOrderbook; - } - getCoinOrderbookByAsk(buyDto) { - const { typeGiven, typeReceived } = buyDto; - const code = [typeReceived, typeGiven].join('-'); + getCoinOrderbookInfo() { + return this.orderbookLatestInfo; + } - const coinOrderbook = this.orderbookLatestInfo.get(code).orderbook_units; - return coinOrderbook; - } + getCoinOrderbookByBid(buyDto: { typeGiven: string; typeReceived: string }) { + const { typeGiven, typeReceived } = buyDto; + const code = `${typeGiven}-${typeReceived}`; + return this.orderbookLatestInfo.get(code)?.orderbook_units || []; + } + getCoinOrderbookByAsk(buyDto: { typeGiven: string; typeReceived: string }) { + const { typeGiven, typeReceived } = buyDto; + const code = `${typeReceived}-${typeGiven}`; + return this.orderbookLatestInfo.get(code)?.orderbook_units || []; + } } diff --git a/packages/server/src/upbit/coin-list.service.ts b/packages/server/src/upbit/coin-list.service.ts index bf7129c5..f9f2b406 100644 --- a/packages/server/src/upbit/coin-list.service.ts +++ b/packages/server/src/upbit/coin-list.service.ts @@ -1,161 +1,152 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { UPBIT_IMAGE_URL } from 'common/upbit'; +import { Injectable, Logger } from '@nestjs/common'; +import { UPBIT_IMAGE_URL } from '@src/upbit/constants'; import { CoinDataUpdaterService } from './coin-data-updater.service'; @Injectable() -export class CoinListService implements OnModuleInit { - constructor( - private readonly coinDataUpdaterService: CoinDataUpdaterService, - ) {} - - onModuleInit() { - this.coinDataUpdaterService.updateCoinList(); - this.coinDataUpdaterService.updateCoinCurrentPrice(); - this.coinDataUpdaterService.updateCurrentOrderBook(); - } - - async getMostTradeCoin() { - let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - while (!krwCoinInfo) { - await new Promise((resolve) => setTimeout(resolve, 100)); - krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - } - return krwCoinInfo - .sort((a, b) => b.acc_trade_price_24h - a.acc_trade_price_24h) - .slice(0, 20) - .map((coin) => { - coin.code = coin.market; - this.convertToCodeCoinDto(coin); - return { - market: coin.code, - image_url: coin.image_url, - korean_name: coin.korean_name, - }; - }); - } - async getSimpleCoin(coins) { - let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - while (!krwCoinInfo) { - await new Promise((resolve) => setTimeout(resolve, 100)); - krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); - } - - if (!coins.length) return []; - - return krwCoinInfo - .filter((coin) => coins.includes(coin.market)) - .map((coin) => { - coin.code = coin.market; - this.convertToCodeCoinDto(coin); - return { - market: coin.code, - image_url: coin.image_url, - korean_name: coin.korean_name, - }; - }); - } - - getCoinNameList() { - return this.coinDataUpdaterService.getCoinCodeList(); - } - - getAllCoinList() { - return this.coinDataUpdaterService.getAllCoinList(); - } - - getKRWCoinList() { - return this.coinDataUpdaterService - .getAllCoinList() - .filter((coin) => coin.market.startsWith('KRW')); - } - - getBTCCoinList() { - return this.coinDataUpdaterService - .getAllCoinList() - .filter((coin) => coin.market.startsWith('BTC')); - } - - getUSDTCoinList() { - return this.coinDataUpdaterService - .getAllCoinList() - .filter((coin) => coin.market.startsWith('USDT')); - } - - getCoinTickers(coins) { - const coinData = this.coinDataUpdaterService.getCoinLatestInfo(); - - const filteredData = Array.from(coinData.entries()) - .filter(([symbol]) => !coins || coins.includes(symbol)) - .map(([symbol, details]) => ({ - code: symbol, - ...details, - })); - - return filteredData; - } - - convertToCodeCoinDto = (coin) => { - coin.korean_name = this.coinDataUpdaterService - .getCoinNameList() - .get(coin.code); - coin.image_url = this.getCoinImageURL(coin.code); - return coin; - }; - convertToMarketCoinDto = (coin) => { - coin.korean_name = this.coinDataUpdaterService - .getCoinNameList() - .get(coin.market); - coin.image_url = this.getCoinImageURL(coin.market); - coin.type = 'ticker'; - coin.code = coin.market; - - return coin; - }; - - convertToOrderbookDto = (message) => { - const beforeTopPrice = this.coinDataUpdaterService - .getCoinLatestInfo() - .get(message.code).prev_closing_price; - - message.korean_name = this.coinDataUpdaterService - .getCoinNameList() - .get(message.code); - message.image_url = this.getCoinImageURL(message.code); - - message.orderbook_units.map((unit) => { - const askRateChange = - ((unit.ask_price - beforeTopPrice) / beforeTopPrice) * 100; - const bidRateChange = - ((unit.bid_price - beforeTopPrice) / beforeTopPrice) * 100; - - unit.ask_rate = - (askRateChange >= 0 - ? `+${askRateChange.toFixed(2)}` - : `${askRateChange.toFixed(2)}`) + '%'; - unit.bid_rate = - (bidRateChange >= 0 - ? `+${bidRateChange.toFixed(2)}` - : `${bidRateChange.toFixed(2)}`) + '%'; - }); - - return message; - }; - - convertToTickerDto = (message) => { - const data = message; - return { - korean_name: this.coinDataUpdaterService.getCoinNameList().get(data.code), - code: data.code, - coin_img_url: this.getCoinImageURL(data.code), - signed_change_price: data.signed_change_price, - opening_price: data.opening_price, - signed_change_rate: data.signed_change_rate, - trade_price: data.trade_price, - }; - }; - - private getCoinImageURL(code: string) { - const logoName = code.split('-')[1]; - return `${UPBIT_IMAGE_URL}${logoName}.png`; - } +export class CoinListService { + + private readonly logger = new Logger(CoinListService.name); + + constructor( + private readonly coinDataUpdaterService: CoinDataUpdaterService, + ) {} + + + async getMostTradeCoin(): Promise { + const krwCoinInfo = await this.waitForKrwCoinInfo(); + return krwCoinInfo + .sort((a, b) => b.acc_trade_price_24h - a.acc_trade_price_24h) + .slice(0, 20) + .map((coin) => this.toSimpleCoinDto(coin)); + } + + async getSimpleCoin(markets: string[]): Promise { + const krwCoinInfo = await this.waitForKrwCoinInfo(); + if (!markets.length) return []; + return krwCoinInfo + .filter((coin) => markets.includes(coin.market)) + .map((coin) => this.toSimpleCoinDto(coin)); + } + + private toSimpleCoinDto(coin: any): any { + return { + market: coin.market, + image_url: this.getCoinImageURL(coin.market), + korean_name: this.coinDataUpdaterService + .getCoinNameList() + .get(coin.market), + }; + } + + private async waitForKrwCoinInfo(): Promise { + let krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); + while (!krwCoinInfo) { + await new Promise((resolve) => setTimeout(resolve, 100)); + krwCoinInfo = this.coinDataUpdaterService.getKrwCoinInfo(); + } + return krwCoinInfo; + } + + getCoinNameList() { + return this.coinDataUpdaterService.getCoinCodeList(); + } + + getAllCoinList() { + return this.coinDataUpdaterService.getAllCoinList(); + } + + getKRWCoinList() { + return this.coinDataUpdaterService + .getAllCoinList() + .filter((coin) => coin.market.startsWith('KRW')); + } + + getBTCCoinList() { + return this.coinDataUpdaterService + .getAllCoinList() + .filter((coin) => coin.market.startsWith('BTC')); + } + + getUSDTCoinList() { + return this.coinDataUpdaterService + .getAllCoinList() + .filter((coin) => coin.market.startsWith('USDT')); + } + + getCoinTickers(coins: string[]): any[] { + const coinData = this.coinDataUpdaterService.getCoinLatestInfo(); + + return Array.from(coinData.entries()) + .filter(([symbol]) => !coins || coins.includes(symbol)) + .map(([symbol, details]) => ({ + code: symbol, + ...details, + })); + } + + convertToCodeCoinDto = (coin) => { + coin.korean_name = this.coinDataUpdaterService + .getCoinNameList() + .get(coin.code); + coin.image_url = this.getCoinImageURL(coin.code); + return coin; + }; + + convertToMarketCoinDto = (coin) => { + coin.korean_name = this.coinDataUpdaterService + .getCoinNameList() + .get(coin.market); + coin.image_url = this.getCoinImageURL(coin.market); + coin.type = 'ticker'; + coin.code = coin.market; + + return coin; + }; + + convertToOrderbookDto = (message) => { + const beforeTopPrice = this.coinDataUpdaterService + .getCoinLatestInfo() + .get(message.code).prev_closing_price; + + message.korean_name = this.coinDataUpdaterService + .getCoinNameList() + .get(message.code); + message.image_url = this.getCoinImageURL(message.code); + + message.orderbook_units.map((unit) => { + const askRateChange = + ((unit.ask_price - beforeTopPrice) / beforeTopPrice) * 100; + const bidRateChange = + ((unit.bid_price - beforeTopPrice) / beforeTopPrice) * 100; + + unit.ask_rate = + (askRateChange >= 0 + ? `+${askRateChange.toFixed(2)}` + : `${askRateChange.toFixed(2)}`) + '%'; + unit.bid_rate = + (bidRateChange >= 0 + ? `+${bidRateChange.toFixed(2)}` + : `${bidRateChange.toFixed(2)}`) + '%'; + }); + + return message; + }; + + convertToTickerDto = (message) => { + const data = message; + return { + korean_name: this.coinDataUpdaterService.getCoinNameList().get(data.code), + code: data.code, + coin_img_url: this.getCoinImageURL(data.code), + signed_change_price: data.signed_change_price, + opening_price: data.opening_price, + signed_change_rate: data.signed_change_rate, + trade_price: data.trade_price, + }; + }; + + private getCoinImageURL(code: string) { + const logoName = code.split('-')[1]; + return `${UPBIT_IMAGE_URL}${logoName}.png`; + } } diff --git a/packages/server/src/upbit/coin-ticker-websocket.service.spec.ts b/packages/server/src/upbit/coin-ticker-websocket.service.spec.ts deleted file mode 100644 index 9022c70a..00000000 --- a/packages/server/src/upbit/coin-ticker-websocket.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CoinTickerService } from './coin-ticker-websocket.service'; - -describe('CoinTickerService', () => { - let service: CoinTickerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CoinTickerService], - }).compile(); - - service = module.get(CoinTickerService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/packages/server/src/upbit/coin-ticker-websocket.service.ts b/packages/server/src/upbit/coin-ticker-websocket.service.ts deleted file mode 100644 index 3dde2f5b..00000000 --- a/packages/server/src/upbit/coin-ticker-websocket.service.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import * as WebSocket from 'ws'; -import { SseService } from './sse.service'; -import { CoinListService } from './coin-list.service'; -import { - UPBIT_UPDATED_COIN_INFO_TIME, - UPBIT_WEBSOCKET_CONNECTION_TIME, - UPBIT_WEBSOCKET_URL, -} from 'common/upbit'; -import { ChartService } from './chart.service'; - -@Injectable() -export class CoinTickerService implements OnModuleInit { - private websocket: WebSocket; - private sending: boolean = false; - private timeoutId: NodeJS.Timeout | null = null; - constructor( - private readonly coinListService: CoinListService, - private readonly sseService: SseService, - private readonly chartService: ChartService, - ) {} - - onModuleInit() { - this.connectWebSocket(); - } - - connectWebSocket() { - this.websocket = new WebSocket(UPBIT_WEBSOCKET_URL); - - this.websocket.on('open', () => { - try { - console.log('CoinTickerWebSocket 연결이 열렸습니다.'); - this.sendWebSocket(); - } catch (error) { - console.error('sendWebSocket 실행 중 오류 발생:', error); - } - }); - this.websocket.on('message', (data) => { - try { - const message = JSON.parse(data.toString()); - if (message.error) throw new Error(JSON.stringify(message)); - this.sseService.coinTickerSendEvent(message); - //this.chartService.makeCandle(message); - } catch (error) { - console.error('CoinTickerWebSocket 오류:', error); - } - }); - this.websocket.on('close', () => { - try { - console.log('CoinTickerWebSocket 연결이 닫혔습니다. 재연결 시도 중...'); - setTimeout( - () => this.connectWebSocket(), - UPBIT_WEBSOCKET_CONNECTION_TIME, - ); - } catch (error) { - console.error('WebSocket 재연결 설정 중 오류 발생:', error); - } - }); - - this.websocket.on('error', (error) => { - console.error('CoinTickerWebSocket 오류:', error); - }); - } - async sendWebSocket() { - if (this.sending) return; - this.sending = true; - try { - if (this.websocket.readyState !== WebSocket.OPEN) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const coin_list = this.coinListService.getCoinNameList(); - const subscribeMessage = JSON.stringify([ - { ticket: 'test' }, - { type: 'ticker', codes: coin_list }, - ]); - this.websocket.send(subscribeMessage); - } catch (error) { - console.error('CoinTickerWebSocket 오류:', error); - } finally { - this.sending = false; - if (this.timeoutId) clearTimeout(this.timeoutId); - this.timeoutId = setTimeout( - () => this.sendWebSocket(), - UPBIT_UPDATED_COIN_INFO_TIME, - ); - } - } -} diff --git a/packages/server/common/upbit.ts b/packages/server/src/upbit/constants.ts similarity index 100% rename from packages/server/common/upbit.ts rename to packages/server/src/upbit/constants.ts diff --git a/packages/server/src/upbit/dtos/coin-ticker.dto.ts b/packages/server/src/upbit/dtos/coin-ticker.dto.ts index 7e52e2d2..462c6136 100644 --- a/packages/server/src/upbit/dtos/coin-ticker.dto.ts +++ b/packages/server/src/upbit/dtos/coin-ticker.dto.ts @@ -10,7 +10,7 @@ export class CoinTickerDto { @IsString() coin_img_url: string; - @IsString() + @IsNumber() signed_change_price: number; @IsNumber() diff --git a/packages/server/src/upbit/orderbook-websocket.service.spec.ts b/packages/server/src/upbit/orderbook-websocket.service.spec.ts deleted file mode 100644 index 3c7cef46..00000000 --- a/packages/server/src/upbit/orderbook-websocket.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { OrderbookService } from './orderbook-websocket.service'; - -describe('OrderbookService', () => { - let service: OrderbookService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [OrderbookService], - }).compile(); - - service = module.get(OrderbookService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/packages/server/src/upbit/orderbook-websocket.service.ts b/packages/server/src/upbit/orderbook-websocket.service.ts deleted file mode 100644 index e808662b..00000000 --- a/packages/server/src/upbit/orderbook-websocket.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import * as WebSocket from 'ws'; -import { SseService } from './sse.service'; -import { CoinListService } from './coin-list.service'; -import { - UPBIT_WEBSOCKET_CONNECTION_TIME, - UPBIT_WEBSOCKET_URL, - UPBIT_UPDATED_ORDER_INFO_TIME, -} from 'common/upbit'; -import { CoinDataUpdaterService } from './coin-data-updater.service'; - -@Injectable() -export class OrderbookService implements OnModuleInit { - private websocket: WebSocket; - private sending: boolean = false; - private timeoutId: NodeJS.Timeout | null = null; - constructor( - private readonly coinListService: CoinListService, - private readonly sseService: SseService, - private readonly coinDataUpdaterService: CoinDataUpdaterService, - ) {} - - onModuleInit() { - this.connectWebSocket(); - } - - connectWebSocket() { - this.websocket = new WebSocket(UPBIT_WEBSOCKET_URL); - - this.websocket.on('open', () => { - try { - console.log('OrderbookWebSocket 연결이 열렸습니다.'); - this.sendWebSocket(); - } catch (error) { - console.error('sendWebSocket 실행 중 오류 발생:', error); - } - }); - this.websocket.on('message', (data) => { - try { - const message = JSON.parse(data.toString()); - if (message.error) throw new Error(JSON.stringify(message)); - this.sseService.orderbookSendEvent(message); - } catch (error) { - console.error('OrderbookWebSocket 오류:', error); - } - }); - this.websocket.on('close', () => { - try { - console.log('OrderbookWebSocket 연결이 닫혔습니다. 재연결 시도 중...'); - setTimeout( - () => this.connectWebSocket(), - UPBIT_WEBSOCKET_CONNECTION_TIME, - ); - } catch (error) { - console.error('OrderbookWebSocket 재연결 설정 중 오류 발생:', error); - } - }); - - this.websocket.on('error', (error) => { - console.error('OrderbookWebSocket 오류:', error); - }); - } - async sendWebSocket() { - if (this.sending) return; - this.sending = true; - try { - if (this.websocket.readyState !== WebSocket.OPEN) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const coin_list = this.coinListService.getCoinNameList(); - const subscribeMessage = JSON.stringify([ - { ticket: 'test' }, - { type: 'orderbook', codes: coin_list }, - ]); - this.websocket.send(subscribeMessage); - } catch (error) { - console.error('OrderbookWebSocket 오류:', error); - } finally { - this.sending = false; - if (this.timeoutId) clearTimeout(this.timeoutId); - this.timeoutId = setTimeout( - () => this.sendWebSocket(), - UPBIT_UPDATED_ORDER_INFO_TIME, - ); - } - } -} diff --git a/packages/server/src/upbit/sse.service.ts b/packages/server/src/upbit/sse.service.ts deleted file mode 100644 index 23a80ce8..00000000 --- a/packages/server/src/upbit/sse.service.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Injectable, OnModuleDestroy } from '@nestjs/common'; -import { Subject, Observable } from 'rxjs'; -import { map, takeUntil, filter } from 'rxjs/operators'; -import { CoinDataUpdaterService } from './coin-data-updater.service'; - -@Injectable() -export class SseService implements OnModuleDestroy { - private coinTickerStream$ = new Subject(); - private orderbookStream$ = new Subject(); - private coinTickerDestroy$ = new Subject(); - private orderBookDestroy$ = new Subject(); - - constructor( - private readonly coinDataUpdaterService: CoinDataUpdaterService, - ) {} - - coinTickerSendEvent(data: any) { - this.coinTickerStream$.next(data); - } - orderbookSendEvent(data: any) { - this.orderbookStream$.next(data); - } - - initPriceStream(coins, dto) { - const events: MessageEvent[] = []; - if (coins && typeof coins === 'string') { - coins = [coins]; - } - coins.forEach(async (coin) => { - let coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); - while ( - coinLatestInfo.size === 0 || - coinLatestInfo.get(coin) === undefined - ) { - await new Promise((resolve) => setTimeout(resolve, 100)); - coinLatestInfo = this.coinDataUpdaterService.getCoinLatestInfo(); - } - - const initData = coinLatestInfo.get(coin); - const setDto = dto(initData); - const msgEvent = new MessageEvent('price-update', { - data: JSON.stringify(setDto), - }) as MessageEvent; - - events.push(msgEvent); - }); - - return events; - } - getPriceUpdatesStream(coins, dto): Observable { - return this.coinTickerStream$.asObservable().pipe( - takeUntil(this.coinTickerDestroy$), - filter((data) => coins.includes(data.code)), - map((data) => { - const setDto = dto(data); - return new MessageEvent('price-update', { - data: JSON.stringify(setDto), - }) as MessageEvent; - }), - ); - } - - getOrderbookUpdatesStream(coins, dto): Observable { - return this.orderbookStream$.asObservable().pipe( - takeUntil(this.orderBookDestroy$), - filter((data) => coins.includes(data.code)), - map((data) => { - const setDto = dto(data); - return new MessageEvent('orderbook-update', { - data: JSON.stringify(setDto), - }) as MessageEvent; - }), - ); - } - onModuleDestroy() { - this.coinTickerDestroy$.next(); - this.orderBookDestroy$.next(); - } -} diff --git a/packages/server/src/upbit/upbit.controller.spec.ts b/packages/server/src/upbit/upbit.controller.spec.ts deleted file mode 100644 index 7e549e82..00000000 --- a/packages/server/src/upbit/upbit.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UpbitController } from './upbit.controller'; - -describe('UpbitController', () => { - let controller: UpbitController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UpbitController], - }).compile(); - - controller = module.get(UpbitController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/packages/server/src/upbit/upbit.controller.ts b/packages/server/src/upbit/upbit.controller.ts index de675a04..811bf4e7 100644 --- a/packages/server/src/upbit/upbit.controller.ts +++ b/packages/server/src/upbit/upbit.controller.ts @@ -1,99 +1,141 @@ -import { Controller, Sse, Query, Get, Param, Res } from '@nestjs/common'; -import { Observable, concat } from 'rxjs'; -import { SseService } from './sse.service'; +import { Controller, Sse, Query, Get, Param, Res, HttpStatus } from '@nestjs/common'; +import { Observable, concat, concatAll, from, map } from 'rxjs'; +import { SseService } from './SSE/sse.service'; import { CoinListService } from './coin-list.service'; import { ChartService } from './chart.service'; import { Response } from 'express'; -import { ApiQuery } from '@nestjs/swagger'; +import { ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; @Controller('upbit') export class UpbitController { - constructor( - private readonly sseService: SseService, - private readonly coinListService: CoinListService, - private readonly chartService: ChartService, - ) {} - - @Sse('price-updates') - priceUpdates(@Query('coins') coins: string[]): Observable { - coins = coins || []; - const initData = this.sseService.initPriceStream( - coins, - this.coinListService.convertToMarketCoinDto, - ); - return concat( - initData, - this.sseService.getPriceUpdatesStream( - coins, - this.coinListService.convertToCodeCoinDto, - ), - ); - } - @Sse('orderbook') - orderbookUpdates(@Query('coins') coins: string[]): Observable { - coins = coins || []; - return this.sseService.getOrderbookUpdatesStream( - coins, - this.coinListService.convertToOrderbookDto, - ); - } + constructor( + private readonly sseService: SseService, + private readonly coinListService: CoinListService, + private readonly chartService: ChartService, + ) {} - @Get('market/all') - getAllMarkets() { - return this.coinListService.getAllCoinList(); - } - @Get('market/krw') - getKRWMarkets() { - return this.coinListService.getKRWCoinList(); - } - @Get('market/btc') - getBTCMarkets() { - return this.coinListService.getBTCCoinList(); - } - @Get('market/usdt') - getUSDTMarkets() { - return this.coinListService.getUSDTCoinList(); - } + @Sse('price-updates') + async priceUpdates( + @Query('coins') coinsQuery?: string | string[], + ): Promise> { + const coins = this.parseCoins(coinsQuery); - @Get('market/top20-trade/krw') - getTop20TradeKRW() { - return this.coinListService.getMostTradeCoin(); - } - @Get('market/simplelist/krw') - getSomeKRW(@Query('market') market: string[]) { - const marketList = market || []; - return this.coinListService.getSimpleCoin(marketList); - } - @Get('market/tickers') - @ApiQuery({ name: 'coins', required: false, type: String }) - getCoinTickers(@Query('coins') coins?: string) { - return this.coinListService.getCoinTickers(coins); - } + const initData$ = from( + this.sseService.initStream( + coins, + this.coinListService.convertToMarketCoinDto, + ), + ).pipe(concatAll()); + + const updatesStream = this.sseService.getUpdatesStream( + 'price', + coins, + this.coinListService.convertToCodeCoinDto, + ); + + return concat(initData$, updatesStream); + } + @Sse('orderbook') + orderbookUpdates( + @Query('coins') coinsQuery?: string[], + ): Observable { + const coins = this.parseCoins(coinsQuery); + + return this.sseService.getUpdatesStream( + 'orderbook', + coins, + this.coinListService.convertToOrderbookDto, + ); + } + + @ApiOperation({ summary: 'Get all available markets' }) + @ApiResponse({ + status: 200, + description: 'List of all coin markets', + }) + @Get('market/all') + getAllMarkets() { + return this.coinListService.getAllCoinList(); + } + + @ApiOperation({ summary: 'Get KRW markets' }) + @Get('market/krw') + getKRWMarkets() { + return this.coinListService.getKRWCoinList(); + } + @ApiOperation({ summary: 'Get BTC markets' }) + @Get('market/btc') + getBTCMarkets() { + return this.coinListService.getBTCCoinList(); + } + @ApiOperation({ summary: 'Get USDT markets' }) + @Get('market/usdt') + getUSDTMarkets() { + return this.coinListService.getUSDTCoinList(); + } + + @ApiOperation({ summary: 'Get top 20 most traded KRW coins' }) + @Get('market/top20-trade/krw') + getTop20TradeKRW() { + return this.coinListService.getMostTradeCoin(); + } + + @ApiOperation({ summary: 'Get simple coin list for specific markets' }) + @Get('market/simplelist/krw') + getSomeKRW(@Query('market') market: string[]) { + return this.coinListService.getSimpleCoin(market || []); + } + + @ApiOperation({ summary: 'Get coin tickers' }) + @ApiQuery({ + name: 'coins', + required: false, + description: 'Comma-separated list of coin codes', + }) + @Get('market/tickers') + @ApiQuery({ name: 'coins', required: false, type: String }) + getCoinTickers(@Query('coins') coins?: string) { + return this.coinListService.getCoinTickers(coins ? coins.split(',') : []); + } + + @ApiOperation({ summary: 'Get candlestick data' }) + @ApiQuery({ name: 'minute', required: false, type: String }) + @Get('candle/:type/:minute?') + async getCandle( + @Res() res: Response, + @Param('type') type: string, + @Query('market') market: string, + @Query('to') to: string, + @Param('minute') minute?: string, + ) { + try { + const response = await this.chartService.upbitApiDoor( + type, + market, + to, + minute, + ); + + return res.status(response.statusCode).json(response); + } catch (error) { + this.handleCandleError(res, error); + } + } + + private parseCoins(coinsQuery?: string | string[]): string[] { + if (!coinsQuery) return []; + + return Array.isArray(coinsQuery) + ? coinsQuery + : coinsQuery.split(',').map((coin) => coin.trim()); + } - @Get('candle/:type/:minute?') - @ApiQuery({ name: 'minute', required: false, type: String }) - async getCandle( - @Res() res: Response, - @Param('type') type: string, - @Query('market') market: string, - @Query('to') to: string, - @Param('minute') minute?: string, - ) { - try { - const response = await this.chartService.upbitApiDoor( - type, - market, - to, - minute, - ); - - return res.status(response.statusCode).json(response); - } catch (error) { - console.error('error' + error); - return res.status(error.status).json({ - message: error.message || '서버오류입니다.', - error: error?.response || null, - }); - } + private handleCandleError(res: Response, error: any) { + console.error('Candle fetch error:', error); + + return res.status(error.status || HttpStatus.INTERNAL_SERVER_ERROR).json({ + message: error.message || '서버오류입니다.', + error: error?.response || null, + }); } } diff --git a/packages/server/src/upbit/upbit.module.ts b/packages/server/src/upbit/upbit.module.ts index 030b3c4d..5397061e 100644 --- a/packages/server/src/upbit/upbit.module.ts +++ b/packages/server/src/upbit/upbit.module.ts @@ -1,23 +1,24 @@ import { Module, Global } from '@nestjs/common'; -import { CoinTickerService } from './coin-ticker-websocket.service'; +import { CoinTickerService } from './SSE/coin-ticker-websocket.service'; import { UpbitController } from './upbit.controller'; import { CoinListService } from './coin-list.service'; import { HttpModule } from '@nestjs/axios'; -import { SseService } from './sse.service'; -import { OrderbookService } from './orderbook-websocket.service'; +import { SseService } from './SSE/sse.service'; +import { OrderbookService } from './SSE/orderbook-websocket.service'; import { CoinDataUpdaterService } from './coin-data-updater.service'; import { ChartService } from './chart.service'; @Global() @Module({ - imports: [HttpModule], + imports: [HttpModule, + ], providers: [ + CoinDataUpdaterService, + ChartService, CoinTickerService, CoinListService, SseService, OrderbookService, - CoinDataUpdaterService, - ChartService, ], controllers: [UpbitController], exports: [CoinDataUpdaterService], diff --git a/packages/server/src/upbit/utils/validation.ts b/packages/server/src/upbit/utils/validation.ts new file mode 100644 index 00000000..acab251d --- /dev/null +++ b/packages/server/src/upbit/utils/validation.ts @@ -0,0 +1,5 @@ +export const VALID_MINUTES = ['1', '3', '5', '10', '15', '30', '60', '240']; + +export function isValidMinute(minute: string): boolean { + return VALID_MINUTES.includes(minute); +} \ No newline at end of file