diff --git a/apps/backend/src/global/successhandler.ts b/apps/backend/src/global/successhandler.ts index 55e71ac..2ce84a2 100644 --- a/apps/backend/src/global/successhandler.ts +++ b/apps/backend/src/global/successhandler.ts @@ -15,6 +15,9 @@ export const successMessage = { GET_MAIL_SUCCESS: { code: 200, message: '메일 조회를 완료했습니다.' }, DELETE_MAIL_SUCCESS: { code: 200, message: '메일 삭제를 완료했습니다.' }, GET_MAIL_ALARM_SUCCESS: { code: 200, message: 'Catch alarm!.' }, + GET_TRANSACTION_SUCCESS: { code: 200, message: '거래 내역 조회를 완료했습니다.' }, + CREATE_ORDER_SUCCESS: { code: 201, message: '주문이 성공적으로 생성되었습니다.' }, + DELETE_ORDER_SUCCESS: { code: 201, message: '주문이 성공적으로 삭제되었습니다.' }, BUY_LOTTO_SUCCESS: { code: 200, message: '로또 구매를 완료했습니다.' }, GET_TOP5_RANK_SUCCESS: { code: 200, message: '상위 5명을 조회했습니다.' }, GET_RANK_SUCCESS: { code: 200, message: '현재 랭킹을 조회했습니다.' } diff --git a/apps/backend/src/market/dto/cropPrice.dto.ts b/apps/backend/src/market/dto/cropPrice.dto.ts index 76785b9..e90b6b2 100644 --- a/apps/backend/src/market/dto/cropPrice.dto.ts +++ b/apps/backend/src/market/dto/cropPrice.dto.ts @@ -1,4 +1,4 @@ export interface CropPrice { - crop: string; + crop: number; price: number; } diff --git a/apps/backend/src/market/market.controller.ts b/apps/backend/src/market/market.controller.ts index 566c72a..a7b350e 100644 --- a/apps/backend/src/market/market.controller.ts +++ b/apps/backend/src/market/market.controller.ts @@ -5,13 +5,13 @@ import { MarketService } from './market.service'; export class MarketController { constructor(private readonly marketService: MarketService) {} - @Get(':crop/price') - getPrice(@Param('crop') crop: string) { + @Get('price/:crop') + async getPrice(@Param('crop') crop: number) { return this.marketService.getCropPrice(crop); } @Get('crop/prices') - getAllPrices() { + async getAllPrices() { return this.marketService.getAllCropPrices(); } } diff --git a/apps/backend/src/market/market.module.ts b/apps/backend/src/market/market.module.ts index ee46666..eecffdd 100644 --- a/apps/backend/src/market/market.module.ts +++ b/apps/backend/src/market/market.module.ts @@ -4,6 +4,7 @@ import { MarketService } from './market.service'; @Module({ controllers: [MarketController], - providers: [MarketService] + providers: [MarketService], + exports: [MarketService] }) export class MarketModule {} diff --git a/apps/backend/src/market/market.service.ts b/apps/backend/src/market/market.service.ts index efa4532..3836723 100644 --- a/apps/backend/src/market/market.service.ts +++ b/apps/backend/src/market/market.service.ts @@ -9,11 +9,11 @@ export class MarketService { constructor(@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType) {} async setCropPrice(data: CropPrice): Promise { - await this.redisClient.hSet(this.redisKey, data.crop, data.price.toString()); + await this.redisClient.hSet(this.redisKey, data.crop.toString(), data.price.toString()); } - async getCropPrice(crop: string): Promise { - const price = await this.redisClient.hGet(this.redisKey, crop); + async getCropPrice(crop: number): Promise { + const price = await this.redisClient.hGet(this.redisKey, crop.toString()); if (!price) { return null; @@ -21,8 +21,19 @@ export class MarketService { return { crop, price: parseFloat(price) }; } + async getCropIdFromName(crop: string): Promise { + const cropId = await this.redisClient.hGet(this.redisKey, crop); + if (!cropId) { + return -1; + } + return parseInt(cropId); + } + async getAllCropPrices(): Promise { const prices = await this.redisClient.hGetAll(this.redisKey); - return Object.entries(prices).map(([crop, price]) => ({ crop, price: parseFloat(price) })); + return Object.entries(prices).map(([crop, price]) => ({ + crop: parseInt(crop), + price: parseFloat(price) + })); } } diff --git a/apps/backend/src/order/dto/orderBook.dto.ts b/apps/backend/src/order/dto/orderBook.dto.ts index 8d434a7..7f4b7db 100644 --- a/apps/backend/src/order/dto/orderBook.dto.ts +++ b/apps/backend/src/order/dto/orderBook.dto.ts @@ -1,19 +1,31 @@ import { ApiProperty } from '@nestjs/swagger'; -import { OrderType } from '../enums/orderType'; +import { OrderType, TradingType } from '../enums/orderType'; export class OrderBookDto { @ApiProperty({ description: '주문 ID', example: 1 }) orderId: number; + @ApiProperty({ description: '회원 ID', example: 15 }) + memberId: number; + @ApiProperty({ description: '상품 ID', example: 1 }) cropId: number; @ApiProperty({ description: '주문 유형', example: 'buy' }) orderType: OrderType; + @ApiProperty({ description: '오더 유형', example: 'limit' }) + tradingType: TradingType; + @ApiProperty({ description: '가격', example: 50 }) price: number; + @ApiProperty({ description: '주문 수량', example: 150 }) + quantity: number; + + @ApiProperty({ description: '체결된 수량', example: 50 }) + filledQuantity: number; + @ApiProperty({ description: '미체결 수량', example: 100 }) unfilledQuantity: number; diff --git a/apps/backend/src/order/matching.service.ts b/apps/backend/src/order/matching.service.ts index 5572c48..5d951c9 100644 --- a/apps/backend/src/order/matching.service.ts +++ b/apps/backend/src/order/matching.service.ts @@ -1,14 +1,20 @@ import { Injectable } from '@nestjs/common'; import { OrderBookService } from './orderBook.service'; -import { OrderType } from './enums/orderType'; +import { OrderStatus, OrderType } from './enums/orderType'; +import { OrderService } from './order.service'; +import { MarketService } from '../market/market.service'; @Injectable() export class MatchingService { - constructor(private readonly orderBookService: OrderBookService) {} + constructor( + private readonly orderBookService: OrderBookService, + private readonly orderService: OrderService, + private readonly marketService: MarketService + ) {} async matchOrders(cropId: number): Promise { - const buyOrders = await this.orderBookService.getBuyOrders(cropId); - const sellOrders = await this.orderBookService.getSellOrders(cropId); + const buyOrders = await this.orderBookService.getBuyOrdersFromRedis(cropId); + const sellOrders = await this.orderBookService.getSellOrdersFromRedis(cropId); let sellIndex = 0; let buyIndex = 0; @@ -24,7 +30,51 @@ export class MatchingService { const matchedQuantity = Math.min(sellOrder.unfilledQuantity, buyOrder.unfilledQuantity); //TODO DB 트랜잭션 업데이트,체결 이벤트 발생 + // 1. 주문 DB 업데이트 v + // 2. 체결 트랜잭션 DB 생성 및 저장 v + // 3. 레디스 오더북 수정 v + // 4. 현재 가격 업데이트 (레디스) v + // 5. 체결 이벤트 발생 + // 이후 트랜잭션 적용 및 분리 예정 + // 1. 주문 DB 업데이트 + if (sellOrder.unfilledQuantity <= matchedQuantity) { + await this.orderService.updateOrder( + sellOrder.orderId, + OrderStatus.COMPLETED, + matchedQuantity, + sellOrder.unfilledQuantity - matchedQuantity + ); + } else if (sellOrder.unfilledQuantity > matchedQuantity) { + await this.orderService.updateOrder( + sellOrder.orderId, + OrderStatus.PARTIALLY_FILLED, + matchedQuantity, + sellOrder.unfilledQuantity - matchedQuantity + ); + } + + if (buyOrder.unfilledQuantity <= matchedQuantity) { + await this.orderService.updateOrder( + buyOrder.orderId, + OrderStatus.COMPLETED, + matchedQuantity, + buyOrder.unfilledQuantity - matchedQuantity + ); + } else if (buyOrder.unfilledQuantity > matchedQuantity) { + await this.orderService.updateOrder( + buyOrder.orderId, + OrderStatus.PARTIALLY_FILLED, + matchedQuantity, + buyOrder.unfilledQuantity - matchedQuantity + ); + } + + // 2. 체결 트랜잭션 DB 생성 및 저장 + await this.orderService.saveTransaction(sellOrder, buyOrder.price, matchedQuantity); + await this.orderService.saveTransaction(buyOrder, buyOrder.price, matchedQuantity); + + // 3. 레디스 오더북 수정 await this.orderBookService.updateOrder( cropId, OrderType.BUY, @@ -38,6 +88,13 @@ export class MatchingService { matchedQuantity ); + // 4. 현재 가격 업데이트 (레디스) + const cropPrice = { + crop: cropId, + price: buyOrder.price + }; + await this.marketService.setCropPrice(cropPrice); + if (sellOrder.unfilledQuantity <= matchedQuantity) { sellIndex++; } diff --git a/apps/backend/src/order/order.controller.ts b/apps/backend/src/order/order.controller.ts index 2961acf..ba1b1de 100644 --- a/apps/backend/src/order/order.controller.ts +++ b/apps/backend/src/order/order.controller.ts @@ -1,56 +1,52 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { OrderService } from './order.service'; import { OrderBookService } from './orderBook.service'; -import { OrderBookDto } from './dto/orderBook.dto'; import DtoTransformer from './utils/dtoTransformer'; import { LimitOrderDto } from './dto/limitOrder.dto'; -import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiOperation } from '@nestjs/swagger'; +import { MatchingService } from './matching.service'; +import { successhandler, successMessage } from '../global/successhandler'; @Controller('api/order') export class OrderController { constructor( private readonly orderService: OrderService, - private readonly orderBookService: OrderBookService + private readonly orderBookService: OrderBookService, + private readonly machineService: MatchingService ) {} - @Post('buy') + @Post('buy/limit') @ApiOperation({ summary: '구매 주문 생성' }) - @ApiResponse({ status: 200, description: '구매 주문이 성공적으로 생성되었습니다.' }) - async createBuyOrder(@Body() limitOrderDto: LimitOrderDto): Promise { + async createBuyOrder(@Body() limitOrderDto: LimitOrderDto) { const orderDto = DtoTransformer.toOrderDto(limitOrderDto); - const orderId = await this.orderService.saveOrder(orderDto); - - await this.orderService.saveOrderToOrderBook(orderDto, orderId); - return '구매 주문이 성공적으로 생성되었습니다.'; + await this.orderService.saveOrder(orderDto); + await this.machineService.matchOrders(limitOrderDto.cropId); + return successhandler(successMessage.CREATE_ORDER_SUCCESS); } - @Post('sell') + @Post('sell/limit') @ApiOperation({ summary: '판매 주문 생성' }) - @ApiResponse({ status: 200, description: '판매 주문이 성공적으로 생성되었습니다.' }) - async createSellOrder(@Body() limitOrderDto: LimitOrderDto): Promise { + async createSellOrder(@Body() limitOrderDto: LimitOrderDto) { const orderDto = DtoTransformer.toOrderDto(limitOrderDto); - const orderId = await this.orderService.saveOrder(orderDto); - - await this.orderService.saveOrderToOrderBook(orderDto, orderId); - return '판매 주문이 성공적으로 생성되었습니다.'; - } - - @Get('buy/:cropId') - async getBuyOrders(@Param('cropId') cropId: number): Promise { - return await this.orderBookService.getBuyOrders(cropId); + await this.orderService.saveOrder(orderDto); + await this.machineService.matchOrders(limitOrderDto.cropId); + return successhandler(successMessage.CREATE_ORDER_SUCCESS); } - @Get('sell/:cropId') - async getSellOrders(@Param('cropId') cropId: number): Promise { - return await this.orderBookService.getSellOrders(cropId); + @Get('') + @ApiOperation({ summary: '각 회원 체결 내역 조회' }) + async getTransactionsByMemberId(@Query('memberId') memberId: number) { + const transactions = await this.orderBookService.getTransactionsByMemberId(memberId); + return successhandler(successMessage.GET_TRANSACTION_SUCCESS, transactions); } @Post('cancel') + @ApiOperation({ summary: '주문 취소' }) async cancelOrder( @Body() { cropId, orderId, orderType }: { cropId: number; orderId: number; orderType: 'buy' | 'sell' } - ): Promise { + ) { await this.orderBookService.removeOrder(cropId, orderId, orderType); - return '주문이 성공적으로 취소되었습니다.'; + return successhandler(successMessage.DELETE_ORDER_SUCCESS); } } diff --git a/apps/backend/src/order/order.module.ts b/apps/backend/src/order/order.module.ts index ab022dc..b324eaa 100644 --- a/apps/backend/src/order/order.module.ts +++ b/apps/backend/src/order/order.module.ts @@ -4,10 +4,12 @@ import { OrderController } from './order.controller'; import { OrderBookService } from './orderBook.service'; import { OrderRepository } from './order.repository'; import { DatabaseModule } from '../database/database.module'; +import { MatchingService } from './matching.service'; +import { MarketModule } from '../market/market.module'; @Module({ - providers: [OrderService, OrderBookService, OrderRepository], + providers: [OrderService, OrderBookService, OrderRepository, MatchingService], controllers: [OrderController], - imports: [DatabaseModule] + imports: [DatabaseModule, MarketModule] }) export class OrderModule {} diff --git a/apps/backend/src/order/order.repository.ts b/apps/backend/src/order/order.repository.ts index 84dfdff..02697d8 100644 --- a/apps/backend/src/order/order.repository.ts +++ b/apps/backend/src/order/order.repository.ts @@ -2,12 +2,13 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { OrderDto } from './dto/order.dto'; import { OrderStatus } from './enums/orderType'; +import { OrderBookDto } from './dto/orderBook.dto'; @Injectable() export class OrderRepository { constructor(private readonly databaseService: DatabaseService) {} - async saveOrder(order: OrderDto): Promise { + async saveOrder(order: OrderDto): Promise { const query = ` INSERT INTO orders (crop_id, member_id, @@ -19,7 +20,7 @@ export class OrderRepository { filled_quantity, unfilled_quantity, time) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING order_id + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING order_id, member_id `; const values = [ @@ -36,6 +37,67 @@ export class OrderRepository { ]; const result = await this.databaseService.query(query, values); - return result.rows[0].order_id; + return [result.rows[0].order_id, result.rows[0].member_id]; + } + + async updateOrder({ + orderId, + status, + filledQuantity, + unfilledQuantity + }: { + orderId: number; + status: OrderStatus; + filledQuantity: number; + unfilledQuantity: number; + }): Promise { + const query = ` + UPDATE orders + SET status = $1, + filled_quantity = $2, + unfilled_quantity = $3 + WHERE order_id = $4 + `; + + const values = [status, filledQuantity, unfilledQuantity, orderId]; + + await this.databaseService.query(query, values); + } + + async saveTransaction( + order: OrderBookDto, + price: number, + matchedQuantity: number + ): Promise { + const query = ` + INSERT INTO transactions (order_id, member_id, crop_id, trading_type, price, total_price, amount) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `; + + console.log(order); + const values = [ + order.orderId, + order.memberId, + order.cropId, + order.tradingType, + price, + price * matchedQuantity, + matchedQuantity + ]; + + await this.databaseService.query(query, values); + } + + async getTransactionsByMemberId(memberId: number): Promise { + const query = ` + SELECT * + FROM transactions + WHERE member_id = $1 + `; + + const values = [memberId]; + + const result = await this.databaseService.query(query, values); + return result.rows; } } diff --git a/apps/backend/src/order/order.service.ts b/apps/backend/src/order/order.service.ts index daea44d..f0aceee 100644 --- a/apps/backend/src/order/order.service.ts +++ b/apps/backend/src/order/order.service.ts @@ -4,6 +4,8 @@ import { OrderRepository } from './order.repository'; import { OrderDto } from './dto/order.dto'; import { LimitOrderDto } from './dto/limitOrder.dto'; import DtoTransformer from './utils/dtoTransformer'; +import { OrderStatus } from './enums/orderType'; +import { OrderBookDto } from './dto/orderBook.dto'; @Injectable() export class OrderService { @@ -12,17 +14,37 @@ export class OrderService { private readonly orderRepository: OrderRepository ) {} - async saveOrder(createOrderDto: LimitOrderDto): Promise { + async saveOrder(createOrderDto: LimitOrderDto): Promise { const orderDto: OrderDto = DtoTransformer.toOrderDto(createOrderDto); - const orderId = await this.orderRepository.saveOrder(orderDto); + const [orderId, memberId] = await this.orderRepository.saveOrder(orderDto); + await this.saveOrderToOrderBook(orderDto, orderId, memberId); - await this.saveOrderToOrderBook(orderDto, orderId); - - return orderId; + return [orderId, memberId]; } - async saveOrderToOrderBook(order: OrderDto, orderId: number): Promise { - const orderBookDto = DtoTransformer.toOrderBookDto(order, orderId); + private async saveOrderToOrderBook( + order: OrderDto, + orderId: number, + memberId: number + ): Promise { + const orderBookDto = DtoTransformer.toOrderBookDto(order, orderId, memberId); await this.orderBookService.addOrder(orderBookDto); } + + async saveTransaction( + order: OrderBookDto, + price: number, + matchedQuantity: number + ): Promise { + await this.orderRepository.saveTransaction(order, price, matchedQuantity); + } + + async updateOrder( + orderId: number, + status: OrderStatus, + filledQuantity: number, + unfilledQuantity: number + ): Promise { + await this.orderRepository.updateOrder({ orderId, status, filledQuantity, unfilledQuantity }); + } } diff --git a/apps/backend/src/order/orderBook.service.ts b/apps/backend/src/order/orderBook.service.ts index e8e2e12..61fe2c3 100644 --- a/apps/backend/src/order/orderBook.service.ts +++ b/apps/backend/src/order/orderBook.service.ts @@ -2,10 +2,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { RedisClientType } from 'redis'; import { OrderBookDto } from './dto/orderBook.dto'; import { OrderType } from './enums/orderType'; +import { OrderRepository } from './order.repository'; +import { OrderDto } from './dto/order.dto'; @Injectable() export class OrderBookService { - constructor(@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType) {} + constructor( + @Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType, + private readonly repository: OrderRepository + ) {} async addOrder(order: OrderBookDto): Promise { const orderKey = `orderBook:${order.cropId}:${order.orderType}`; @@ -13,28 +18,6 @@ export class OrderBookService { await this.redisClient.zAdd(orderKey, { score: order.price, value: orderData }); } - async getBuyOrders(cropId: number): Promise { - const orderKey = `orderBook:${cropId}:buy`; - const orders = await this.redisClient.zRange(orderKey, 0, -1, { REV: true }); - return orders.map((order: string) => this.deserializeOrder(order)); - } - - async getSellOrders(cropId: number): Promise { - const orderKey = `orderBook:${cropId}:sell`; - const orders = await this.redisClient.zRange(orderKey, 0, -1); - return orders.map((order: string) => this.deserializeOrder(order)); - } - - async removeOrder(cropId: number, orderId: number, orderType: 'buy' | 'sell'): Promise { - const orderKey = `orderBook:${cropId}:${orderType}`; - const orders = await this.redisClient.zRange(orderKey, 0, -1); - - const orderToRemove = orders.find(order => this.deserializeOrder(order).orderId === orderId); - if (orderToRemove) { - await this.redisClient.zRem(orderKey, orderToRemove); - } - } - async updateOrder( cropId: number, orderType: OrderType, @@ -43,8 +26,8 @@ export class OrderBookService { ): Promise { const orders = orderType === OrderType.BUY - ? await this.getBuyOrders(cropId) - : await this.getSellOrders(cropId); + ? await this.getBuyOrdersFromRedis(cropId) + : await this.getSellOrdersFromRedis(cropId); const targetOrder = orders.find(order => order.orderId === orderId); if (!targetOrder) { @@ -59,6 +42,32 @@ export class OrderBookService { } } + async removeOrder(cropId: number, orderId: number, orderType: 'buy' | 'sell'): Promise { + const orderKey = `orderBook:${cropId}:${orderType}`; + const orders = await this.redisClient.zRange(orderKey, 0, -1); + + const orderToRemove = orders.find(order => this.deserializeOrder(order).orderId === orderId); + if (orderToRemove) { + await this.redisClient.zRem(orderKey, orderToRemove); + } + } + + async getTransactionsByMemberId(memberId: number): Promise { + return await this.repository.getTransactionsByMemberId(memberId); + } + + async getBuyOrdersFromRedis(cropId: number): Promise { + const orderKey = `orderBook:${cropId}:sell`; + const orders = await this.redisClient.zRange(orderKey, 0, -1); + return orders.map((order: string) => this.deserializeOrder(order)); + } + + async getSellOrdersFromRedis(cropId: number): Promise { + const orderKey = `orderBook:${cropId}:sell`; + const orders = await this.redisClient.zRange(orderKey, 0, -1); + return orders.map((order: string) => this.deserializeOrder(order)); + } + private serializeOrder(order: OrderBookDto): string { const time = order.time.toISOString(); return JSON.stringify({ ...order, time }); diff --git a/apps/backend/src/order/utils/dtoTransformer.ts b/apps/backend/src/order/utils/dtoTransformer.ts index d9a4858..cbec641 100644 --- a/apps/backend/src/order/utils/dtoTransformer.ts +++ b/apps/backend/src/order/utils/dtoTransformer.ts @@ -4,12 +4,16 @@ import { LimitOrderDto } from '../dto/limitOrder.dto'; import { OrderStatus } from '../enums/orderType'; export default class DtoTransformer { - static toOrderBookDto(order: OrderDto, orderId: number): OrderBookDto { + static toOrderBookDto(order: OrderDto, orderId: number, memberId: number): OrderBookDto { return { orderId, + memberId, cropId: order.cropId, orderType: order.orderType, + tradingType: order.tradingType, price: order.price, + quantity: order.quantity, + filledQuantity: order.filledQuantity, unfilledQuantity: order.quantity, time: order.time };