diff --git a/packages/server/src/account/account.repository.ts b/packages/server/src/account/account.repository.ts index 0e0842c3..20d29348 100644 --- a/packages/server/src/account/account.repository.ts +++ b/packages/server/src/account/account.repository.ts @@ -1,8 +1,8 @@ import { DataSource, Repository, QueryRunner } from 'typeorm'; import { - Injectable, - UnprocessableEntityException, - Logger, + Injectable, + UnprocessableEntityException, + Logger, } from '@nestjs/common'; import { Account } from './account.entity'; import { User } from '@src/auth/user.entity'; @@ -11,119 +11,175 @@ import { UserDto } from './dtos/my-account.response.dto'; @Injectable() export class AccountRepository extends Repository { - private readonly logger = new Logger(AccountRepository.name); - - constructor(private readonly dataSource: DataSource) { - super(Account, dataSource.createEntityManager()); - } - - async createAccountForAdmin(adminUser: User): Promise { - this.logger.log(`관리자 계정 생성 시작: ${adminUser.id}`); - try { - const account = new Account(); - account.KRW = CURRENCY_CONSTANTS.ADMIN_INITIAL_KRW; - account.USDT = CURRENCY_CONSTANTS.ADMIN_INITIAL_USDT; - account.BTC = CURRENCY_CONSTANTS.ADMIN_INITIAL_BTC; - account.user = adminUser; - - await this.save(account); - this.logger.log(`관리자 계정 생성 완료: ${adminUser.id}`); - } catch (error) { - this.logger.error(`관리자 계정 생성 실패: ${error.message}`, error.stack); - throw error; - } - } - - async getMyMoney(user: UserDto, moneyType: string): Promise { - try { - const account = await this.findOne({ - where: { user: { id: user.userId } }, - }); - - return account?.[moneyType] || 0; - } catch (error) { - this.logger.error(`잔액 조회 실패: ${error.message}`, error.stack); - throw error; - } - } - - async updateAccountCurrency( - typeGiven: string, - accountBalance: number, - accountId: number, - queryRunner: QueryRunner, - ): Promise { - this.logger.log( - `계정 통화 업데이트 시작: accountId=${accountId}, type=${typeGiven}`, - ); - try { - await queryRunner.manager - .createQueryBuilder() - .update(Account) - .set({ [typeGiven]: accountBalance }) - .where('id = :id', { id: accountId }) - .execute(); - - this.logger.log(`계정 통화 업데이트 완료: accountId=${accountId}`); - } catch (error) { - this.logger.error( - `계정 통화 업데이트 실패: ${error.message}`, - error.stack, - ); - throw error; - } - } - - async updateAccountBTC( - id: number, - quantity: number, - queryRunner: QueryRunner, - ): Promise { - this.logger.log(`BTC 잔액 업데이트 시작: accountId=${id}`); - try { - await queryRunner.manager - .createQueryBuilder() - .update(Account) - .set({ BTC: quantity }) - .where('id = :id', { id }) - .execute(); - - this.logger.log(`BTC 잔액 업데이트 완료: accountId=${id}`); - } catch (error) { - this.logger.error( - `BTC 잔액 업데이트 실패: ${error.message}`, - error.stack, - ); - throw error; - } - } - - async validateUserAccount(userId: number): Promise { - this.logger.log(`사용자 계정 검증 시작: userId=${userId}`); - const userAccount = await this.findOne({ - where: { user: { id: userId } }, - }); - - if (!userAccount) { - this.logger.warn(`존재하지 않는 사용자 계정: userId=${userId}`); - throw new UnprocessableEntityException('유저가 존재하지 않습니다.'); - } - - return userAccount; - } - - async getAccount(id: number, queryRunner: QueryRunner): Promise { - this.logger.log(`계정 조회 시작: userId=${id}`); - try { - const account = await queryRunner.manager.findOne(Account, { - where: { user: { id } }, - }); - - this.logger.log(`계정 조회 완료: userId=${id}`); - return account; - } catch (error) { - this.logger.error(`계정 조회 실패: ${error.message}`, error.stack); - throw error; - } - } + private readonly logger = new Logger(AccountRepository.name); + + constructor(private readonly dataSource: DataSource) { + super(Account, dataSource.createEntityManager()); + } + + async createAccountForAdmin(adminUser: User): Promise { + this.logger.log(`관리자 계정 생성 시작: ${adminUser.id}`); + try { + const account = new Account(); + account.KRW = CURRENCY_CONSTANTS.ADMIN_INITIAL_KRW; + account.USDT = CURRENCY_CONSTANTS.ADMIN_INITIAL_USDT; + account.BTC = CURRENCY_CONSTANTS.ADMIN_INITIAL_BTC; + account.user = adminUser; + + await this.save(account); + this.logger.log(`관리자 계정 생성 완료: ${adminUser.id}`); + } catch (error) { + this.logger.error(`관리자 계정 생성 실패: ${error.message}`, error.stack); + throw error; + } + } + + async getMyMoney(user: UserDto, moneyType: string): Promise { + try { + const account = await this.findOne({ + where: { user: { id: user.userId } }, + }); + + return account?.[moneyType] || 0; + } catch (error) { + this.logger.error(`잔액 조회 실패: ${error.message}`, error.stack); + throw error; + } + } + + async updateAccountCurrency( + typeGiven: string, + accountBalance: number, + accountId: number, + queryRunner: QueryRunner, + ): Promise { + this.logger.log( + `계정 통화 업데이트 시작: accountId=${accountId}, type=${typeGiven}`, + ); + try { + await queryRunner.manager + .createQueryBuilder() + .update(Account) + .set({ [typeGiven]: accountBalance }) + .where('id = :id', { id: accountId }) + .execute(); + + this.logger.log(`계정 통화 업데이트 완료: accountId=${accountId}`); + } catch (error) { + this.logger.error( + `계정 통화 업데이트 실패: ${error.message}`, + error.stack, + ); + throw error; + } + } + + async updateAvailableKRW( + accountId: number, + updatedAvailableKRW: number, + queryRunner: QueryRunner, + ): Promise { + await queryRunner.manager + .createQueryBuilder() + .update(Account) + .set({ availableKRW: updatedAvailableKRW }) + .where('id = :id', { id: accountId }) + .execute(); + } + + async getAvailableKRW( + accountId: number, + queryRunner: QueryRunner, + ): Promise { + const account = await queryRunner.manager + .createQueryBuilder(Account, 'account') + .select('account.availableKRW') + .where('account.id = :id', { id: accountId }) + .getOne(); + + if (!account) { + throw new Error(`Account with id ${accountId} not found`); + } + + return account.availableKRW; + } + + async updateAccountAvailableCurrency( + change: number, + accountId: number, + queryRunner: QueryRunner, + ): Promise { + this.logger.log( + `계정 통화 업데이트 시작: accountId=${accountId}, type=availableKRW`, + ); + try { + await queryRunner.manager + .createQueryBuilder() + .update(Account) + .set({ availableKRW: () => `availableKRW + ${change}` }) + .where('id = :id', { id: accountId }) + .execute(); + + this.logger.log(`계정 통화 업데이트 완료: accountId=${accountId}`); + } catch (error) { + this.logger.error( + `계정 통화 업데이트 실패: ${error.message}`, + error.stack, + ); + throw error; + } + } + + async updateAccountBTC( + id: number, + quantity: number, + queryRunner: QueryRunner, + ): Promise { + this.logger.log(`BTC 잔액 업데이트 시작: accountId=${id}`); + try { + await queryRunner.manager + .createQueryBuilder() + .update(Account) + .set({ BTC: quantity }) + .where('id = :id', { id }) + .execute(); + + this.logger.log(`BTC 잔액 업데이트 완료: accountId=${id}`); + } catch (error) { + this.logger.error( + `BTC 잔액 업데이트 실패: ${error.message}`, + error.stack, + ); + throw error; + } + } + + async validateUserAccount(userId: number): Promise { + this.logger.log(`사용자 계정 검증 시작: userId=${userId}`); + const userAccount = await this.findOne({ + where: { user: { id: userId } }, + }); + + if (!userAccount) { + this.logger.warn(`존재하지 않는 사용자 계정: userId=${userId}`); + throw new UnprocessableEntityException('유저가 존재하지 않습니다.'); + } + + return userAccount; + } + + async getAccount(id: number, queryRunner: QueryRunner): Promise { + this.logger.log(`계정 조회 시작: userId=${id}`); + try { + const account = await queryRunner.manager.findOne(Account, { + where: { user: { id } }, + }); + + this.logger.log(`계정 조회 완료: userId=${id}`); + return account; + } catch (error) { + this.logger.error(`계정 조회 실패: ${error.message}`, error.stack); + throw error; + } + } } diff --git a/packages/server/src/trade/trade-bid.service.ts b/packages/server/src/trade/trade-bid.service.ts index e720f526..bda20ac2 100644 --- a/packages/server/src/trade/trade-bid.service.ts +++ b/packages/server/src/trade/trade-bid.service.ts @@ -1,20 +1,20 @@ import { - BadRequestException, - Injectable, - OnModuleInit, - UnprocessableEntityException, + BadRequestException, + Injectable, + OnModuleInit, + UnprocessableEntityException, } from '@nestjs/common'; import { QueryRunner } from 'typeorm'; import { - TRADE_TYPES, - TRANSACTION_CHECK_INTERVAL, + TRADE_TYPES, + TRANSACTION_CHECK_INTERVAL, } from './constants/trade.constants'; import { formatQuantity, isMinimumQuantity } from './helpers/trade.helper'; import { - OrderBookEntry, - TradeData, - TradeResponse, - TradeDataRedis, + OrderBookEntry, + TradeData, + TradeResponse, + TradeDataRedis, } from './dtos/trade.interface'; import { UPBIT_UPDATED_COIN_INFO_TIME } from '../upbit/constants'; import { TradeNotFoundException } from './exceptions/trade.exceptions'; @@ -22,290 +22,289 @@ import { TradeAskBidService } from './trade-ask-bid.service'; @Injectable() export class BidService extends TradeAskBidService implements OnModuleInit { - private transactionCreateBid: boolean = false; - private isProcessing: { [key: number]: boolean } = {}; - - onModuleInit() { - this.startPendingTradesProcessor(); - } - - private startPendingTradesProcessor() { - const processBidTrades = async () => { - try { - await this.processPendingTrades( - TRADE_TYPES.BUY, - this.bidTradeService.bind(this), - ); - } finally { - setTimeout(processBidTrades, UPBIT_UPDATED_COIN_INFO_TIME); - } - }; - processBidTrades(); - } - - async calculatePercentBuy( - user: any, - moneyType: string, - percent: number, - ): Promise { - const account = await this.accountRepository.findOne({ - where: { user: { id: user.userId } }, - }); - - const balance = account[moneyType]; - return formatQuantity(balance * (percent / 100)); - } - - async createBidTrade(user: any, bidDto: TradeData): Promise { - if (isMinimumQuantity(bidDto.receivedAmount * bidDto.receivedPrice)) { - throw new BadRequestException('최소 거래 금액보다 작습니다.'); - } - - if (this.transactionCreateBid) { - await this.waitForTransaction(() => this.transactionCreateBid); - } - this.transactionCreateBid = true; - - try { - let userTrade; - const transactionResult = await this.executeTransaction( - async (queryRunner) => { - if (bidDto.receivedAmount <= 0) { - throw new BadRequestException('수량은 0보다 커야 합니다.'); - } - - const userAccount = await this.accountRepository.validateUserAccount( - user.userId, - ); - const accountBalance = await this.checkCurrencyBalance( - bidDto, - userAccount, - ); - - await this.accountRepository.updateAccountCurrency( - 'availableKRW', - accountBalance, - userAccount.id, - queryRunner, - ); - - userTrade = await this.tradeRepository.createTrade( - bidDto, - user.userId, - TRADE_TYPES.BUY, - queryRunner, - ); - return { - statusCode: 200, - message: '거래가 정상적으로 등록되었습니다.', - }; - }, - ); - if (transactionResult.statusCode === 200) { - const tradeData: TradeDataRedis = { - tradeId: userTrade.tradeId, - userId: user.userId, - tradeType: TRADE_TYPES.BUY, - tradeCurrency: bidDto.typeGiven, - assetName: bidDto.typeReceived, - price: bidDto.receivedPrice, - quantity: bidDto.receivedAmount, - createdAt: userTrade.createdAt, - }; - - await this.redisRepository.createTrade(tradeData); - } - return transactionResult; - } finally { - this.transactionCreateBid = false; - } - } - - private async checkCurrencyBalance( - bidDto: TradeData, - account: any, - ): Promise { - const { receivedPrice, receivedAmount } = bidDto; - const balance = account.availableKRW; - - const givenAmount = formatQuantity(receivedPrice * receivedAmount); - const remaining = formatQuantity(balance - givenAmount); - - if (remaining < 0) { - throw new UnprocessableEntityException('자산이 부족합니다.'); - } - - return remaining; - } - - private async bidTradeService(bidDto: TradeData): Promise { - if (this.isProcessing[bidDto.tradeId]) { - return; - } - - this.isProcessing[bidDto.tradeId] = true; - - try { - const { userId, typeGiven } = bidDto; - // 트랜잭션 없이 계정 조회 - const account = await this.accountRepository.findOne({ - where: { user: { id: userId } }, - }); - - bidDto.accountBalance = account[typeGiven]; - bidDto.account = account; - - const orderbook = - this.coinDataUpdaterService.getCoinOrderbookByBid(bidDto); - - for (const order of orderbook) { - try { - if (order.ask_price > bidDto.receivedPrice) break; - const tradeResult = await this.executeTransaction( - async (queryRunner) => { - const remainingQuantity = await this.executeBidTrade( - bidDto, - order, - queryRunner, - ); - - return !isMinimumQuantity(remainingQuantity); - }, - ); - - if (!tradeResult) break; - } catch (error) { - if (error instanceof TradeNotFoundException) { - break; - } - throw error; - } - } - } finally { - delete this.isProcessing[bidDto.tradeId]; - } - } - - private async executeBidTrade( - bidDto: TradeData, - order: OrderBookEntry, - queryRunner: QueryRunner, - ): Promise { - const tradeData = await this.tradeRepository.findTradeWithLock( - bidDto.tradeId, - queryRunner, - ); - - if (!tradeData || isMinimumQuantity(tradeData.quantity)) { - return 0; - } - const { ask_price, ask_size } = order; - const { userId, account, typeReceived, krw } = bidDto; - - const buyData = { ...tradeData }; - buyData.quantity = formatQuantity( - tradeData.quantity >= ask_size ? ask_size : tradeData.quantity, - ); - - if (isMinimumQuantity(buyData.quantity)) { - return 0; - } - - buyData.price = formatQuantity(ask_price * krw); - const user = await this.userRepository.getUser(userId); - - await this.tradeHistoryRepository.createTradeHistory( - user, - buyData, - queryRunner, - ); - - const asset = await this.assetRepository.getAsset( - account.id, - typeReceived, - queryRunner, - ); - - await this.processAssetUpdate(bidDto, asset, buyData, queryRunner); - await this.updateAccountBalances(bidDto, buyData, queryRunner); - return await this.updateTradeData(tradeData, buyData, queryRunner); - } - - private async processAssetUpdate( - bidDto: TradeData, - asset: any, - buyData: any, - queryRunner: QueryRunner, - ): Promise { - if (asset) { - asset.price = formatQuantity( - asset.price + buyData.price * buyData.quantity, - ); - asset.quantity = formatQuantity(asset.quantity + buyData.quantity); - asset.availableQuantity = formatQuantity( - asset.availableQuantity + buyData.quantity, - ); - - await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); - } else { - await this.assetRepository.createAsset( - bidDto.typeReceived, - bidDto.account, - formatQuantity(buyData.price * buyData.quantity), - formatQuantity(buyData.quantity), - queryRunner, - ); - } - } - - private async updateAccountBalances( - bidDto: TradeData, - buyData: any, - queryRunner: QueryRunner, - ): Promise { - const { account, typeGiven, typeReceived } = bidDto; - - if (typeReceived === 'BTC') { - const btcQuantity = formatQuantity(account.BTC + buyData.quantity); - await this.accountRepository.updateAccountBTC( - account.id, - btcQuantity, - queryRunner, - ); - } - - const returnChange = formatQuantity( - account[typeGiven] - buyData.price * buyData.quantity, + private transactionCreateBid: boolean = false; + private isProcessing: { [key: number]: boolean } = {}; + + onModuleInit() { + this.startPendingTradesProcessor(); + } + + private startPendingTradesProcessor() { + const processBidTrades = async () => { + try { + await this.processPendingTrades( + TRADE_TYPES.BUY, + this.bidTradeService.bind(this), + ); + } finally { + setTimeout(processBidTrades, UPBIT_UPDATED_COIN_INFO_TIME); + } + }; + processBidTrades(); + } + + async calculatePercentBuy( + user: any, + moneyType: string, + percent: number, + ): Promise { + const account = await this.accountRepository.findOne({ + where: { user: { id: user.userId } }, + }); + + const balance = account[moneyType]; + return formatQuantity(balance * (percent / 100)); + } + + async createBidTrade(user: any, bidDto: TradeData): Promise { + if (isMinimumQuantity(bidDto.receivedAmount * bidDto.receivedPrice)) { + throw new BadRequestException('최소 거래 금액보다 작습니다.'); + } + + if (this.transactionCreateBid) { + await this.waitForTransaction(() => this.transactionCreateBid); + } + this.transactionCreateBid = true; + + try { + let userTrade; + const transactionResult = await this.executeTransaction( + async (queryRunner) => { + if (bidDto.receivedAmount <= 0) { + throw new BadRequestException('수량은 0보다 커야 합니다.'); + } + + const userAccount = await this.accountRepository.validateUserAccount( + user.userId, + ); + const accountBalance = await this.checkCurrencyBalance( + bidDto, + userAccount, + ); + + await this.accountRepository.updateAccountCurrency( + 'availableKRW', + accountBalance, + userAccount.id, + queryRunner, + ); + + userTrade = await this.tradeRepository.createTrade( + bidDto, + user.userId, + TRADE_TYPES.BUY, + queryRunner, + ); + return { + statusCode: 200, + message: '거래가 정상적으로 등록되었습니다.', + }; + }, + ); + if (transactionResult.statusCode === 200) { + const tradeData: TradeDataRedis = { + tradeId: userTrade.tradeId, + userId: user.userId, + tradeType: TRADE_TYPES.BUY, + tradeCurrency: bidDto.typeGiven, + assetName: bidDto.typeReceived, + price: bidDto.receivedPrice, + quantity: bidDto.receivedAmount, + createdAt: userTrade.createdAt, + }; + + await this.redisRepository.createTrade(tradeData); + } + return transactionResult; + } finally { + this.transactionCreateBid = false; + } + } + + private async checkCurrencyBalance( + bidDto: TradeData, + account: any, + ): Promise { + const { receivedPrice, receivedAmount } = bidDto; + const balance = account.availableKRW; + + const givenAmount = formatQuantity(receivedPrice * receivedAmount); + const remaining = formatQuantity(balance - givenAmount); + + if (remaining < 0) { + throw new UnprocessableEntityException('자산이 부족합니다.'); + } + + return remaining; + } + + private async bidTradeService(bidDto: TradeData): Promise { + if (this.isProcessing[bidDto.tradeId]) { + return; + } + + this.isProcessing[bidDto.tradeId] = true; + + try { + const { userId, typeGiven } = bidDto; + // 트랜잭션 없이 계정 조회 + const account = await this.accountRepository.findOne({ + where: { user: { id: userId } }, + }); + + bidDto.accountBalance = account[typeGiven]; + bidDto.account = account; + + const orderbook = + this.coinDataUpdaterService.getCoinOrderbookByBid(bidDto); + + for (const order of orderbook) { + try { + if (order.ask_price > bidDto.receivedPrice) break; + const tradeResult = await this.executeTransaction( + async (queryRunner) => { + const remainingQuantity = await this.executeBidTrade( + bidDto, + order, + queryRunner, + ); + + return !isMinimumQuantity(remainingQuantity); + }, + ); + + if (!tradeResult) break; + } catch (error) { + if (error instanceof TradeNotFoundException) { + break; + } + throw error; + } + } + } finally { + delete this.isProcessing[bidDto.tradeId]; + } + } + + private async executeBidTrade( + bidDto: TradeData, + order: OrderBookEntry, + queryRunner: QueryRunner, + ): Promise { + const tradeData = await this.tradeRepository.findTradeWithLock( + bidDto.tradeId, + queryRunner, ); - - const returnAvailableChange = formatQuantity( - account.availableKRW - buyData.price * buyData.quantity, + if (!tradeData || isMinimumQuantity(tradeData.quantity)) { + return 0; + } + const { ask_price, ask_size } = order; + const { userId, account, typeReceived, krw } = bidDto; + + const buyData = { ...tradeData }; + buyData.quantity = formatQuantity( + tradeData.quantity >= ask_size ? ask_size : tradeData.quantity, ); - await this.accountRepository.updateAccountCurrency( - typeGiven, - returnChange, - account.id, - queryRunner, - ); - - await this.accountRepository.updateAccountCurrency( - 'availableKRW', - returnAvailableChange, - account.id, - queryRunner, - ); - } - - private async waitForTransaction( - checkCondition: () => boolean, - ): Promise { - return new Promise((resolve) => { - const check = () => { - if (!checkCondition()) resolve(); - else setTimeout(check, TRANSACTION_CHECK_INTERVAL); - }; - check(); - }); - } + if (isMinimumQuantity(buyData.quantity)) { + return 0; + } + + buyData.price = formatQuantity(ask_price * krw); + const user = await this.userRepository.getUser(userId); + + await this.tradeHistoryRepository.createTradeHistory( + user, + buyData, + queryRunner, + ); + + const asset = await this.assetRepository.getAsset( + account.id, + typeReceived, + queryRunner, + ); + + await this.processAssetUpdate(bidDto, asset, buyData, queryRunner); + await this.updateAccountBalances(bidDto, buyData, queryRunner); + return await this.updateTradeData(tradeData, buyData, queryRunner); + } + + private async processAssetUpdate( + bidDto: TradeData, + asset: any, + buyData: any, + queryRunner: QueryRunner, + ): Promise { + if (asset) { + asset.price = formatQuantity( + asset.price + buyData.price * buyData.quantity, + ); + asset.quantity = formatQuantity(asset.quantity + buyData.quantity); + asset.availableQuantity = formatQuantity( + asset.availableQuantity + buyData.quantity, + ); + + await this.assetRepository.updateAssetQuantityPrice(asset, queryRunner); + } else { + await this.assetRepository.createAsset( + bidDto.typeReceived, + bidDto.account, + formatQuantity(buyData.price * buyData.quantity), + formatQuantity(buyData.quantity), + queryRunner, + ); + } + } + + private async updateAccountBalances( + bidDto: TradeData, + buyData: any, + queryRunner: QueryRunner, + ): Promise { + const { account, typeGiven, typeReceived } = bidDto; + const userAccount = await this.accountRepository.getAccount(account.id, queryRunner); + + if (typeReceived === 'BTC') { + const btcQuantity = formatQuantity(account.BTC + buyData.quantity); + await this.accountRepository.updateAccountBTC( + userAccount.id, + btcQuantity, + queryRunner, + ); + } + + const change = formatQuantity( + (bidDto.receivedPrice - buyData.price) * buyData.quantity, + ); + + const returnChange = formatQuantity( + userAccount[typeGiven] - buyData.price * buyData.quantity, + ); + + await this.accountRepository.updateAccountCurrency( + typeGiven, + returnChange, + account.id, + queryRunner, + ); + + await this.accountRepository.updateAccountAvailableCurrency( + change, + account.id, + queryRunner, + ); + } + + private async waitForTransaction( + checkCondition: () => boolean, + ): Promise { + return new Promise((resolve) => { + const check = () => { + if (!checkCondition()) resolve(); + else setTimeout(check, TRANSACTION_CHECK_INTERVAL); + }; + check(); + }); + } }