diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 32bcc36b..4d7679eb 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -4,7 +4,7 @@ on: push: branches: [back/main, front/main] pull_request: - branches: [main, back/main, dev] + branches: [back/main, front/main] jobs: BE-test-and-build: @@ -49,7 +49,7 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: ./BE/package-lock.json + cache-dependency-path: ./FE/package-lock.json - name: Install dependencies working-directory: ./FE diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index cf0c3cd9..e2cdb2a2 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -2,9 +2,9 @@ name: deploy on: push: - branches: [main] + branches: [dev] pull_request: - branches: [main] + branches: [dev] env: DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/juga-docker @@ -14,6 +14,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest strategy: + max-parallel: 1 matrix: app: [ @@ -22,7 +23,7 @@ jobs: ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 @@ -38,9 +39,11 @@ jobs: - name: Install dependencies working-directory: ./${{matrix.app.dir}} + continue-on-error: true run: npm ci - name: Run tests + if: ${{ matrix.app.name == 'be' }} working-directory: ./${{matrix.app.dir}} run: npm test env: diff --git a/.github/workflows/deply-alpha.yml b/.github/workflows/deply-alpha.yml index 155e349b..ec6ec9ce 100644 --- a/.github/workflows/deply-alpha.yml +++ b/.github/workflows/deply-alpha.yml @@ -14,6 +14,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest strategy: + max-parallel: 1 matrix: app: [ @@ -22,7 +23,7 @@ jobs: ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 @@ -38,9 +39,11 @@ jobs: - name: Install dependencies working-directory: ./${{matrix.app.dir}} + continue-on-error: true run: npm ci - name: Run tests + if: ${{ matrix.app.name == 'be' }} working-directory: ./${{matrix.app.dir}} run: npm test env: @@ -97,7 +100,7 @@ jobs: with: host: ${{ secrets.NCP_ALPHA_SERVER_HOST }} username: ${{ secrets.NCP_ALPHA_SERVER_USERNAME }} - key: ${{ secrets.NCP_ALPHA_SERVER_SSH_KEY }} + key: ${{ secrets.NCP_SERVER_SSH_KEY }} port: 22 script: | docker system prune -af diff --git a/BE/.dockerignore b/BE/.dockerignore index 6ccb2fdd..7bd5f175 100644 --- a/BE/.dockerignore +++ b/BE/.dockerignore @@ -2,3 +2,4 @@ Dockerfile node_modules dist +.env* \ No newline at end of file diff --git a/BE/.eslintrc.js b/BE/.eslintrc.js index 0cea1c7c..b54cc5a8 100644 --- a/BE/.eslintrc.js +++ b/BE/.eslintrc.js @@ -34,5 +34,6 @@ module.exports = { 'class-methods-use-this': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/naming-convention': 'off', }, }; diff --git a/BE/Dockerfile b/BE/Dockerfile index e23c0848..60079503 100644 --- a/BE/Dockerfile +++ b/BE/Dockerfile @@ -1,8 +1,17 @@ -FROM node:20 -RUN mkdir -p /var/app -WORKDIR /var/app -COPY . . +# 빌드 스테이지 +FROM node:20-slim as builder +WORKDIR /app +COPY package*.json ./ RUN npm install +COPY . . RUN npm run build + +# 실행 스테이지 +FROM node:20-slim +WORKDIR /var/app +COPY package*.json ./ +RUN npm install --only=production +COPY --from=builder /app/dist ./dist + EXPOSE 3000 -CMD [ "node", "dist/main.js" ] \ No newline at end of file +CMD ["node", "dist/main.js"] \ No newline at end of file diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 8a1dab3d..93e45a4e 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -9,6 +9,8 @@ import { StockIndexModule } from './stock/index/stock-index.module'; import { StockTopfiveModule } from './stock/topfive/stock-topfive.module'; import { KoreaInvestmentModule } from './koreaInvestment/korea-investment.module'; import { SocketModule } from './websocket/socket.module'; +import { StockOrderModule } from './stock/order/stock-order.module'; +import { StockDetailModule } from './stock/detail/stock-detail.module'; import { typeOrmConfig } from './configs/typeorm.config'; @Module({ @@ -21,6 +23,8 @@ import { typeOrmConfig } from './configs/typeorm.config'; StockIndexModule, StockTopfiveModule, SocketModule, + StockDetailModule, + StockOrderModule, ], controllers: [AppController], providers: [AppService], diff --git a/BE/src/auth/auth.controller.ts b/BE/src/auth/auth.controller.ts index 684384be..d7cdd7c0 100644 --- a/BE/src/auth/auth.controller.ts +++ b/BE/src/auth/auth.controller.ts @@ -59,10 +59,10 @@ export class AuthController { ) { const { accessToken, refreshToken } = await this.authService.kakaoLoginUser(authCredentialsDto); - + res.cookie('accessToken', accessToken, { httpOnly: true }); res.cookie('refreshToken', refreshToken, { httpOnly: true }); res.cookie('isRefreshToken', true, { httpOnly: true }); - return res.status(200).json({ accessToken }); + return res.redirect(this.configService.get('FRONTEND_URL')); } @ApiOperation({ summary: 'Refresh Token 요청 API' }) @@ -79,8 +79,9 @@ export class AuthController { const newAccessToken = await this.authService.refreshToken(refreshToken); + res.cookie('accessToken', newAccessToken, { httpOnly: true }); res.cookie('refreshToken', refreshToken, { httpOnly: true }); res.cookie('isRefreshToken', true, { httpOnly: true }); - return res.status(200).json({ accessToken: newAccessToken }); + return res.redirect(this.configService.get('FRONTEND_URL')); } } diff --git a/BE/src/auth/jwt-auth-guard.ts b/BE/src/auth/jwt-auth-guard.ts new file mode 100644 index 00000000..0aa58fd8 --- /dev/null +++ b/BE/src/auth/jwt-auth-guard.ts @@ -0,0 +1,5 @@ +import { AuthGuard } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/BE/src/main.ts b/BE/src/main.ts index bc11d55f..f64342a0 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -9,7 +9,11 @@ async function bootstrap() { setupSwagger(app); app.enableCors({ - origin: ['http://localhost:5173', 'http://223.130.151.42:3000'], + origin: [ + 'http://localhost:5173', + 'http://223.130.151.42:5173', + 'http://223.130.151.42:3000', + ], methods: 'GET, HEAD, PUT, PATH, POST, DELETE', preflightContinue: false, optionsSuccessStatus: 204, diff --git a/BE/src/stock/detail/dto/stock-detail-output1.dto.ts b/BE/src/stock/detail/dto/stock-detail-output1.dto.ts new file mode 100644 index 00000000..aa911bd1 --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-output1.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InquirePriceOutput1Dto { + @ApiProperty({ description: 'HTS 한글 종목명' }) + hts_kor_isnm: string; + + @ApiProperty({ description: '종목코드' }) + stck_shrn_iscd: string; + + @ApiProperty({ description: '주식 현재가' }) + stck_prpr: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; + + @ApiProperty({ description: 'HTS 시가총액' }) + hts_avls: string; + + @ApiProperty({ description: 'PER' }) + per: string; +} diff --git a/BE/src/stock/detail/dto/stock-detail-output2.dto.ts b/BE/src/stock/detail/dto/stock-detail-output2.dto.ts new file mode 100644 index 00000000..c6df900f --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-output2.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InquirePriceOutput2Dto { + @ApiProperty({ description: '주식 영업 일자' }) + stck_bsop_date: string; + + @ApiProperty({ description: '주식 종가' }) + stck_clpr: string; + + @ApiProperty({ description: '주식 시가' }) + stck_oprc: string; + + @ApiProperty({ description: '주식 최고가' }) + stck_hgpr: string; + + @ApiProperty({ description: '주식 최저가' }) + stck_lwpr: string; + + @ApiProperty({ description: '누적 거래량' }) + acml_vol: string; + + @ApiProperty({ description: '누적 거래 대금' }) + acml_tr_pbmn: string; + + @ApiProperty({ description: '락 구분 코드' }) + flng_cls_code: string; + + @ApiProperty({ description: '분할 비율' }) + prtt_rate: string; + + @ApiProperty({ description: '분할변경여부' }) + mod_yn: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '재평가사유코드' }) + revl_issu_reas: string; +} diff --git a/BE/src/stock/detail/dto/stock-detail-query-parameter.dto.ts b/BE/src/stock/detail/dto/stock-detail-query-parameter.dto.ts new file mode 100644 index 00000000..feb9ca0f --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-query-parameter.dto.ts @@ -0,0 +1,34 @@ +/** + * 주식 현재가 시세 API를 사용할 때 쿼리 파라미터로 사용할 요청값 DTO + */ +export class StockDetailQueryParameterDto { + /** + * 조건 시장 분류 코드 + * 'J' 주식 + */ + fid_cond_mrkt_div_code: string; + + /** + * 주식 종목 코드 + * (ex) 005930 + */ + fid_input_iscd: string; + + /** + * 조회 시작일자 + * (ex) 20220501 + */ + fid_input_date_1: string; + + /** + * 조회 종료일자 + * (ex) 20220530 + */ + fid_input_date_2: string; + + /** + * 기간 분류 코드 + * D:일봉, W:주봉, M:월봉, Y:년봉 + */ + fid_period_div_code: string; +} diff --git a/BE/src/stock/detail/dto/stock-detail-request.dto.ts b/BE/src/stock/detail/dto/stock-detail-request.dto.ts new file mode 100644 index 00000000..7bd1b1d8 --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 국내주식기간별시세(일/주/월/년) API를 이용할 때 필요한 요청 데이터를 담고 있는 DTO + */ +export class StockDetailRequestDto { + @ApiProperty({ description: '조회 시작일자 (ex) 20220501' }) + fid_input_date_1: string; + + @ApiProperty({ description: '조회 종료일자 (ex) 20220530' }) + fid_input_date_2: string; + + @ApiProperty({ + description: '기간 분류 코드 (ex) D(일봉) W(주봉) M(월봉) Y(년봉)', + }) + fid_period_div_code: string; +} diff --git a/BE/src/stock/detail/dto/stock-detail-response.dto.ts b/BE/src/stock/detail/dto/stock-detail-response.dto.ts new file mode 100644 index 00000000..a86f9e04 --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { InquirePriceOutput1Dto } from './stock-detail-output1.dto'; +import { InquirePriceOutput2Dto } from './stock-detail-output2.dto'; + +/** + * 국내주식기간별시세(일/주/월/년) API 응답값 정제 후 FE에 보낼 DTO + */ +export class InquirePriceResponseDto { + @ApiProperty({ type: InquirePriceOutput1Dto, description: '상승률 순위' }) + output1: InquirePriceOutput1Dto; + + @ApiProperty({ type: [InquirePriceOutput2Dto], description: '하락률 순위' }) + output2: InquirePriceOutput2Dto[]; +} diff --git a/BE/src/stock/detail/interface/stock-detail.interface.ts b/BE/src/stock/detail/interface/stock-detail.interface.ts new file mode 100644 index 00000000..1169eb90 --- /dev/null +++ b/BE/src/stock/detail/interface/stock-detail.interface.ts @@ -0,0 +1,56 @@ +export interface InquirePriceOutput1Data { + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + stck_prdy_clpr: string; + acml_vol: string; + acml_tr_pbmn: string; + hts_kor_isnm: string; + stck_prpr: string; + stck_shrn_iscd: string; + prdy_vol: string; + stck_mxpr: string; + stck_llam: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_prdy_oprc: string; + stck_prdy_hgpr: string; + stck_prdy_lwpr: string; + askp: string; + bidp: string; + prdy_vrss_vol: string; + vol_tnrt: string; + stck_fcam: string; + lstn_stcn: string; + cpfn: string; + hts_avls: string; + per: string; + eps: string; + pbr: string; + itewhol_loan_rmnd_ratem_name: string; +} + +export interface InquirePriceOutput2Data { + stck_bsop_date: string; + stck_clpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + acml_vol: string; + acml_tr_pbmn: string; + flng_cls_code: string; + prtt_rate: string; + mod_yn: string; + prdy_vrss_sign: string; + prdy_vrss: string; + revl_issu_reas: string; +} + +export interface InquirePriceApiResponse { + output1: InquirePriceOutput1Data; + output2: InquirePriceOutput2Data[]; + rt_cd: string; + msg_cd: string; + msg1: string; +} diff --git a/BE/src/stock/detail/stock-detail.controller.ts b/BE/src/stock/detail/stock-detail.controller.ts new file mode 100644 index 00000000..5c4cfa85 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { StockDetailService } from './stock-detail.service'; +import { StockDetailRequestDto } from './dto/stock-detail-request.dto'; +import { InquirePriceResponseDto } from './dto/stock-detail-response.dto'; + +@Controller('/api/stocks') +export class StockDetailController { + constructor(private readonly stockDetailService: StockDetailService) {} + + @Post(':stockCode') + @ApiOperation({ summary: '단일 주식 종목 detail 페이지 상단부 조회 API' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiBody({ + description: + '주식 상세 조회에 필요한 데이터\n\n' + + 'fid_input_date_1: 조회 시작일자 (ex) 20240505\n\n' + + 'fid_input_date_2: 조회 종료일자 (ex) 20241111\n\n' + + 'fid_period_div_code: 기간 분류 코드 (ex) D(일봉), W(주봉), M(월봉), Y(년봉)', + type: StockDetailRequestDto, + }) + @ApiResponse({ + status: 201, + description: '단일 주식 종목 기본값 조회 성공', + type: InquirePriceResponseDto, + }) + getStockDetail( + @Param('stockCode') stockCode: string, + @Body() body: StockDetailRequestDto, + ) { + const { fid_input_date_1, fid_input_date_2, fid_period_div_code } = body; + return this.stockDetailService.getInquirePrice( + stockCode, + fid_input_date_1, + fid_input_date_2, + fid_period_div_code, + ); + } +} diff --git a/BE/src/stock/detail/stock-detail.module.ts b/BE/src/stock/detail/stock-detail.module.ts new file mode 100644 index 00000000..cfb2b57b --- /dev/null +++ b/BE/src/stock/detail/stock-detail.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; +import { StockDetailController } from './stock-detail.controller'; +import { StockDetailService } from './stock-detail.service'; + +@Module({ + imports: [KoreaInvestmentModule], + controllers: [StockDetailController], + providers: [StockDetailService], +}) +export class StockDetailModule {} diff --git a/BE/src/stock/detail/stock-detail.service.ts b/BE/src/stock/detail/stock-detail.service.ts new file mode 100644 index 00000000..c0629406 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.service.ts @@ -0,0 +1,144 @@ +import axios from 'axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { KoreaInvestmentService } from '../../koreaInvestment/korea-investment.service'; +import { getHeader } from '../../util/get-header'; +import { getFullURL } from '../../util/get-full-URL'; +import { InquirePriceApiResponse } from './interface/stock-detail.interface'; +import { StockDetailQueryParameterDto } from './dto/stock-detail-query-parameter.dto'; +import { InquirePriceResponseDto } from './dto/stock-detail-response.dto'; + +@Injectable() +export class StockDetailService { + private readonly logger = new Logger(); + + constructor(private readonly koreaInvetmentService: KoreaInvestmentService) {} + + /** + * 특정 주식의 기간별시세 데이터를 반환하는 함수 + * @param {string} stockCode - 종목코드 + * @param {string} date1 - 조회 시작일자 + * @param {string} date2 - 조회 종료일자 + * @param {string} periodDivCode - 기간 분류 코드 + * @returns - 특정 주식의 기간별시세 데이터 객체 반환 + * + * @author uuuo3o + */ + async getInquirePrice( + stockCode: string, + date1: string, + date2: string, + periodDivCode: string, + ) { + try { + const queryParams = new StockDetailQueryParameterDto(); + queryParams.fid_cond_mrkt_div_code = 'J'; + queryParams.fid_input_iscd = stockCode; + queryParams.fid_input_date_1 = date1; + queryParams.fid_input_date_2 = date2; + queryParams.fid_period_div_code = periodDivCode; + + const response = await this.requestApi(queryParams); + + return this.formatStockData(response); + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, // 실제 요청 헤더 + message: error.message, + }); + throw error; + } + } + + /** + * @private 한국투자 Open API - [국내주식] 기본시세 - 국내주식기간별시세(일/주/월/년) 호출 함수 + * @param {StockDetailQueryParameterDto} queryParams - API 요청 시 필요한 쿼리 파라미터 DTO + * @returns - 국내주식기간별시세(일/주/월/년) 데이터 + * + * @author uuuo3o + */ + private async requestApi(queryParams: StockDetailQueryParameterDto) { + try { + const accessToken = await this.koreaInvetmentService.getAccessToken(); + const headers = getHeader(accessToken, 'FHKST03010100'); + const url = getFullURL( + '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice', + ); + const params = this.getInquirePriceParams(queryParams); + + const response = await axios.get(url, { + headers, + params, + }); + + return response.data; + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, + message: error.message, + }); + throw error; + } + } + + /** + * @private API에서 받은 국내주식기간별시세(일/주/월/년) 데이터를 필요한 정보로 정제하는 함수 + * @param {InquirePriceApiResponse} response - API 응답에서 받은 원시 데이터 + * @returns - 필요한 정보만 추출한 데이터 배열 + * + * @author uuuo3o + */ + private formatStockData(response: InquirePriceApiResponse) { + const stockData = new InquirePriceResponseDto(); + const { output1, output2 } = response; + + const { + hts_kor_isnm, + stck_shrn_iscd, + stck_prpr, + prdy_vrss, + prdy_vrss_sign, + prdy_ctrt, + hts_avls, + per, + } = output1; + + stockData.output1 = { + hts_kor_isnm, + stck_shrn_iscd, + stck_prpr, + prdy_vrss, + prdy_vrss_sign, + prdy_ctrt, + hts_avls, + per, + }; + + stockData.output2 = output2; + + return stockData; + } + + /** + * @private 국내주식기간별시세(일/주/월/년) 요청을 위한 쿼리 파라미터 객체 생성 함수 + * @param {StockDetailQueryParameterDto} params - API 요청에 필요한 쿼리 파라미터 DTO + * @returns - API 요청에 필요한 쿼리 파라미터 객체 + * + * @author uuuo3o + */ + private getInquirePriceParams(params: StockDetailQueryParameterDto) { + return { + fid_cond_mrkt_div_code: params.fid_cond_mrkt_div_code, + fid_input_iscd: params.fid_input_iscd, + fid_input_date_1: params.fid_input_date_1, + fid_input_date_2: params.fid_input_date_2, + fid_period_div_code: params.fid_period_div_code, + fid_org_adj_prc: 0, + }; + } +} diff --git a/BE/src/stock/order/dto/stock-order-request.dto.ts b/BE/src/stock/order/dto/stock-order-request.dto.ts new file mode 100644 index 00000000..b06a3dcb --- /dev/null +++ b/BE/src/stock/order/dto/stock-order-request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNumber, IsPositive } from 'class-validator'; + +export class StockOrderRequestDto { + @ApiProperty({ description: '주식 id' }) + @IsInt() + @IsPositive() + stock_id: number; + + @ApiProperty({ description: '매수/매도 희망 가격' }) + @IsNumber() + @IsPositive() + price: number; + + @ApiProperty({ description: '매수/매도 희망 수량' }) + @IsInt() + @IsPositive() + amount: number; +} diff --git a/BE/src/stock/order/enum/status-type.ts b/BE/src/stock/order/enum/status-type.ts new file mode 100644 index 00000000..04b3caeb --- /dev/null +++ b/BE/src/stock/order/enum/status-type.ts @@ -0,0 +1,4 @@ +export enum StatusType { + PENDING = 'PENDING', + COMPLETE = 'COMPLETE', +} diff --git a/BE/src/stock/order/enum/trade-type.ts b/BE/src/stock/order/enum/trade-type.ts new file mode 100644 index 00000000..c07270e3 --- /dev/null +++ b/BE/src/stock/order/enum/trade-type.ts @@ -0,0 +1,4 @@ +export enum TradeType { + SELL = 'SELL', + BUY = 'BUY', +} diff --git a/BE/src/stock/order/interface/request.interface.ts b/BE/src/stock/order/interface/request.interface.ts new file mode 100644 index 00000000..d7616da0 --- /dev/null +++ b/BE/src/stock/order/interface/request.interface.ts @@ -0,0 +1,9 @@ +export interface RequestInterface { + user: { + id: number; + email: string; + password: string; + tutorial: boolean; + kakaoId: number; + }; +} diff --git a/BE/src/stock/order/stock-order.controller.ts b/BE/src/stock/order/stock-order.controller.ts new file mode 100644 index 00000000..82d72083 --- /dev/null +++ b/BE/src/stock/order/stock-order.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Delete, + Param, + Post, + Req, + UseGuards, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { StockOrderService } from './stock-order.service'; +import { StockOrderRequestDto } from './dto/stock-order-request.dto'; +import { JwtAuthGuard } from '../../auth/jwt-auth-guard'; +import { RequestInterface } from './interface/request.interface'; + +@Controller('/api/stocks/trade') +@ApiTags('주식 매수/매도 API') +export class StockOrderController { + constructor(private readonly stockTradeService: StockOrderService) {} + + @Post('/buy') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '주식 매수 API', + description: '주식 id, 매수 가격, 수량으로 주식을 매수한다.', + }) + @ApiResponse({ + status: 201, + description: '주식 매수 예약 등록 성공', + }) + async buy( + @Req() request: RequestInterface, + @Body(ValidationPipe) stockOrderRequest: StockOrderRequestDto, + ) { + await this.stockTradeService.buy(request.user.id, stockOrderRequest); + } + + @Post('/sell') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '주식 매도 API', + description: '주식 id, 매도 가격, 수량으로 주식을 매도한다.', + }) + @ApiResponse({ + status: 201, + description: '주식 매도 예약 등록 성공', + }) + async sell( + @Req() request: RequestInterface, + @Body(ValidationPipe) stockOrderRequest: StockOrderRequestDto, + ) { + await this.stockTradeService.sell(request.user.id, stockOrderRequest); + } + + @Delete('/:order_id') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '주식 매도/매수 취소 API', + description: '주문 id로 미체결된 주문을 취소한다.', + }) + @ApiResponse({ + status: 200, + description: '주식 매도/매수 취소 성공', + }) + async cancel( + @Req() request: RequestInterface, + @Param('order_id') orderId: number, + ) { + await this.stockTradeService.cancel(request.user.id, orderId); + } +} diff --git a/BE/src/stock/order/stock-order.entity.ts b/BE/src/stock/order/stock-order.entity.ts new file mode 100644 index 00000000..a88de180 --- /dev/null +++ b/BE/src/stock/order/stock-order.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { TradeType } from './enum/trade-type'; +import { StatusType } from './enum/status-type'; + +@Entity('orders') +export class Order { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: false }) + user_id: number; + + @Column({ nullable: false }) + stock_id: number; + + @Column({ + type: 'enum', + enum: TradeType, + nullable: false, + }) + trade_type: TradeType; + + @Column({ nullable: false }) + amount: number; + + @Column({ nullable: false }) + price: number; + + @Column({ + type: 'enum', + enum: StatusType, + nullable: false, + }) + status: StatusType; + + @CreateDateColumn() + created_at: Date; + + @Column({ nullable: true }) + completed_at?: Date; +} diff --git a/BE/src/stock/order/stock-order.module.ts b/BE/src/stock/order/stock-order.module.ts new file mode 100644 index 00000000..b5c54337 --- /dev/null +++ b/BE/src/stock/order/stock-order.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StockOrderController } from './stock-order.controller'; +import { StockOrderService } from './stock-order.service'; +import { Order } from './stock-order.entity'; +import { StockOrderRepository } from './stock-order.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([Order])], + controllers: [StockOrderController], + providers: [StockOrderService, StockOrderRepository], +}) +export class StockOrderModule {} diff --git a/BE/src/stock/order/stock-order.repository.ts b/BE/src/stock/order/stock-order.repository.ts new file mode 100644 index 00000000..cec0080a --- /dev/null +++ b/BE/src/stock/order/stock-order.repository.ts @@ -0,0 +1,11 @@ +import { DataSource, Repository } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; +import { Order } from './stock-order.entity'; + +@Injectable() +export class StockOrderRepository extends Repository { + constructor(@InjectDataSource() dataSource: DataSource) { + super(Order, dataSource.createEntityManager()); + } +} diff --git a/BE/src/stock/order/stock-order.service.ts b/BE/src/stock/order/stock-order.service.ts new file mode 100644 index 00000000..3b5e7e07 --- /dev/null +++ b/BE/src/stock/order/stock-order.service.ts @@ -0,0 +1,56 @@ +import { + ConflictException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { NotFoundError } from 'rxjs'; +import { Injectable } from '@nestjs/common'; +import { StockOrderRequestDto } from './dto/stock-order-request.dto'; +import { StockOrderRepository } from './stock-order.repository'; +import { TradeType } from './enum/trade-type'; +import { StatusType } from './enum/status-type'; + +@Injectable() +export class StockOrderService { + constructor(private readonly stockOrderRepository: StockOrderRepository) {} + + async buy(userId: number, stockOrderRequest: StockOrderRequestDto) { + const order = this.stockOrderRepository.create({ + user_id: userId, + stock_id: stockOrderRequest.stock_id, + trade_type: TradeType.BUY, + amount: stockOrderRequest.amount, + price: stockOrderRequest.price, + status: StatusType.PENDING, + }); + + await this.stockOrderRepository.save(order); + } + + async sell(userId: number, stockOrderRequest: StockOrderRequestDto) { + const order = this.stockOrderRepository.create({ + user_id: userId, + stock_id: stockOrderRequest.stock_id, + trade_type: TradeType.SELL, + amount: stockOrderRequest.amount, + price: stockOrderRequest.price, + status: StatusType.PENDING, + }); + + await this.stockOrderRepository.save(order); + } + + async cancel(userId: number, orderId: number) { + const order = await this.stockOrderRepository.findOneBy({ id: orderId }); + + if (!order) throw new NotFoundError('주문을 찾을 수 없습니다.'); + + if (order.user_id !== userId) + throw new ForbiddenException('다른 사용자의 주문은 취소할 수 없습니다.'); + + if (order.status === StatusType.COMPLETE) + throw new ConflictException('이미 체결된 주문은 취소할 수 없습니다.'); + + await this.stockOrderRepository.remove(order); + } +} diff --git a/BE/src/util/swagger.ts b/BE/src/util/swagger.ts index 44de0c96..e820d85e 100644 --- a/BE/src/util/swagger.ts +++ b/BE/src/util/swagger.ts @@ -13,6 +13,7 @@ export function setupSwagger(app: INestApplication): void { .setTitle('Juga API') .setDescription('Juga API 문서입니다.') .setVersion('1.0.0') + .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, options); diff --git a/FE/.dockerignore b/FE/.dockerignore new file mode 100644 index 00000000..7bd5f175 --- /dev/null +++ b/FE/.dockerignore @@ -0,0 +1,5 @@ +.git +Dockerfile +node_modules +dist +.env* \ No newline at end of file diff --git a/FE/Dockerfile b/FE/Dockerfile index 6773e6a4..c4389e8c 100644 --- a/FE/Dockerfile +++ b/FE/Dockerfile @@ -1,17 +1,16 @@ -FROM node:20 - -RUN mkdir -p /var/app -WORKDIR /var/app - +# 빌드 스테이지 +FROM node:20-slim as builder +WORKDIR /app COPY package*.json ./ RUN npm install - COPY . . - RUN npm run build +# 실행 스테이지 +FROM node:20-slim +WORKDIR /app RUN npm install -g serve +COPY --from=builder /app/dist ./dist EXPOSE 5173 - CMD ["serve", "-s", "dist", "-l", "5173"] \ No newline at end of file