diff --git a/.github/workflows/deply-alpha.yml b/.github/workflows/deploy-alpha.yml similarity index 88% rename from .github/workflows/deply-alpha.yml rename to .github/workflows/deploy-alpha.yml index db8ea854..ba52ca0e 100644 --- a/.github/workflows/deply-alpha.yml +++ b/.github/workflows/deploy-alpha.yml @@ -17,10 +17,7 @@ jobs: max-parallel: 1 matrix: app: - [ - { name: 'be', dir: 'BE', port: 3000, container: 'juga-docker-be' }, - { name: 'fe', dir: 'FE', port: 5173, container: 'juga-docker-fe' }, - ] + [{ name: 'be', dir: 'BE', port: 3000, container: 'juga-docker-be' }] steps: - uses: actions/checkout@v4 @@ -35,11 +32,7 @@ jobs: - name: Create .env file run: | touch ./${{ matrix.app.dir }}/.env - if [ "${{ matrix.app.name }}" = "be" ]; then - echo "${{ secrets.ENV }}" > ./${{matrix.app.dir}}/.env - else - echo "${{ secrets.ENV_FE }}" > ./${{matrix.app.dir}}/.env - fi + echo "${{ secrets.ENV_ALPHA }}" > ./${{matrix.app.dir}}/.env - name: Install dependencies working-directory: ./${{matrix.app.dir}} @@ -108,12 +101,7 @@ jobs: port: 22 script: | docker system prune -af - if [ "${{ matrix.app.name }}" = "be" ]; then - echo "${{ secrets.ENV }}" > .env - else - echo "${{ secrets.ENV_FE }}" > .env - fi - + echo "${{ secrets.ENV_ALPHA }}" > .env docker network create juga-network || true diff --git a/BE/Dockerfile b/BE/Dockerfile index 60079503..f530f5ad 100644 --- a/BE/Dockerfile +++ b/BE/Dockerfile @@ -12,6 +12,7 @@ WORKDIR /var/app COPY package*.json ./ RUN npm install --only=production COPY --from=builder /app/dist ./dist +RUN ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime EXPOSE 3000 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 2b2a14d5..40ed1831 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -8,8 +8,8 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; 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 { KoreaInvestmentModule } from './common/koreaInvestment/korea-investment.module'; +import { SocketModule } from './common/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'; @@ -17,6 +17,7 @@ import { StockListModule } from './stock/list/stock-list.module'; import { StockTradeHistoryModule } from './stock/trade/history/stock-trade-history.module'; import { RedisModule } from './common/redis/redis.module'; import { HTTPExceptionFilter } from './common/filters/http-exception.filter'; +import { RankingModule } from './ranking/ranking.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { HTTPExceptionFilter } from './common/filters/http-exception.filter'; StockListModule, StockTradeHistoryModule, RedisModule, + RankingModule, ], controllers: [AppController], providers: [ diff --git a/BE/src/asset/asset.controller.ts b/BE/src/asset/asset.controller.ts index 3bca562e..360b85f4 100644 --- a/BE/src/asset/asset.controller.ts +++ b/BE/src/asset/asset.controller.ts @@ -1,6 +1,78 @@ -import { Controller } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { Cron } from '@nestjs/schedule'; +import { JwtAuthGuard } from '../auth/jwt-auth-guard'; +import { AssetService } from './asset.service'; +import { MypageResponseDto } from './dto/mypage-response.dto'; @Controller('/api/assets') -@ApiTags('자산 API') -export class AssetController {} +@ApiTags('사용자 자산 및 보유 주식 API') +export class AssetController { + constructor(private readonly assetService: AssetService) {} + + @Get('/stocks/:stockCode') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '매도 가능 주식 개수 조회 API', + description: '특정 주식 매도 시에 필요한 매도 가능한 주식 개수를 조회한다.', + }) + @ApiResponse({ + status: 200, + description: '매도 가능 주식 개수 조회 성공', + example: { quantity: 0 }, + }) + async getUserStockByCode( + @Req() request: Request, + @Param('stockCode') stockCode: string, + ) { + return this.assetService.getUserStockByCode( + parseInt(request.user.userId, 10), + stockCode, + ); + } + + @Get('/cash') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '매수 가능 금액 조회 API', + description: + '특정 주식 매수 시에 필요한 매수 가능한 금액(현재 가용자산)을 조회한다.', + }) + @ApiResponse({ + status: 200, + description: '매수 가능 금액 조회 성공', + example: { cash_balance: 0 }, + }) + async getCashBalance(@Req() request: Request) { + return this.assetService.getCashBalance(parseInt(request.user.userId, 10)); + } + + @Get() + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '마이페이지 보유 자산 현황 조회 API', + description: '마이페이지 조회 시 필요한 보유 자산 현황을 조회한다.', + }) + @ApiResponse({ + status: 200, + description: '매수 가능 금액 조회 성공', + type: MypageResponseDto, + }) + async getMyPage(@Req() request: Request) { + return this.assetService.getMyPage(parseInt(request.user.userId, 10)); + } + + @Cron('*/10 9-16 * * 1-5') + async updateAllAssets() { + await this.assetService.updateAllAssets(); + } +} diff --git a/BE/src/asset/asset.entity.ts b/BE/src/asset/asset.entity.ts index b3504463..f729263e 100644 --- a/BE/src/asset/asset.entity.ts +++ b/BE/src/asset/asset.entity.ts @@ -27,4 +27,7 @@ export class Asset { @Column({ nullable: true }) last_updated?: Date; + + @Column({ default: INIT_ASSET }) + prev_total_asset?: number; } diff --git a/BE/src/asset/asset.module.ts b/BE/src/asset/asset.module.ts index 5ddf8874..0b582ee1 100644 --- a/BE/src/asset/asset.module.ts +++ b/BE/src/asset/asset.module.ts @@ -4,11 +4,14 @@ import { AssetController } from './asset.controller'; import { AssetService } from './asset.service'; import { AssetRepository } from './asset.repository'; import { Asset } from './asset.entity'; +import { UserStock } from './user-stock.entity'; +import { UserStockRepository } from './user-stock.repository'; +import { StockDetailModule } from '../stock/detail/stock-detail.module'; @Module({ - imports: [TypeOrmModule.forFeature([Asset])], + imports: [TypeOrmModule.forFeature([Asset, UserStock]), StockDetailModule], controllers: [AssetController], - providers: [AssetService, AssetRepository], - exports: [AssetRepository], + providers: [AssetService, AssetRepository, UserStockRepository], + exports: [AssetRepository, UserStockRepository], }) export class AssetModule {} diff --git a/BE/src/asset/asset.repository.ts b/BE/src/asset/asset.repository.ts index cfc05c92..3aac066b 100644 --- a/BE/src/asset/asset.repository.ts +++ b/BE/src/asset/asset.repository.ts @@ -8,4 +8,11 @@ export class AssetRepository extends Repository { constructor(@InjectDataSource() dataSource: DataSource) { super(Asset, dataSource.createEntityManager()); } + + async getAssets() { + return this.createQueryBuilder('asset') + .leftJoin('user', 'user', 'asset.user_id = user.id') + .select(['asset.* ', 'user.nickname as nickname']) + .getRawMany(); + } } diff --git a/BE/src/asset/asset.service.ts b/BE/src/asset/asset.service.ts index f424a0d8..cf6c04db 100644 --- a/BE/src/asset/asset.service.ts +++ b/BE/src/asset/asset.service.ts @@ -1,4 +1,117 @@ import { Injectable } from '@nestjs/common'; +import { UserStockRepository } from './user-stock.repository'; +import { AssetRepository } from './asset.repository'; +import { MypageResponseDto } from './dto/mypage-response.dto'; +import { StockElementResponseDto } from './dto/stock-element-response.dto'; +import { AssetResponseDto } from './dto/asset-response.dto'; +import { StockDetailService } from '../stock/detail/stock-detail.service'; +import { UserStock } from './user-stock.entity'; +import { Asset } from './asset.entity'; @Injectable() -export class AssetService {} +export class AssetService { + constructor( + private readonly userStockRepository: UserStockRepository, + private readonly assetRepository: AssetRepository, + private readonly stockDetailService: StockDetailService, + ) {} + + async getUserStockByCode(userId: number, stockCode: string) { + const userStock = await this.userStockRepository.findOneBy({ + user_id: userId, + stock_code: stockCode, + }); + + return { quantity: userStock ? userStock.quantity : 0 }; + } + + async getCashBalance(userId: number) { + const asset = await this.assetRepository.findOneBy({ user_id: userId }); + + return { cash_balance: asset.cash_balance }; + } + + async getMyPage(userId: number) { + const userStocks = + await this.userStockRepository.findUserStockWithNameByUserId(userId); + const asset = await this.assetRepository.findOneBy({ user_id: userId }); + const newAsset = await this.updateMyAsset( + asset, + await this.getCurrPrices(), + ); + + const myStocks = userStocks.map((userStock) => { + return new StockElementResponseDto( + userStock.stocks_name, + userStock.stocks_code, + userStock.user_stocks_quantity, + userStock.user_stocks_avg_price, + ); + }); + + const myAsset = new AssetResponseDto( + newAsset.cash_balance, + newAsset.stock_balance, + newAsset.total_asset, + newAsset.total_profit, + newAsset.total_profit_rate, + newAsset.total_profit_rate >= 0, + ); + + const response = new MypageResponseDto(); + response.asset = myAsset; + response.stocks = myStocks; + + return response; + } + + async updateAllAssets() { + const currPrices = await this.getCurrPrices(); + const assets = await this.assetRepository.find(); + + await Promise.allSettled( + assets.map((asset) => this.updateMyAsset(asset, currPrices)), + ); + } + + private async updateMyAsset(asset: Asset, currPrices) { + const userId = asset.user_id; + const userStocks = await this.userStockRepository.find({ + where: { user_id: userId }, + }); + + const totalPrice = userStocks.reduce( + (sum, userStock) => + sum + userStock.quantity * currPrices[userStock.stock_code], + 0, + ); + + const updatedAsset = { + ...asset, + stock_balance: totalPrice, + total_asset: asset.cash_balance + totalPrice, + total_profit: asset.cash_balance + totalPrice - 10000000, + total_profit_rate: (asset.cash_balance + totalPrice - 10000000) / 100000, + last_updated: new Date(), + prev_total_asset: asset.total_asset, + }; + return this.assetRepository.save(updatedAsset); + } + + private async getCurrPrices() { + const userStocks: UserStock[] = + await this.userStockRepository.findAllDistinctCode(); + const currPrices = {}; + + await Promise.allSettled( + userStocks.map(async (userStock) => { + const inquirePrice = await this.stockDetailService.getInquirePrice( + userStock.stock_code, + ); + currPrices[userStock.stock_code] = Number(inquirePrice.stck_prpr); + }), + ); + + return currPrices; + } +} diff --git a/BE/src/asset/dto/asset-response.dto.ts b/BE/src/asset/dto/asset-response.dto.ts new file mode 100644 index 00000000..b24f5d6f --- /dev/null +++ b/BE/src/asset/dto/asset-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AssetResponseDto { + constructor( + cash_balance, + stock_balance, + total_asset, + total_profit, + total_profit_rate, + is_positive, + ) { + this.cash_balance = cash_balance; + this.stock_balance = stock_balance; + this.total_asset = total_asset; + this.total_profit = total_profit; + this.total_profit_rate = total_profit_rate; + this.is_positive = is_positive; + } + + @ApiProperty({ description: '보유 현금' }) + cash_balance: number; + + @ApiProperty({ description: '주식 평가 금액' }) + stock_balance: number; + + @ApiProperty({ description: '총 자산' }) + total_asset: number; + + @ApiProperty({ description: '총 수익금' }) + total_profit: number; + + @ApiProperty({ description: '총 수익률' }) + total_profit_rate: number; + + @ApiProperty({ description: '수익률 부호' }) + is_positive: boolean; +} diff --git a/BE/src/asset/dto/mypage-response.dto.ts b/BE/src/asset/dto/mypage-response.dto.ts new file mode 100644 index 00000000..54e5873e --- /dev/null +++ b/BE/src/asset/dto/mypage-response.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockElementResponseDto } from './stock-element-response.dto'; +import { AssetResponseDto } from './asset-response.dto'; + +export class MypageResponseDto { + @ApiProperty({ + description: '보유 자산', + type: AssetResponseDto, + }) + asset: AssetResponseDto; + + @ApiProperty({ + description: '보유 주식 리스트', + type: [StockElementResponseDto], + }) + stocks: StockElementResponseDto[]; +} diff --git a/BE/src/asset/dto/stock-element-response.dto.ts b/BE/src/asset/dto/stock-element-response.dto.ts new file mode 100644 index 00000000..d2ce6d27 --- /dev/null +++ b/BE/src/asset/dto/stock-element-response.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StockElementResponseDto { + constructor(name, code, quantity, avg_price) { + this.name = name; + this.code = code; + this.quantity = quantity; + this.avg_price = avg_price; + } + + @ApiProperty({ description: '종목 이름' }) + name: string; + + @ApiProperty({ description: '종목 코드' }) + code: string; + + @ApiProperty({ description: '보유량' }) + quantity: number; + + @ApiProperty({ description: '평균 매수가' }) + avg_price: number; +} diff --git a/BE/src/asset/interface/user-stock.interface.ts b/BE/src/asset/interface/user-stock.interface.ts new file mode 100644 index 00000000..44734a50 --- /dev/null +++ b/BE/src/asset/interface/user-stock.interface.ts @@ -0,0 +1,11 @@ +export interface UserStockInterface { + user_stocks_id: number; + user_stocks_user_id: number; + user_stocks_stock_code: string; + user_stocks_quantity: number; + user_stocks_avg_price: string; + user_stocks_last_updated: Date; + stocks_code: string; + stocks_name: string; + stocks_market: string; +} diff --git a/BE/src/userStock/user-stock.entity.ts b/BE/src/asset/user-stock.entity.ts similarity index 81% rename from BE/src/userStock/user-stock.entity.ts rename to BE/src/asset/user-stock.entity.ts index 60a1b169..5d0803dc 100644 --- a/BE/src/userStock/user-stock.entity.ts +++ b/BE/src/asset/user-stock.entity.ts @@ -2,10 +2,12 @@ import { Column, Entity, PrimaryGeneratedColumn, + Unique, UpdateDateColumn, } from 'typeorm'; @Entity('user_stocks') +@Unique(['user_id', 'stock_code']) export class UserStock { @PrimaryGeneratedColumn() id: number; @@ -19,7 +21,7 @@ export class UserStock { @Column({ nullable: false }) quantity: number; - @Column('decimal', { nullable: false, precision: 10, scale: 5 }) + @Column('float', { nullable: false, scale: 5 }) avg_price: number; @UpdateDateColumn() diff --git a/BE/src/asset/user-stock.repository.ts b/BE/src/asset/user-stock.repository.ts new file mode 100644 index 00000000..6542047f --- /dev/null +++ b/BE/src/asset/user-stock.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, MoreThan, Repository } from 'typeorm'; +import { UserStock } from './user-stock.entity'; +import { UserStockInterface } from './interface/user-stock.interface'; + +@Injectable() +export class UserStockRepository extends Repository { + constructor(@InjectDataSource() private dataSource: DataSource) { + super(UserStock, dataSource.createEntityManager()); + } + + findUserStockWithNameByUserId(userId: number) { + return this.createQueryBuilder('user_stocks') + .leftJoinAndSelect( + 'stocks', + 'stocks', + 'stocks.code = user_stocks.stock_code', + ) + .where('user_stocks.user_id = :userId', { userId }) + .getRawMany(); + } + + findAllDistinctCode() { + return this.createQueryBuilder('user_stocks') + .select('DISTINCT user_stocks.stock_code') + .where({ quantity: MoreThan(0) }) + .getRawMany(); + } +} diff --git a/BE/src/auth/auth.controller.ts b/BE/src/auth/auth.controller.ts index 8fcdf300..7e58a4bb 100644 --- a/BE/src/auth/auth.controller.ts +++ b/BE/src/auth/auth.controller.ts @@ -10,7 +10,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { ApiOperation } from '@nestjs/swagger'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; @@ -38,18 +38,12 @@ export class AuthController { const { accessToken, refreshToken } = await this.authService.loginUser(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 }); } - @ApiOperation({ summary: 'Token 인증 테스트 API' }) - @Get('/test') - @UseGuards(AuthGuard('jwt')) - test(@Req() req: Request) { - return req; - } - @ApiOperation({ summary: 'Kakao 로그인 API' }) @Get('/kakao') @UseGuards(AuthGuard('kakao')) @@ -86,4 +80,16 @@ export class AuthController { res.cookie('isRefreshToken', true, { httpOnly: true }); return res.redirect(this.configService.get('FRONTEND_URL')); } + + @ApiOperation({ summary: '로그인 상태 확인 API' }) + @Get('/check') + @UseGuards(AuthGuard('jwt')) + @ApiResponse({ + status: 200, + description: '로그인 상태 조회 성공', + example: { isLogin: true }, + }) + check() { + return { isLogin: true }; + } } diff --git a/BE/src/auth/auth.module.ts b/BE/src/auth/auth.module.ts index 581b401c..2b6834a5 100644 --- a/BE/src/auth/auth.module.ts +++ b/BE/src/auth/auth.module.ts @@ -10,6 +10,8 @@ import { UserRepository } from './user.repository'; import { JwtStrategy } from './strategy/jwt.strategy'; import { KakaoStrategy } from './strategy/kakao.strategy'; import { AssetModule } from '../asset/asset.module'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; @Module({ imports: [ @@ -28,8 +30,14 @@ import { AssetModule } from '../asset/asset.module'; }), AssetModule, ], - controllers: [AuthController], - providers: [AuthService, UserRepository, JwtStrategy, KakaoStrategy], + controllers: [AuthController, UserController], + providers: [ + AuthService, + UserRepository, + JwtStrategy, + KakaoStrategy, + UserService, + ], exports: [JwtStrategy, PassportModule], }) export class AuthModule {} diff --git a/BE/src/auth/dto/profile-response.dto.ts b/BE/src/auth/dto/profile-response.dto.ts new file mode 100644 index 00000000..5363aaa8 --- /dev/null +++ b/BE/src/auth/dto/profile-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProfileResponseDto { + constructor(name, email) { + this.name = name; + this.email = email; + } + + @ApiProperty({ description: '사용자 이름' }) + name: string; + + @ApiProperty({ description: '사용자 이메일' }) + email: string; +} diff --git a/BE/src/auth/optional-auth-guard.ts b/BE/src/auth/optional-auth-guard.ts new file mode 100644 index 00000000..bd9fde59 --- /dev/null +++ b/BE/src/auth/optional-auth-guard.ts @@ -0,0 +1,10 @@ +import { AuthGuard } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { User } from './user.entity'; + +@Injectable() +export class OptionalAuthGuard extends AuthGuard('jwt') { + handleRequest(err: Error, user: TUser): TUser { + return user; + } +} diff --git a/BE/src/auth/strategy/jwt.strategy.ts b/BE/src/auth/strategy/jwt.strategy.ts index 7f8f5d2a..a638aba3 100644 --- a/BE/src/auth/strategy/jwt.strategy.ts +++ b/BE/src/auth/strategy/jwt.strategy.ts @@ -1,11 +1,26 @@ import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; -import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Strategy } from 'passport-jwt'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; import { UserRepository } from '../user.repository'; import { User } from '../user.entity'; +interface RequestWithCookies extends Request { + cookies: { + accessToken?: string; + [key: string]: string | undefined; + }; +} + +function extractJWTFromCookie(req: RequestWithCookies): string | null { + if (req.cookies && 'accessToken' in req.cookies) { + return req.cookies.accessToken; + } + return null; +} + @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( @@ -14,16 +29,23 @@ export class JwtStrategy extends PassportStrategy(Strategy) { ) { super({ secretOrKey: configService.get('JWT_SECRET'), - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: extractJWTFromCookie, }); } - async validate(payload) { + async validate(payload: { email: string }): Promise<{ + userId: number; + email: string; + tutorial: boolean; + kakaoId: string | null; + nickname: string; + }> { const { email } = payload; const user: User = await this.userRepository.findOne({ where: { email } }); if (!user) throw new UnauthorizedException(); return { + nickname: user.nickname, userId: user.id, email: user.email, tutorial: user.tutorial, diff --git a/BE/src/auth/user.controller.ts b/BE/src/auth/user.controller.ts new file mode 100644 index 00000000..881a2b7c --- /dev/null +++ b/BE/src/auth/user.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { Request } from 'express'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UserService } from './user.service'; +import { JwtAuthGuard } from './jwt-auth-guard'; +import { ProfileResponseDto } from './dto/profile-response.dto'; + +@Controller('/api/user') +@ApiTags('프로필 API') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get('/profile') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '내 프로필 조회 API' }) + @ApiResponse({ + status: 200, + description: '프로필 조회 성공', + type: ProfileResponseDto, + }) + getProfile(@Req() request: Request) { + return this.userService.getProfile(parseInt(request.user.userId, 10)); + } +} diff --git a/BE/src/auth/user.entity.ts b/BE/src/auth/user.entity.ts index 16773177..fa31448c 100644 --- a/BE/src/auth/user.entity.ts +++ b/BE/src/auth/user.entity.ts @@ -6,12 +6,18 @@ export class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; + @Column() + name: string; + @Column() email: string; @Column() password: string; + @Column({ default: '' }) + nickname: string; + @Column({ default: false }) tutorial: boolean; diff --git a/BE/src/auth/user.service.ts b/BE/src/auth/user.service.ts new file mode 100644 index 00000000..05dfc89e --- /dev/null +++ b/BE/src/auth/user.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { UserRepository } from './user.repository'; +import { ProfileResponseDto } from './dto/profile-response.dto'; + +@Injectable() +export class UserService { + constructor(private readonly userRepository: UserRepository) {} + + async getProfile(userId: number) { + const user = await this.userRepository.findOneBy({ id: userId }); + return new ProfileResponseDto(user.name, user.email); + } +} diff --git a/BE/src/koreaInvestment/interface/korea-investment.interface.ts b/BE/src/common/koreaInvestment/interface/korea-investment.interface.ts similarity index 100% rename from BE/src/koreaInvestment/interface/korea-investment.interface.ts rename to BE/src/common/koreaInvestment/interface/korea-investment.interface.ts diff --git a/BE/src/koreaInvestment/korea-investment.service.ts b/BE/src/common/koreaInvestment/korea-investment.domain-service.ts similarity index 52% rename from BE/src/koreaInvestment/korea-investment.service.ts rename to BE/src/common/koreaInvestment/korea-investment.domain-service.ts index 29dc323c..26e890d3 100644 --- a/BE/src/koreaInvestment/korea-investment.service.ts +++ b/BE/src/common/koreaInvestment/korea-investment.domain-service.ts @@ -1,9 +1,10 @@ import axios from 'axios'; import { UnauthorizedException } from '@nestjs/common'; -import { getFullURL } from '../util/get-full-URL'; +import { getFullURL } from '../../util/get-full-URL'; import { AccessTokenInterface } from './interface/korea-investment.interface'; +import { getHeader } from '../../util/get-header'; -export class KoreaInvestmentService { +export class KoreaInvestmentDomainService { private accessToken: string; private tokenExpireTime: Date; @@ -29,4 +30,30 @@ export class KoreaInvestmentService { return this.accessToken; } + + /** + * @private 한국투자 Open API - API 호출용 공통 함수 + * @param {string} trId - API 호출에 사용할 tr_id + * @param {string} apiURL - API 호출에 사용할 URL + * @param {Record} params - API 요청 시 필요한 쿼리 파라미터 DTO + * @returns - API 호출에 대한 응답 데이터 + * + * @author uuuo3o + */ + async requestApi( + trId: string, + apiURL: string, + params: Record, + ): Promise { + const accessToken = await this.getAccessToken(); + const headers = getHeader(accessToken, trId); + const url = getFullURL(apiURL); + + const response = await axios.get(url, { + headers, + params, + }); + + return response.data; + } } diff --git a/BE/src/common/koreaInvestment/korea-investment.module.ts b/BE/src/common/koreaInvestment/korea-investment.module.ts new file mode 100644 index 00000000..2ffd0889 --- /dev/null +++ b/BE/src/common/koreaInvestment/korea-investment.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { KoreaInvestmentDomainService } from './korea-investment.domain-service'; + +@Module({ + imports: [], + controllers: [], + providers: [KoreaInvestmentDomainService], + exports: [KoreaInvestmentDomainService], +}) +export class KoreaInvestmentModule {} diff --git a/BE/src/common/redis/redis.domain-service.ts b/BE/src/common/redis/redis.domain-service.ts index ce636d6e..77700cc4 100644 --- a/BE/src/common/redis/redis.domain-service.ts +++ b/BE/src/common/redis/redis.domain-service.ts @@ -8,6 +8,10 @@ export class RedisDomainService { private readonly redis: Redis, ) {} + async exists(key: string): Promise { + return this.redis.exists(key); + } + async get(key: string): Promise { return this.redis.get(key); } @@ -47,6 +51,10 @@ export class RedisDomainService { return this.redis.zrevrange(key, start, stop); } + async zrevrank(key: string, member: string): Promise { + return this.redis.zrevrank(key, member); + } + async zrem(key: string, member: string): Promise { return this.redis.zrem(key, member); } diff --git a/BE/src/websocket/base-socket.service.ts b/BE/src/common/websocket/base-socket.domain-service.ts similarity index 85% rename from BE/src/websocket/base-socket.service.ts rename to BE/src/common/websocket/base-socket.domain-service.ts index 66bc6a1d..527bf9ff 100644 --- a/BE/src/websocket/base-socket.service.ts +++ b/BE/src/common/websocket/base-socket.domain-service.ts @@ -5,10 +5,10 @@ import { Logger, OnModuleInit, } from '@nestjs/common'; -import { SocketTokenService } from './socket-token.service'; +import { SocketTokenDomainService } from './socket-token.domain-service'; @Injectable() -export class BaseSocketService implements OnModuleInit { +export class BaseSocketDomainService implements OnModuleInit { private socket: WebSocket; private socketConnectionKey: string; private socketOpenHandlers: (() => void | Promise)[] = []; @@ -18,11 +18,13 @@ export class BaseSocketService implements OnModuleInit { private readonly logger = new Logger(); - constructor(private readonly socketTokenService: SocketTokenService) {} + constructor( + private readonly socketTokenDomainService: SocketTokenDomainService, + ) {} async onModuleInit() { this.socketConnectionKey = - await this.socketTokenService.getSocketConnectionKey(); + await this.socketTokenDomainService.getSocketConnectionKey(); this.socket = new WebSocket(process.env.KOREA_INVESTMENT_SOCKET_URL); this.socket.onopen = () => { @@ -63,6 +65,11 @@ export class BaseSocketService implements OnModuleInit { this.socket.onclose = () => { this.logger.warn(`한국투자증권 소켓 연결 종료`); + setTimeout(() => { + this.onModuleInit().catch((err) => { + throw new InternalServerErrorException(err); + }); + }, 60000); }; } diff --git a/BE/src/websocket/interface/socket.interface.ts b/BE/src/common/websocket/interface/socket.interface.ts similarity index 100% rename from BE/src/websocket/interface/socket.interface.ts rename to BE/src/common/websocket/interface/socket.interface.ts diff --git a/BE/src/websocket/socket-token.service.ts b/BE/src/common/websocket/socket-token.domain-service.ts similarity index 86% rename from BE/src/websocket/socket-token.service.ts rename to BE/src/common/websocket/socket-token.domain-service.ts index f4ff9e3c..cce5ea03 100644 --- a/BE/src/websocket/socket-token.service.ts +++ b/BE/src/common/websocket/socket-token.domain-service.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import { SocketConnectTokenInterface } from './interface/socket.interface'; -import { getFullURL } from '../util/get-full-URL'; +import { getFullURL } from '../../util/get-full-URL'; -export class SocketTokenService { +export class SocketTokenDomainService { private approvalKey: string; async getSocketConnectionKey() { diff --git a/BE/src/websocket/socket.gateway.ts b/BE/src/common/websocket/socket.gateway.ts similarity index 87% rename from BE/src/websocket/socket.gateway.ts rename to BE/src/common/websocket/socket.gateway.ts index d533c3f0..48668008 100644 --- a/BE/src/websocket/socket.gateway.ts +++ b/BE/src/common/websocket/socket.gateway.ts @@ -20,4 +20,8 @@ export class SocketGateway { this.server.emit(event, stockIndexValue); } + + sendStockTradeHistoryValueToClient(event, historyData) { + this.server.emit(event, historyData); + } } diff --git a/BE/src/common/websocket/socket.module.ts b/BE/src/common/websocket/socket.module.ts new file mode 100644 index 00000000..0cc8ceac --- /dev/null +++ b/BE/src/common/websocket/socket.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SocketGateway } from './socket.gateway'; +import { SocketTokenDomainService } from './socket-token.domain-service'; +import { BaseSocketDomainService } from './base-socket.domain-service'; + +@Module({ + providers: [SocketTokenDomainService, SocketGateway, BaseSocketDomainService], + exports: [SocketGateway, BaseSocketDomainService], +}) +export class SocketModule {} diff --git a/BE/src/koreaInvestment/korea-investment.module.ts b/BE/src/koreaInvestment/korea-investment.module.ts deleted file mode 100644 index 8e7dd10a..00000000 --- a/BE/src/koreaInvestment/korea-investment.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { KoreaInvestmentService } from './korea-investment.service'; - -@Module({ - imports: [], - controllers: [], - providers: [KoreaInvestmentService], - exports: [KoreaInvestmentService], -}) -export class KoreaInvestmentModule {} diff --git a/BE/src/main.ts b/BE/src/main.ts index 50a2241d..324722bd 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -21,9 +21,10 @@ async function bootstrap() { 'http://175.45.204.158:3000', 'http://juga.kro.kr', ], - methods: 'GET, HEAD, PUT, PATH, POST, DELETE', + methods: 'GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS', preflightContinue: false, optionsSuccessStatus: 204, + credentials: true, }); app.use(cookieParser()); diff --git a/BE/src/ranking/dto/ranking-data.dto.ts b/BE/src/ranking/dto/ranking-data.dto.ts new file mode 100644 index 00000000..97094da9 --- /dev/null +++ b/BE/src/ranking/dto/ranking-data.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RankingDataDto { + @ApiProperty({ + description: '사용자 닉네임', + example: 'trader123', + }) + nickname: string; + + @ApiProperty({ + description: '수익률 (%)', + example: 15.7, + required: false, + }) + profitRate?: number; + + @ApiProperty({ + description: '총 자산', + example: 1000000, + required: false, + }) + totalAsset?: number; +} diff --git a/BE/src/ranking/dto/ranking-response.dto.ts b/BE/src/ranking/dto/ranking-response.dto.ts new file mode 100644 index 00000000..e89f53ce --- /dev/null +++ b/BE/src/ranking/dto/ranking-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RankingResultDto } from './ranking-result.dto'; + +export class RankingResponseDto { + @ApiProperty({ + description: '수익률 랭킹', + }) + profitRateRanking: RankingResultDto; + + @ApiProperty({ + description: '자산 랭킹', + }) + assetRanking: RankingResultDto; +} diff --git a/BE/src/ranking/dto/ranking-result.dto.ts b/BE/src/ranking/dto/ranking-result.dto.ts new file mode 100644 index 00000000..61fa5888 --- /dev/null +++ b/BE/src/ranking/dto/ranking-result.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RankingDataDto } from './ranking-data.dto'; + +export class RankingResultDto { + @ApiProperty({ + description: '상위 10명의 랭킹 데이터', + type: [RankingDataDto], + }) + topRank: RankingDataDto[]; + + @ApiProperty({ + description: '현재 사용자의 순위 (없을 경우 null)', + example: 42, + nullable: true, + }) + userRank: number | null; +} diff --git a/BE/src/ranking/enum/sort-type.enum.ts b/BE/src/ranking/enum/sort-type.enum.ts new file mode 100644 index 00000000..758ac97c --- /dev/null +++ b/BE/src/ranking/enum/sort-type.enum.ts @@ -0,0 +1,4 @@ +export enum SortType { + PROFIT_RATE = 'profitRate', + ASSET = 'totalAsset', +} diff --git a/BE/src/ranking/interface/ranking.interface.ts b/BE/src/ranking/interface/ranking.interface.ts new file mode 100644 index 00000000..4ec58225 --- /dev/null +++ b/BE/src/ranking/interface/ranking.interface.ts @@ -0,0 +1,6 @@ +export interface Ranking { + id: number; + totalAsset: number; + profitRate: number; + nickname: string; +} diff --git a/BE/src/ranking/ranking.controller.ts b/BE/src/ranking/ranking.controller.ts new file mode 100644 index 00000000..e3f48d11 --- /dev/null +++ b/BE/src/ranking/ranking.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { OptionalAuthGuard } from 'src/auth/optional-auth-guard'; +import { RankingService } from './ranking.service'; +import { RankingResponseDto } from './dto/ranking-response.dto'; + +@Controller('/api/ranking') +@ApiTags('랭킹 API') +export class RankingController { + constructor(private readonly rankingService: RankingService) {} + + @ApiOperation({ summary: '랭킹 조회' }) + @ApiResponse({ + status: 200, + description: '랭킹 조회 성공', + type: RankingResponseDto, + }) + @Get() + @UseGuards(OptionalAuthGuard) + async getRanking(@Req() req: Request): Promise { + if (!req.user) { + return this.rankingService.getRanking(); + } + + const { nickname } = req.user; + return this.rankingService.getRankingAuthUser(nickname); + } +} diff --git a/BE/src/ranking/ranking.module.ts b/BE/src/ranking/ranking.module.ts new file mode 100644 index 00000000..0ef57dd7 --- /dev/null +++ b/BE/src/ranking/ranking.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { Asset } from 'src/asset/asset.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RedisModule } from 'src/common/redis/redis.module'; +import { RedisDomainService } from 'src/common/redis/redis.domain-service'; +import { AssetRepository } from 'src/asset/asset.repository'; +import { RankingService } from './ranking.service'; +import { RankingController } from './ranking.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset]), RedisModule], + controllers: [RankingController], + providers: [RankingService, RedisDomainService, AssetRepository], +}) +export class RankingModule {} diff --git a/BE/src/ranking/ranking.service.ts b/BE/src/ranking/ranking.service.ts new file mode 100644 index 00000000..dc8e6b96 --- /dev/null +++ b/BE/src/ranking/ranking.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from '@nestjs/common'; +import { RedisDomainService } from 'src/common/redis/redis.domain-service'; +import { AssetRepository } from 'src/asset/asset.repository'; +import { Cron } from '@nestjs/schedule'; +import { SortType } from './enum/sort-type.enum'; +import { Ranking } from './interface/ranking.interface'; +import { RankingResponseDto } from './dto/ranking-response.dto'; +import { RankingResultDto } from './dto/ranking-result.dto'; +import { RankingDataDto } from './dto/ranking-data.dto'; + +@Injectable() +export class RankingService { + constructor( + private readonly assetRepository: AssetRepository, + private readonly redisDomainService: RedisDomainService, + ) {} + + async getRanking(): Promise { + const profitRateRanking = await this.getRankingData(SortType.PROFIT_RATE); + const assetRanking = await this.getRankingData(SortType.ASSET); + + return { profitRateRanking, assetRanking }; + } + + async getRankingAuthUser(nickname: string): Promise { + const profitRateRanking = await this.getRankingData(SortType.PROFIT_RATE, { + nickname, + }); + const assetRanking = await this.getRankingData(SortType.ASSET, { + nickname, + }); + return { profitRateRanking, assetRanking }; + } + + private async getRankingData( + sortBy: SortType, + options: { nickname?: string } = { nickname: null }, + ): Promise { + const date = new Date().toISOString().slice(0, 10); + const key = `ranking:${date}:${sortBy}`; + + if (!(await this.redisDomainService.exists(key))) { + const ranking = await this.calculateRanking(sortBy); + await Promise.all( + ranking.map((rank: Ranking) => + this.redisDomainService.zadd( + key, + this.getSortScore(rank, sortBy), + sortBy === SortType.PROFIT_RATE + ? JSON.stringify({ + nickname: rank.nickname, + profitRate: rank.profitRate, + }) + : JSON.stringify({ + nickname: rank.nickname, + totalAsset: rank.totalAsset, + }), + ), + ), + ); + } + + const findUserRank = async () => { + if (!options.nickname) return null; + + const members = await this.redisDomainService.zrange(key, 0, -1); + const userMember = members.find((member) => { + const parsed = JSON.parse(member); + return parsed.nickname === options.nickname; + }); + + return userMember + ? this.redisDomainService.zrevrank(key, userMember) + : null; + }; + + const [topRank, userRank] = await Promise.all([ + this.redisDomainService.zrevrange(key, 0, 9), + findUserRank(), + ]); + + const parsedTopRank: RankingDataDto[] = topRank.map((rank) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + JSON.parse(rank), + ); + + return { + topRank: parsedTopRank, + userRank: userRank !== null ? userRank + 1 : null, + }; + } + + @Cron('0 16 * * 1-5') + async updateRanking() { + const [profitRateRanking, assetRanking] = await Promise.all([ + this.calculateRanking(SortType.PROFIT_RATE), + this.calculateRanking(SortType.ASSET), + ]); + + const date = new Date().toISOString().slice(0, 10); + const profitRateKey = `ranking:${date}:${SortType.PROFIT_RATE}`; + const assetKey = `ranking:${date}:${SortType.ASSET}`; + + await Promise.all([ + this.redisDomainService.del(profitRateKey), + this.redisDomainService.del(assetKey), + ]); + + await Promise.all([ + Promise.all( + profitRateRanking.map((rank: Ranking) => + this.redisDomainService.zadd( + profitRateKey, + rank.profitRate, + JSON.stringify({ + nickname: rank.nickname, + profitRate: rank.profitRate, + }), + ), + ), + ), + Promise.all( + assetRanking.map((rank: Ranking) => + this.redisDomainService.zadd( + assetKey, + rank.totalAsset, + JSON.stringify({ + nickname: rank.nickname, + totalAsset: rank.totalAsset, + }), + ), + ), + ), + ]); + } + + async calculateRanking(sortBy: SortType) { + const assets = await this.assetRepository.getAssets(); + const ranking = assets + .map((asset) => ({ + id: asset.id, + userId: asset.user_id, + nickname: asset.nickname, + totalAsset: asset.total_asset, + profitRate: + ((asset.total_asset - asset.prev_total_asset) / + asset.prev_total_asset) * + 100, + })) + .sort((a, b) => + sortBy === SortType.PROFIT_RATE + ? b.profitRate - a.profitRate + : b.totalAsset - a.totalAsset, + ); + + return ranking; + } + + private getSortScore(rank: Ranking, sortBy: SortType) { + switch (sortBy) { + case SortType.PROFIT_RATE: + return rank.profitRate; + case SortType.ASSET: + return rank.totalAsset; + default: + return rank.profitRate; + } + } +} diff --git a/BE/src/stock/detail/dto/stock-detail-response.dto.ts b/BE/src/stock/detail/dto/stock-detail-response.dto.ts index 96e4ba52..6c7d1aa6 100644 --- a/BE/src/stock/detail/dto/stock-detail-response.dto.ts +++ b/BE/src/stock/detail/dto/stock-detail-response.dto.ts @@ -24,4 +24,10 @@ export class InquirePriceResponseDto { @ApiProperty({ description: 'PER' }) per: string; + + @ApiProperty({ description: '주식 상한가' }) + stck_mxpr: string; + + @ApiProperty({ description: '주식 하한가' }) + stck_llam: string; } diff --git a/BE/src/stock/detail/stock-detail.module.ts b/BE/src/stock/detail/stock-detail.module.ts index b6447a76..67899209 100644 --- a/BE/src/stock/detail/stock-detail.module.ts +++ b/BE/src/stock/detail/stock-detail.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; +import { KoreaInvestmentModule } from '../../common/koreaInvestment/korea-investment.module'; import { StockDetailController } from './stock-detail.controller'; import { StockDetailService } from './stock-detail.service'; import { StockDetailRepository } from './stock-detail.repository'; @@ -10,5 +10,6 @@ import { Stocks } from './stock-detail.entity'; imports: [KoreaInvestmentModule, TypeOrmModule.forFeature([Stocks])], controllers: [StockDetailController], providers: [StockDetailService, StockDetailRepository], + exports: [StockDetailService], }) export class StockDetailModule {} diff --git a/BE/src/stock/detail/stock-detail.service.ts b/BE/src/stock/detail/stock-detail.service.ts index 28589a67..b3e537ed 100644 --- a/BE/src/stock/detail/stock-detail.service.ts +++ b/BE/src/stock/detail/stock-detail.service.ts @@ -1,8 +1,5 @@ -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 { Injectable } from '@nestjs/common'; +import { KoreaInvestmentDomainService } from '../../common/koreaInvestment/korea-investment.domain-service'; import { InquirePriceChartApiResponse } from './interface/stock-detail-chart.interface'; import { InquirePriceChartDataDto } from './dto/stock-detail-chart-data.dto'; import { @@ -14,10 +11,8 @@ import { StockDetailRepository } from './stock-detail.repository'; @Injectable() export class StockDetailService { - private readonly logger = new Logger(); - constructor( - private readonly koreaInvestmentService: KoreaInvestmentService, + private readonly koreaInvestmentDomainService: KoreaInvestmentDomainService, private readonly stockDetailRepository: StockDetailRepository, ) {} @@ -29,29 +24,18 @@ export class StockDetailService { * @author uuuo3o */ async getInquirePrice(stockCode: string) { - try { - const queryParams = { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockCode, - }; + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + }; - const response = await this.requestApi( + const response = + await this.koreaInvestmentDomainService.requestApi( 'FHKST01010100', '/uapi/domestic-stock/v1/quotations/inquire-price', queryParams, ); - - return await this.formatStockData(response.output); - } 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; - } + return this.formatStockData(response.output); } /** @@ -77,6 +61,8 @@ export class StockDetailService { prdy_ctrt: stock.prdy_ctrt, hts_avls: stock.hts_avls, per: stock.per, + stck_mxpr: stock.stck_mxpr, + stck_llam: stock.stck_llam, }; } @@ -96,33 +82,23 @@ export class StockDetailService { date2: string, periodDivCode: string, ) { - try { - const queryParams = { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockCode, - fid_input_date_1: date1, - fid_input_date_2: date2, - fid_period_div_code: periodDivCode, - fid_org_adj_prc: '0', - }; + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + fid_input_date_1: date1, + fid_input_date_2: date2, + fid_period_div_code: periodDivCode, + fid_org_adj_prc: '0', + }; - const response = await this.requestApi( + const response = + await this.koreaInvestmentDomainService.requestApi( 'FHKST03010100', '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice', queryParams, ); - return this.formatStockInquirePriceData(response).slice().reverse(); - } 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; - } + return this.formatStockInquirePriceData(response).slice().reverse(); } /** @@ -158,41 +134,4 @@ export class StockDetailService { return stockData; }); } - - /** - * @private 한국투자 Open API - API 호출용 공통 함수 - * @param {string} trId - API 호출에 사용할 tr_id - * @param {string} apiURL - API 호출에 사용할 URL - * @param {Record} params - API 요청 시 필요한 쿼리 파라미터 DTO - * @returns - API 호출에 대한 응답 데이터 - * - * @author uuuo3o - */ - private async requestApi( - trId: string, - apiURL: string, - params: Record, - ): Promise { - try { - const accessToken = await this.koreaInvestmentService.getAccessToken(); - const headers = getHeader(accessToken, trId); - const url = getFullURL(apiURL); - - 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; - } - } } diff --git a/BE/src/stock/index/stock-index-socket.service.ts b/BE/src/stock/index/stock-index-socket.service.ts index f82ba9a2..05ecb307 100644 --- a/BE/src/stock/index/stock-index-socket.service.ts +++ b/BE/src/stock/index/stock-index-socket.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { StockIndexValueElementDto } from './dto/stock-index-value-element.dto'; -import { BaseSocketService } from '../../websocket/base-socket.service'; -import { SocketGateway } from '../../websocket/socket.gateway'; +import { BaseSocketDomainService } from '../../common/websocket/base-socket.domain-service'; +import { SocketGateway } from '../../common/websocket/socket.gateway'; @Injectable() export class StockIndexSocketService { @@ -15,16 +15,16 @@ export class StockIndexSocketService { constructor( private readonly socketGateway: SocketGateway, - private readonly baseSocketService: BaseSocketService, + private readonly baseSocketDomainService: BaseSocketDomainService, ) { - baseSocketService.registerSocketOpenHandler(() => { - this.baseSocketService.registerCode(this.TR_ID, '0001'); // 코스피 - this.baseSocketService.registerCode(this.TR_ID, '1001'); // 코스닥 - this.baseSocketService.registerCode(this.TR_ID, '2001'); // 코스피200 - this.baseSocketService.registerCode(this.TR_ID, '3003'); // KSQ150 + baseSocketDomainService.registerSocketOpenHandler(() => { + this.baseSocketDomainService.registerCode(this.TR_ID, '0001'); // 코스피 + this.baseSocketDomainService.registerCode(this.TR_ID, '1001'); // 코스닥 + this.baseSocketDomainService.registerCode(this.TR_ID, '2001'); // 코스피200 + this.baseSocketDomainService.registerCode(this.TR_ID, '3003'); // KSQ150 }); - baseSocketService.registerSocketDataHandler( + baseSocketDomainService.registerSocketDataHandler( this.TR_ID, (data: string[]) => { this.socketGateway.sendStockIndexValueToClient( diff --git a/BE/src/stock/index/stock-index.controller.ts b/BE/src/stock/index/stock-index.controller.ts index 330685e2..0de30ccc 100644 --- a/BE/src/stock/index/stock-index.controller.ts +++ b/BE/src/stock/index/stock-index.controller.ts @@ -3,17 +3,11 @@ import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Cron } from '@nestjs/schedule'; import { StockIndexService } from './stock-index.service'; import { StockIndexResponseDto } from './dto/stock-index-response.dto'; -import { KoreaInvestmentService } from '../../koreaInvestment/korea-investment.service'; -import { SocketGateway } from '../../websocket/socket.gateway'; @Controller('/api/stocks/index') @ApiTags('주가 지수 API') export class StockIndexController { - constructor( - private readonly stockIndexService: StockIndexService, - private readonly koreaInvestmentService: KoreaInvestmentService, - private readonly socketGateway: SocketGateway, - ) {} + constructor(private readonly stockIndexService: StockIndexService) {} @Get() @ApiOperation({ @@ -25,97 +19,12 @@ export class StockIndexController { description: '주가 지수 조회 성공', type: StockIndexResponseDto, }) - async getStockIndex() { - const accessToken = await this.koreaInvestmentService.getAccessToken(); - - const [kospiChart, kosdaqChart, kospi200Chart, ksq150Chart] = - await Promise.all([ - this.stockIndexService.getDomesticStockIndexListByCode( - '0001', - accessToken, - ), // 코스피 - this.stockIndexService.getDomesticStockIndexListByCode( - '1001', - accessToken, - ), // 코스닥 - this.stockIndexService.getDomesticStockIndexListByCode( - '2001', - accessToken, - ), // 코스피200 - this.stockIndexService.getDomesticStockIndexListByCode( - '3003', - accessToken, - ), // KSQ150 - ]); - - const [kospiValue, kosdaqValue, kospi200Value, ksq150Value] = - await Promise.all([ - this.stockIndexService.getDomesticStockIndexValueByCode( - '0001', - accessToken, - ), // 코스피 - this.stockIndexService.getDomesticStockIndexValueByCode( - '1001', - accessToken, - ), // 코스닥 - this.stockIndexService.getDomesticStockIndexValueByCode( - '2001', - accessToken, - ), // 코스피200 - this.stockIndexService.getDomesticStockIndexValueByCode( - '3003', - accessToken, - ), // KSQ150 - ]); - - const stockIndexResponse = new StockIndexResponseDto(); - stockIndexResponse.KOSPI = { - value: kospiValue, - chart: kospiChart, - }; - stockIndexResponse.KOSDAQ = { - value: kosdaqValue, - chart: kosdaqChart, - }; - stockIndexResponse.KOSPI200 = { - value: kospi200Value, - chart: kospi200Chart, - }; - stockIndexResponse.KSQ150 = { - value: ksq150Value, - chart: ksq150Chart, - }; - return stockIndexResponse; + getStockIndex() { + return this.stockIndexService.getDomesticStockIndexList(); } @Cron('*/5 9-16 * * 1-5') async cronStockIndexLists() { - const accessToken = await this.koreaInvestmentService.getAccessToken(); - - const stockLists = await Promise.all([ - this.stockIndexService.getDomesticStockIndexListByCode( - '0001', - accessToken, - ), // 코스피 - this.stockIndexService.getDomesticStockIndexListByCode( - '1001', - accessToken, - ), // 코스닥 - this.stockIndexService.getDomesticStockIndexListByCode( - '2001', - accessToken, - ), // 코스피200 - this.stockIndexService.getDomesticStockIndexListByCode( - '3003', - accessToken, - ), // KSQ150 - ]); - - this.socketGateway.sendStockIndexListToClient({ - KOSPI: stockLists[0], - KOSDAQ: stockLists[1], - KOSPI200: stockLists[2], - KSQ150: stockLists[3], - }); + await this.stockIndexService.cronDomesticStockIndexList(); } } diff --git a/BE/src/stock/index/stock-index.module.ts b/BE/src/stock/index/stock-index.module.ts index d763d972..dd3d21f4 100644 --- a/BE/src/stock/index/stock-index.module.ts +++ b/BE/src/stock/index/stock-index.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { StockIndexController } from './stock-index.controller'; import { StockIndexService } from './stock-index.service'; -import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; -import { SocketModule } from '../../websocket/socket.module'; +import { KoreaInvestmentModule } from '../../common/koreaInvestment/korea-investment.module'; +import { SocketModule } from '../../common/websocket/socket.module'; import { StockIndexSocketService } from './stock-index-socket.service'; @Module({ diff --git a/BE/src/stock/index/stock-index.service.ts b/BE/src/stock/index/stock-index.service.ts index fa05ed4c..f68aa9d1 100644 --- a/BE/src/stock/index/stock-index.service.ts +++ b/BE/src/stock/index/stock-index.service.ts @@ -1,21 +1,91 @@ -import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import axios from 'axios'; +import { Injectable } from '@nestjs/common'; import { StockIndexListChartElementDto } from './dto/stock-index-list-chart.element.dto'; import { StockIndexValueElementDto } from './dto/stock-index-value-element.dto'; import { StockIndexChartInterface, StockIndexValueInterface, } from './interface/stock-index.interface'; -import { getFullURL } from '../../util/get-full-URL'; -import { getHeader } from '../../util/get-header'; +import { KoreaInvestmentDomainService } from '../../common/koreaInvestment/korea-investment.domain-service'; +import { StockIndexResponseDto } from './dto/stock-index-response.dto'; +import { SocketGateway } from '../../common/websocket/socket.gateway'; @Injectable() export class StockIndexService { - async getDomesticStockIndexListByCode(code: string, accessToken: string) { - const result = await this.requestDomesticStockIndexListApi( - code, - accessToken, - ); + constructor( + private readonly koreaInvestmentDomainService: KoreaInvestmentDomainService, + private readonly socketGateway: SocketGateway, + ) {} + + async getDomesticStockIndexList() { + await this.koreaInvestmentDomainService.getAccessToken(); + + const [kospiChart, kosdaqChart, kospi200Chart, ksq150Chart] = + await Promise.all([ + this.getDomesticStockIndexListByCode('0001'), // 코스피 + this.getDomesticStockIndexListByCode('1001'), // 코스닥 + this.getDomesticStockIndexListByCode('2001'), // 코스피200 + this.getDomesticStockIndexListByCode('3003'), // KSQ150 + ]); + + const [kospiValue, kosdaqValue, kospi200Value, ksq150Value] = + await Promise.all([ + this.getDomesticStockIndexValueByCode('0001'), // 코스피 + this.getDomesticStockIndexValueByCode('1001'), // 코스닥 + this.getDomesticStockIndexValueByCode('2001'), // 코스피200 + this.getDomesticStockIndexValueByCode('3003'), // KSQ150 + ]); + + const stockIndexResponse = new StockIndexResponseDto(); + stockIndexResponse.KOSPI = { + value: kospiValue, + chart: kospiChart, + }; + stockIndexResponse.KOSDAQ = { + value: kosdaqValue, + chart: kosdaqChart, + }; + stockIndexResponse.KOSPI200 = { + value: kospi200Value, + chart: kospi200Chart, + }; + stockIndexResponse.KSQ150 = { + value: ksq150Value, + chart: ksq150Chart, + }; + return stockIndexResponse; + } + + async cronDomesticStockIndexList() { + await this.koreaInvestmentDomainService.getAccessToken(); + + const stockLists = await Promise.all([ + this.getDomesticStockIndexListByCode('0001'), // 코스피 + this.getDomesticStockIndexListByCode('1001'), // 코스닥 + this.getDomesticStockIndexListByCode('2001'), // 코스피200 + this.getDomesticStockIndexListByCode('3003'), // KSQ150 + ]); + + this.socketGateway.sendStockIndexListToClient({ + KOSPI: stockLists[0], + KOSDAQ: stockLists[1], + KOSPI200: stockLists[2], + KSQ150: stockLists[3], + }); + } + + private async getDomesticStockIndexListByCode(code: string) { + const queryParams = { + fid_input_hour_1: '300', + fid_cond_mrkt_div_code: 'U', + fid_input_iscd: code, + }; + + const result = + await this.koreaInvestmentDomainService.requestApi( + 'FHPUP02110200', + '/uapi/domestic-stock/v1/quotations/inquire-index-timeprice', + queryParams, + ); return result.output.map((element) => { return new StockIndexListChartElementDto( @@ -26,11 +96,18 @@ export class StockIndexService { }); } - async getDomesticStockIndexValueByCode(code: string, accessToken: string) { - const result = await this.requestDomesticStockIndexValueApi( - code, - accessToken, - ); + private async getDomesticStockIndexValueByCode(code: string) { + const queryParams = { + fid_cond_mrkt_div_code: 'U', + fid_input_iscd: code, + }; + + const result = + await this.koreaInvestmentDomainService.requestApi( + 'FHPUP02100000', + '/uapi/domestic-stock/v1/quotations/inquire-index-price', + queryParams, + ); const data = result.output; @@ -41,55 +118,4 @@ export class StockIndexService { data.prdy_vrss_sign, ); } - - private async requestDomesticStockIndexListApi( - code: string, - accessToken: string, - ) { - const response = await axios - .get( - getFullURL( - '/uapi/domestic-stock/v1/quotations/inquire-index-timeprice', - ), - { - headers: getHeader(accessToken, 'FHPUP02110200'), - params: { - fid_input_hour_1: 300, - fid_cond_mrkt_div_code: 'U', - fid_input_iscd: code, - }, - }, - ) - .catch(() => { - throw new InternalServerErrorException( - '주가 지수 차트 정보를 조회하지 못했습니다.', - ); - }); - - return response.data; - } - - private async requestDomesticStockIndexValueApi( - code: string, - accessToken: string, - ) { - const response = await axios - .get( - getFullURL('/uapi/domestic-stock/v1/quotations/inquire-index-price'), - { - headers: getHeader(accessToken, 'FHPUP02100000'), - params: { - fid_cond_mrkt_div_code: 'U', - fid_input_iscd: code, - }, - }, - ) - .catch(() => { - throw new InternalServerErrorException( - '주가 지수 값 정보를 조회하지 못했습니다.', - ); - }); - - return response.data; - } } diff --git a/BE/src/stock/list/stock-list.controller.ts b/BE/src/stock/list/stock-list.controller.ts index 4db69192..e0ebb13c 100644 --- a/BE/src/stock/list/stock-list.controller.ts +++ b/BE/src/stock/list/stock-list.controller.ts @@ -22,6 +22,11 @@ export class StockListController { description: '모든 주식 종목 리스트를 조회한다.', }) @Get() + @ApiResponse({ + status: 200, + description: '주식 종목 리스트 조회 성공', + type: [StockListResponseDto], + }) async findAll(): Promise { return this.stockListService.findAll(); } diff --git a/BE/src/stock/order/dto/stock-order-element-response.dto.ts b/BE/src/stock/order/dto/stock-order-element-response.dto.ts new file mode 100644 index 00000000..dc5eb0e1 --- /dev/null +++ b/BE/src/stock/order/dto/stock-order-element-response.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TradeType } from '../enum/trade-type'; + +export class StockOrderElementResponseDto { + constructor( + id: number, + stock_code: string, + stock_name: string, + amount: number, + price: number, + trade_type: TradeType, + created_at: Date, + ) { + this.id = id; + this.stock_code = stock_code; + this.stock_name = stock_name; + this.amount = amount; + this.price = price; + this.trade_type = trade_type; + this.created_at = created_at; + } + + @ApiProperty({ description: '주문 id' }) + id: number; + + @ApiProperty({ description: '종목 코드' }) + stock_code: string; + + @ApiProperty({ description: '종목 이름' }) + stock_name: string; + + @ApiProperty({ description: '매도/매수 희망 수량' }) + amount: number; + + @ApiProperty({ description: '매도/매수 희망 가격' }) + price: number; + + @ApiProperty({ description: '매도: SELL, 매수: BUY' }) + trade_type: TradeType; + + @ApiProperty({ description: '주문 시간' }) + created_at: Date; +} diff --git a/BE/src/stock/order/dto/stock-order-request.dto.ts b/BE/src/stock/order/dto/stock-order-request.dto.ts index 407d05ad..458f54af 100644 --- a/BE/src/stock/order/dto/stock-order-request.dto.ts +++ b/BE/src/stock/order/dto/stock-order-request.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsNumber, IsPositive } from 'class-validator'; +import { IsInt, IsNotEmpty, IsNumber, IsPositive } from 'class-validator'; export class StockOrderRequestDto { @ApiProperty({ description: '주식 id', example: '005930' }) + @IsNotEmpty() stock_code: string; @ApiProperty({ description: '매수/매도 희망 가격' }) diff --git a/BE/src/stock/order/interface/request.interface.ts b/BE/src/stock/order/interface/request.interface.ts deleted file mode 100644 index d7616da0..00000000 --- a/BE/src/stock/order/interface/request.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface RequestInterface { - user: { - id: number; - email: string; - password: string; - tutorial: boolean; - kakaoId: number; - }; -} diff --git a/BE/src/stock/order/interface/stock-order-raw.interface.ts b/BE/src/stock/order/interface/stock-order-raw.interface.ts new file mode 100644 index 00000000..293048c6 --- /dev/null +++ b/BE/src/stock/order/interface/stock-order-raw.interface.ts @@ -0,0 +1,17 @@ +import { StatusType } from '../enum/status-type'; +import { TradeType } from '../enum/trade-type'; + +export interface StockOrderRawInterface { + o_id: number; + o_user_id: number; + o_stock_code: string; + o_trade_type: TradeType; + o_amount: number; + o_price: number; + o_status: StatusType; + o_created_at: Date; + o_completed_at: Date; + s_code: string; + s_name: string; + s_market: string; +} diff --git a/BE/src/stock/order/stock-order-socket.service.ts b/BE/src/stock/order/stock-order-socket.service.ts index 6e2bec3b..60c6afa1 100644 --- a/BE/src/stock/order/stock-order-socket.service.ts +++ b/BE/src/stock/order/stock-order-socket.service.ts @@ -4,8 +4,8 @@ import { Logger, } from '@nestjs/common'; import { LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { BaseSocketService } from '../../websocket/base-socket.service'; -import { SocketGateway } from '../../websocket/socket.gateway'; +import { BaseSocketDomainService } from '../../common/websocket/base-socket.domain-service'; +import { SocketGateway } from '../../common/websocket/socket.gateway'; import { Order } from './stock-order.entity'; import { TradeType } from './enum/trade-type'; import { StatusType } from './enum/status-type'; @@ -19,36 +19,36 @@ export class StockOrderSocketService { constructor( private readonly socketGateway: SocketGateway, - private readonly baseSocketService: BaseSocketService, + private readonly baseSocketDomainService: BaseSocketDomainService, private readonly stockOrderRepository: StockOrderRepository, ) { - baseSocketService.registerSocketOpenHandler(async () => { + baseSocketDomainService.registerSocketOpenHandler(async () => { const orders: Order[] = await this.stockOrderRepository.findAllCodeByStatus(); orders.forEach((order) => { - baseSocketService.registerCode(this.TR_ID, order.stock_code); + baseSocketDomainService.registerCode(this.TR_ID, order.stock_code); }); }); - baseSocketService.registerSocketDataHandler( + baseSocketDomainService.registerSocketDataHandler( this.TR_ID, (data: string[]) => { this.checkExecutableOrder( data[0], // 주식 코드 data[2], // 주식 체결가 - ).catch(() => { - throw new InternalServerErrorException(); + ).catch((err) => { + throw new InternalServerErrorException(err); }); }, ); } subscribeByCode(trKey: string) { - this.baseSocketService.registerCode(this.TR_ID, trKey); + this.baseSocketDomainService.registerCode(this.TR_ID, trKey); } unsubscribeByCode(trKey: string) { - this.baseSocketService.unregisterCode(this.TR_ID, trKey); + this.baseSocketDomainService.unregisterCode(this.TR_ID, trKey); } private async checkExecutableOrder(stockCode: string, value) { diff --git a/BE/src/stock/order/stock-order.controller.ts b/BE/src/stock/order/stock-order.controller.ts index 31960590..c43199d9 100644 --- a/BE/src/stock/order/stock-order.controller.ts +++ b/BE/src/stock/order/stock-order.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + Get, Param, Post, Req, @@ -14,12 +15,14 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { Request } from 'express'; +import { Cron } from '@nestjs/schedule'; 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'; +import { StockOrderElementResponseDto } from './dto/stock-order-element-response.dto'; -@Controller('/api/stocks/trade') +@Controller('/api/stocks/order') @ApiTags('주식 매수/매도 API') export class StockOrderController { constructor(private readonly stockOrderService: StockOrderService) {} @@ -36,10 +39,13 @@ export class StockOrderController { description: '주식 매수 예약 등록 성공', }) async buy( - @Req() request: RequestInterface, + @Req() request: Request, @Body(ValidationPipe) stockOrderRequest: StockOrderRequestDto, ) { - await this.stockOrderService.buy(request.user.id, stockOrderRequest); + await this.stockOrderService.buy( + parseInt(request.user.userId, 10), + stockOrderRequest, + ); } @Post('/sell') @@ -54,13 +60,16 @@ export class StockOrderController { description: '주식 매도 예약 등록 성공', }) async sell( - @Req() request: RequestInterface, + @Req() request: Request, @Body(ValidationPipe) stockOrderRequest: StockOrderRequestDto, ) { - await this.stockOrderService.sell(request.user.id, stockOrderRequest); + await this.stockOrderService.sell( + parseInt(request.user.userId, 10), + stockOrderRequest, + ); } - @Delete('/:order_id') + @Delete('/:orderId') @ApiBearerAuth() @UseGuards(JwtAuthGuard) @ApiOperation({ @@ -71,10 +80,33 @@ export class StockOrderController { status: 200, description: '주식 매도/매수 취소 성공', }) - async cancel( - @Req() request: RequestInterface, - @Param('order_id') orderId: number, - ) { - await this.stockOrderService.cancel(request.user.id, orderId); + async cancel(@Req() request: Request, @Param('orderId') orderId: number) { + await this.stockOrderService.cancel( + parseInt(request.user.userId, 10), + orderId, + ); + } + + @Get('/list') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '미체결 주문 리스트 조회 API', + description: '미체결 주문 취소를 위해, 미체결된 주문 리스트를 조회한다.', + }) + @ApiResponse({ + status: 200, + description: '미체결 주문 리스트 조회 성공', + type: [StockOrderElementResponseDto], + }) + async getPendingList(@Req() request: Request) { + return this.stockOrderService.getPendingListByUserId( + parseInt(request.user.userId, 10), + ); + } + + @Cron('0 18 * * *') + async cronRemovePendingOrders() { + await this.stockOrderService.removePendingOrders(); } } diff --git a/BE/src/stock/order/stock-order.module.ts b/BE/src/stock/order/stock-order.module.ts index 155c0855..375d4a53 100644 --- a/BE/src/stock/order/stock-order.module.ts +++ b/BE/src/stock/order/stock-order.module.ts @@ -4,18 +4,12 @@ import { StockOrderController } from './stock-order.controller'; import { StockOrderService } from './stock-order.service'; import { Order } from './stock-order.entity'; import { StockOrderRepository } from './stock-order.repository'; -import { SocketModule } from '../../websocket/socket.module'; +import { SocketModule } from '../../common/websocket/socket.module'; import { AssetModule } from '../../asset/asset.module'; import { StockOrderSocketService } from './stock-order-socket.service'; -import { UserStockModule } from '../../userStock/user-stock.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([Order]), - SocketModule, - AssetModule, - UserStockModule, - ], + imports: [TypeOrmModule.forFeature([Order]), SocketModule, AssetModule], controllers: [StockOrderController], providers: [StockOrderService, StockOrderRepository, StockOrderSocketService], }) diff --git a/BE/src/stock/order/stock-order.repository.ts b/BE/src/stock/order/stock-order.repository.ts index 6cd70c39..92da1ce6 100644 --- a/BE/src/stock/order/stock-order.repository.ts +++ b/BE/src/stock/order/stock-order.repository.ts @@ -4,7 +4,8 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Order } from './stock-order.entity'; import { StatusType } from './enum/status-type'; import { Asset } from '../../asset/asset.entity'; -import { UserStock } from '../../userStock/user-stock.entity'; +import { UserStock } from '../../asset/user-stock.entity'; +import { StockOrderRawInterface } from './interface/stock-order-raw.interface'; @Injectable() export class StockOrderRepository extends Repository { @@ -15,6 +16,7 @@ export class StockOrderRepository extends Repository { async findAllCodeByStatus() { return this.createQueryBuilder('orders') .select('DISTINCT orders.stock_code') + .where({ status: StatusType.PENDING }) .getRawMany(); } @@ -28,43 +30,36 @@ export class StockOrderRepository extends Repository { { id: order.id }, { status: StatusType.COMPLETE, completed_at: new Date() }, ); - // TODO: stock_balance와 total_asset은 실시간 주가에 따라 변동하도록 따로 구현해야 함 await queryRunner.manager .createQueryBuilder() .update(Asset) .set({ - cash_balance: () => `cash_balance - ${realPrice}`, - total_asset: () => `total_asset - ${realPrice}`, - total_profit: () => `total_profit - ${realPrice}`, - total_profit_rate: () => `total_profit / 10000000`, + cash_balance: () => `cash_balance - :realPrice`, last_updated: new Date(), }) .where({ user_id: order.user_id }) + .setParameters({ realPrice }) .execute(); - await queryRunner.manager - .createQueryBuilder() - .insert() - .into(UserStock) - .values({ - user_id: order.user_id, - stock_code: order.stock_code, - quantity: order.amount, - avg_price: order.price, - }) - .orUpdate( - [ - `quantity = quantity + ${order.amount}`, - `avg_price = ((avg_price * quantity + ${order.price} * ${order.amount}) / (quantity + ${order.amount}))`, - ], - ['user_id', 'stock_code'], - ) - .execute(); + await queryRunner.query( + `INSERT INTO user_stocks (user_id, stock_code, quantity, avg_price, last_updated) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE avg_price = (avg_price * quantity + ? * ?) / (quantity + ?), quantity = quantity + ?`, + [ + order.user_id, + order.stock_code, + order.amount, + order.price, + new Date(), + order.price, + order.amount, + order.amount, + order.amount, + ], + ); await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); - throw new InternalServerErrorException(); + throw new InternalServerErrorException(err); } finally { await queryRunner.release(); } @@ -80,27 +75,25 @@ export class StockOrderRepository extends Repository { { id: order.id }, { status: StatusType.COMPLETE, completed_at: new Date() }, ); - // TODO: stock_balance와 total_asset은 실시간 주가에 따라 변동하도록 따로 구현해야 함 await queryRunner.manager .createQueryBuilder() .update(Asset) .set({ - cash_balance: () => `cash_balance + ${realPrice}`, - total_asset: () => `total_asset + ${realPrice}`, - total_profit: () => `total_profit + ${realPrice}`, - total_profit_rate: () => `total_profit / 10000000`, + cash_balance: () => `cash_balance + :realPrice`, last_updated: new Date(), }) .where({ user_id: order.user_id }) + .setParameters({ realPrice }) .execute(); await queryRunner.manager .createQueryBuilder() .update(UserStock) .set({ - quantity: () => `quantity - ${order.amount}`, + quantity: () => `quantity - :newQuantity`, }) .where({ user_id: order.user_id, stock_code: order.stock_code }) + .setParameters({ newQuantity: order.amount }) .execute(); await queryRunner.commitTransaction(); @@ -111,4 +104,11 @@ export class StockOrderRepository extends Repository { await queryRunner.release(); } } + + async findAllPendingOrdersByUserId(userId: number) { + return this.createQueryBuilder('o') + .leftJoinAndSelect('stocks', 's', 's.code = o.stock_code') + .where({ user_id: userId, status: StatusType.PENDING }) + .getRawMany(); + } } diff --git a/BE/src/stock/order/stock-order.service.ts b/BE/src/stock/order/stock-order.service.ts index bdbfe8ce..81002143 100644 --- a/BE/src/stock/order/stock-order.service.ts +++ b/BE/src/stock/order/stock-order.service.ts @@ -10,7 +10,10 @@ import { StockOrderRepository } from './stock-order.repository'; import { TradeType } from './enum/trade-type'; import { StatusType } from './enum/status-type'; import { StockOrderSocketService } from './stock-order-socket.service'; -import { UserStockRepository } from '../../userStock/user-stock.repository'; +import { UserStockRepository } from '../../asset/user-stock.repository'; +import { AssetRepository } from '../../asset/asset.repository'; +import { StockOrderElementResponseDto } from './dto/stock-order-element-response.dto'; +import { Order } from './stock-order.entity'; @Injectable() export class StockOrderService { @@ -18,9 +21,18 @@ export class StockOrderService { private readonly stockOrderRepository: StockOrderRepository, private readonly stockOrderSocketService: StockOrderSocketService, private readonly userStockRepository: UserStockRepository, + private readonly assetRepository: AssetRepository, ) {} async buy(userId: number, stockOrderRequest: StockOrderRequestDto) { + const asset = await this.assetRepository.findOneBy({ user_id: userId }); + + if ( + asset && + asset.cash_balance < stockOrderRequest.amount * stockOrderRequest.price + ) + throw new BadRequestException('가용 자산이 충분하지 않습니다.'); + const order = this.stockOrderRepository.create({ user_id: userId, stock_code: stockOrderRequest.stock_code, @@ -40,7 +52,7 @@ export class StockOrderService { stock_code: stockOrderRequest.stock_code, }); - if (!userStock || userStock.quantity === 0) + if (!userStock || userStock.quantity < stockOrderRequest.amount) throw new BadRequestException('주식을 매도 수만큼 가지고 있지 않습니다.'); const order = this.stockOrderRepository.create({ @@ -77,4 +89,34 @@ export class StockOrderService { ) this.stockOrderSocketService.unsubscribeByCode(order.stock_code); } + + async getPendingListByUserId(userId: number) { + const stockOrderRaws = + await this.stockOrderRepository.findAllPendingOrdersByUserId(userId); + + return stockOrderRaws.map((stockOrderRaw) => { + return new StockOrderElementResponseDto( + stockOrderRaw.o_id, + stockOrderRaw.o_stock_code, + stockOrderRaw.s_name, + stockOrderRaw.o_amount, + stockOrderRaw.o_price, + stockOrderRaw.o_trade_type, + stockOrderRaw.o_created_at, + ); + }); + } + + async removePendingOrders() { + const orders: Order[] = + await this.stockOrderRepository.findAllCodeByStatus(); + + await Promise.all( + orders.map((order) => + this.stockOrderSocketService.unsubscribeByCode(order.stock_code), + ), + ); + + await this.stockOrderRepository.delete({ status: StatusType.PENDING }); + } } diff --git a/BE/src/stock/topfive/dto/stock-ranking-data.dto.ts b/BE/src/stock/topfive/dto/stock-ranking-data.dto.ts index 5f1a3026..268283e7 100644 --- a/BE/src/stock/topfive/dto/stock-ranking-data.dto.ts +++ b/BE/src/stock/topfive/dto/stock-ranking-data.dto.ts @@ -4,6 +4,9 @@ import { ApiProperty } from '@nestjs/swagger'; * 등락률 API 요청 후 받은 응답값 정제용 DTO */ export class StockRankingDataDto { + @ApiProperty({ description: '주식 종목 코드' }) + stck_shrn_iscd: string; + @ApiProperty({ description: 'HTS 한글 종목명' }) hts_kor_isnm: string; diff --git a/BE/src/stock/topfive/stock-topfive.module.ts b/BE/src/stock/topfive/stock-topfive.module.ts index 5be62a4f..1b2fb051 100644 --- a/BE/src/stock/topfive/stock-topfive.module.ts +++ b/BE/src/stock/topfive/stock-topfive.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { StockTopfiveController } from './stock-topfive.controller'; import { StockTopfiveService } from './stock-topfive.service'; -import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; +import { KoreaInvestmentModule } from '../../common/koreaInvestment/korea-investment.module'; @Module({ imports: [ConfigModule, KoreaInvestmentModule], diff --git a/BE/src/stock/topfive/stock-topfive.service.ts b/BE/src/stock/topfive/stock-topfive.service.ts index 82f659d5..15052081 100644 --- a/BE/src/stock/topfive/stock-topfive.service.ts +++ b/BE/src/stock/topfive/stock-topfive.service.ts @@ -1,5 +1,4 @@ -import axios from 'axios'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { StockRankingQueryParameterDto } from './dto/stock-ranking-request.dto'; import { StockRankingResponseDto } from './dto/stock-ranking-response.dto'; import { StockRankingDataDto } from './dto/stock-ranking-data.dto'; @@ -8,50 +7,14 @@ import { StockApiOutputData, StockApiResponse, } from './interface/stock-topfive.interface'; -import { getHeader } from '../../util/get-header'; -import { getFullURL } from '../../util/get-full-URL'; -import { KoreaInvestmentService } from '../../koreaInvestment/korea-investment.service'; +import { KoreaInvestmentDomainService } from '../../common/koreaInvestment/korea-investment.domain-service'; @Injectable() export class StockTopfiveService { - private readonly logger = new Logger(); - constructor( - private readonly koreaInvestmentService: KoreaInvestmentService, + private readonly koreaInvestmentDomainService: KoreaInvestmentDomainService, ) {} - /** - * @private 한국투자 Open API - [국내주식] 순위 분석 - 국내주식 등락률 순위 호출 함수 - * @param {StockRankingQueryParameterDto} queryParams - API 요청 시 필요한 쿼리 파라미터 DTO - * @returns - 국내주식 등락률 데이터 - * - * @author uuuo3o - */ - private async requestApi(queryParams: StockRankingQueryParameterDto) { - try { - const accessToken = await this.koreaInvestmentService.getAccessToken(); - const headers = getHeader(accessToken, 'FHPST01700000'); - const url = getFullURL('/uapi/domestic-stock/v1/ranking/fluctuation'); - const params = this.getStockRankingParams(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; - } - } - /** * 국내주식 등락률 데이터 중 필요한 시장 종류 데이터를 반환하는 함수 * @param {MarketType} marketType - 시장 종류(ALL, KOSPI, KOSDAQ, KOSPI200) @@ -60,52 +23,51 @@ export class StockTopfiveService { * @author uuuo3o */ async getMarketRanking(marketType: MarketType) { - try { - const queryParams = new StockRankingQueryParameterDto(); - queryParams.fid_cond_mrkt_div_code = 'J'; + const queryParams = new StockRankingQueryParameterDto(); + queryParams.fid_cond_mrkt_div_code = 'J'; - switch (marketType) { - case MarketType.ALL: - queryParams.fid_input_iscd = '0000'; - break; - case MarketType.KOSPI: - queryParams.fid_input_iscd = '0001'; - break; - case MarketType.KOSDAQ: - queryParams.fid_input_iscd = '1001'; - break; - case MarketType.KOSPI200: - queryParams.fid_input_iscd = '2001'; - break; - default: - break; - } + switch (marketType) { + case MarketType.ALL: + queryParams.fid_input_iscd = '0000'; + break; + case MarketType.KOSPI: + queryParams.fid_input_iscd = '0001'; + break; + case MarketType.KOSDAQ: + queryParams.fid_input_iscd = '1001'; + break; + case MarketType.KOSPI200: + queryParams.fid_input_iscd = '2001'; + break; + default: + break; + } - const highResponse = await this.requestApi({ - ...queryParams, - fid_rank_sort_cls_code: '0', - }); + const highResponse = + await this.koreaInvestmentDomainService.requestApi( + 'FHPST01700000', + '/uapi/domestic-stock/v1/ranking/fluctuation', + this.getStockRankingParams({ + ...queryParams, + fid_rank_sort_cls_code: '0', + }), + ); - const lowResponse = await this.requestApi({ - ...queryParams, - fid_rank_sort_cls_code: '1', - }); + const lowResponse = + await this.koreaInvestmentDomainService.requestApi( + 'FHPST01700000', + '/uapi/domestic-stock/v1/ranking/fluctuation', + this.getStockRankingParams({ + ...queryParams, + fid_rank_sort_cls_code: '1', + }), + ); - const response = new StockRankingResponseDto(); - response.high = this.formatStockData(highResponse.output); - response.low = this.formatStockData(lowResponse.output); + const response = new StockRankingResponseDto(); + response.high = this.formatStockData(highResponse.output); + response.low = this.formatStockData(lowResponse.output); - return 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; - } + return response; } /** @@ -118,6 +80,7 @@ export class StockTopfiveService { private formatStockData(stocks: StockApiOutputData[]) { return stocks.slice(0, 5).map((stock) => { const stockData = new StockRankingDataDto(); + stockData.stck_shrn_iscd = stock.stck_shrn_iscd; stockData.hts_kor_isnm = stock.hts_kor_isnm; stockData.stck_prpr = stock.stck_prpr; stockData.prdy_vrss = stock.prdy_vrss; diff --git a/BE/src/stock/trade/history/dto/stock-detail-socket-data.dto.ts b/BE/src/stock/trade/history/dto/stock-detail-socket-data.dto.ts new file mode 100644 index 00000000..c13362d9 --- /dev/null +++ b/BE/src/stock/trade/history/dto/stock-detail-socket-data.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StockDetailSocketDataDto { + @ApiProperty({ description: '주식 현재가' }) + stck_prpr: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; +} diff --git a/BE/src/stock/trade/history/interface/sse-event.ts b/BE/src/stock/trade/history/interface/sse-event.ts new file mode 100644 index 00000000..e20f58cb --- /dev/null +++ b/BE/src/stock/trade/history/interface/sse-event.ts @@ -0,0 +1,6 @@ +export interface SseEvent { + data: string; + id?: string; + type?: string; + retry?: number; +} diff --git a/BE/src/stock/trade/history/stock-trade-history-socket.service.ts b/BE/src/stock/trade/history/stock-trade-history-socket.service.ts new file mode 100644 index 00000000..9b055a3c --- /dev/null +++ b/BE/src/stock/trade/history/stock-trade-history-socket.service.ts @@ -0,0 +1,155 @@ +import { WebSocket } from 'ws'; +import axios from 'axios'; +import { Observable, Subject } from 'rxjs'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { SseEvent } from './interface/sse-event'; +import { SocketConnectTokenInterface } from '../../../common/websocket/interface/socket.interface'; +import { getFullTestURL } from '../../../util/get-full-URL'; +import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-data.dto'; +import { SocketGateway } from '../../../common/websocket/socket.gateway'; +import { StockDetailSocketDataDto } from './dto/stock-detail-socket-data.dto'; + +@Injectable() +export class StockTradeHistorySocketService implements OnModuleInit { + private readonly logger = new Logger(''); + private socket: WebSocket; + private socketConnectionKey: string; + private subscribedStocks = new Set(); + private TR_ID = 'H0STCNT0'; + private eventSubject = new Subject(); + + constructor(private readonly socketGateway: SocketGateway) {} + + async onModuleInit() { + this.socketConnectionKey = await this.getSocketConnectionKey(); + this.socket = new WebSocket(process.env.KOREA_INVESTMENT_TEST_SOCKET_URL); + + this.socket.onopen = () => {}; + + this.socket.onmessage = (event) => { + const data = + typeof event.data === 'string' + ? event.data.split('|') + : JSON.stringify(event.data); + + if (data.length < 2) { + const json = JSON.parse(data[0]); + if (json.body) + this.logger.log( + `한국투자증권 웹소켓 연결: ${json.body.msg1}`, + json.header.tr_id, + ); + if (json.header.tr_id === 'PINGPONG') + this.socket.pong(JSON.stringify(json)); + return; + } + + const dataList = data[3].split('^'); + + const tradeData: TodayStockTradeHistoryDataDto = { + stck_cntg_hour: dataList[1], + stck_prpr: dataList[2], + prdy_vrss_sign: dataList[3], + cntg_vol: dataList[12], + prdy_ctrt: dataList[5], + }; + + const detailData: StockDetailSocketDataDto = { + stck_prpr: dataList[2], + prdy_vrss_sign: dataList[3], + prdy_vrss: dataList[4], + prdy_ctrt: dataList[5], + }; + + this.eventSubject.next({ + data: JSON.stringify({ + tradeData, + }), + }); + + this.socketGateway.sendStockTradeHistoryValueToClient( + `trade-history/${dataList[0]}`, + tradeData, + ); + + this.socketGateway.sendStockIndexValueToClient( + `detail/${dataList[0]}`, + detailData, + ); + }; + + this.socket.onclose = () => { + this.logger.warn(`한국투자증권 소켓 연결 종료`); + }; + } + + getTradeDataStream(): Observable { + return this.eventSubject.asObservable(); + } + + subscribeByCode(stockCode: string) { + this.registerCode(this.TR_ID, stockCode); + this.subscribedStocks.add(stockCode); + } + + unsubscribeByCode(stockCode: string) { + this.unregisterCode(this.TR_ID, stockCode); + this.subscribedStocks.delete(stockCode); + } + + registerCode(trId: string, trKey: string) { + this.socket.send( + JSON.stringify({ + header: { + approval_key: this.socketConnectionKey, + custtype: 'P', + tr_type: '1', + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: trId, + tr_key: trKey, + }, + }, + }), + ); + } + + unregisterCode(trId: string, trKey: string) { + this.socket.send( + JSON.stringify({ + header: { + approval_key: this.socketConnectionKey, + custtype: 'P', + tr_type: '2', + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: trId, + tr_key: trKey, + }, + }, + }), + ); + } + + async getSocketConnectionKey() { + if (this.socketConnectionKey) { + return this.socketConnectionKey; + } + + const response = await axios.post( + getFullTestURL('/oauth2/Approval'), + { + grant_type: 'client_credentials', + appkey: process.env.KOREA_INVESTMENT_TEST_APP_KEY, + secretkey: process.env.KOREA_INVESTMENT_TEST_APP_SECRET, + }, + ); + + this.socketConnectionKey = response.data.approval_key; + return this.socketConnectionKey; + } +} diff --git a/BE/src/stock/trade/history/stock-trade-history.controller.ts b/BE/src/stock/trade/history/stock-trade-history.controller.ts index 1b4c1533..74f21c44 100644 --- a/BE/src/stock/trade/history/stock-trade-history.controller.ts +++ b/BE/src/stock/trade/history/stock-trade-history.controller.ts @@ -1,14 +1,18 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Controller, Get, Param, Sse } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { StockTradeHistoryService } from './stock-trade-history.service'; -import { TodayStockTradeHistoryResponseDto } from './dto/today-stock-trade-history-response.dto'; +import { StockTradeHistorySocketService } from './stock-trade-history-socket.service'; +import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-data.dto'; import { DailyStockTradeHistoryDataDto } from './dto/daily-stock-trade-history-data.dto'; +import { SseEvent } from './interface/sse-event'; @ApiTags('주식현재가 체결 조회 API') @Controller('/api/stocks/trade-history') export class StockTradeHistoryController { constructor( private readonly stockTradeHistoryService: StockTradeHistoryService, + private readonly stockTradeHistorySocketService: StockTradeHistorySocketService, ) {} @Get(':stockCode/today') @@ -23,10 +27,15 @@ export class StockTradeHistoryController { @ApiResponse({ status: 200, description: '단일 주식 종목에 대한 주식현재가 체결값 조회 성공', - type: TodayStockTradeHistoryResponseDto, + type: TodayStockTradeHistoryDataDto, }) getTodayStockTradeHistory(@Param('stockCode') stockCode: string) { - return this.stockTradeHistoryService.getTodayStockTradeHistory(stockCode); + const data = + this.stockTradeHistoryService.getTodayStockTradeHistory(stockCode); + + this.stockTradeHistorySocketService.subscribeByCode(stockCode); + + return data; } @Get(':stockCode/daily') @@ -46,4 +55,51 @@ export class StockTradeHistoryController { getDailyStockTradeHistory(@Param('stockCode') stockCode: string) { return this.stockTradeHistoryService.getDailyStockTradeHistory(stockCode); } + + @Sse(':stockCode/today-sse') + @ApiOperation({ summary: '단일 주식 종목에 대한 실시간 체결 데이터 스트림' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiResponse({ + status: 200, + description: + '단일 주식 종목에 대한 주식현재가 체결값 실시간 데이터 조회 성공', + type: TodayStockTradeHistoryDataDto, + }) + streamTradeHistory(@Param('stockCode') stockCode: string) { + this.stockTradeHistorySocketService.subscribeByCode(stockCode); + + return new Observable((subscriber) => { + const subscription = this.stockTradeHistorySocketService + .getTradeDataStream() + .subscribe(subscriber); + + return () => { + this.stockTradeHistorySocketService.unsubscribeByCode(stockCode); + subscription.unsubscribe(); + }; + }); + } + + @Get(':stockCode/unsubscribe') + @ApiOperation({ summary: '페이지를 벗어날 때 구독을 취소하기 위한 API' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiResponse({ + status: 200, + description: '구독 취소 성공', + }) + unsubscribeCode(@Param('stockCode') stockCode: string) { + this.stockTradeHistorySocketService.unsubscribeByCode(stockCode); + } } diff --git a/BE/src/stock/trade/history/stock-trade-history.module.ts b/BE/src/stock/trade/history/stock-trade-history.module.ts index 965889a1..ca5ce277 100644 --- a/BE/src/stock/trade/history/stock-trade-history.module.ts +++ b/BE/src/stock/trade/history/stock-trade-history.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; -import { KoreaInvestmentModule } from '../../../koreaInvestment/korea-investment.module'; +import { KoreaInvestmentModule } from '../../../common/koreaInvestment/korea-investment.module'; import { StockTradeHistoryController } from './stock-trade-history.controller'; import { StockTradeHistoryService } from './stock-trade-history.service'; +import { StockTradeHistorySocketService } from './stock-trade-history-socket.service'; +import { SocketModule } from '../../../common/websocket/socket.module'; @Module({ - imports: [KoreaInvestmentModule], + imports: [KoreaInvestmentModule, SocketModule], controllers: [StockTradeHistoryController], - providers: [StockTradeHistoryService], + providers: [StockTradeHistoryService, StockTradeHistorySocketService], }) export class StockTradeHistoryModule {} diff --git a/BE/src/stock/trade/history/stock-trade-history.service.ts b/BE/src/stock/trade/history/stock-trade-history.service.ts index 449b9451..564ba4ca 100644 --- a/BE/src/stock/trade/history/stock-trade-history.service.ts +++ b/BE/src/stock/trade/history/stock-trade-history.service.ts @@ -1,8 +1,5 @@ -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 { Injectable } from '@nestjs/common'; +import { KoreaInvestmentDomainService } from '../../../common/koreaInvestment/korea-investment.domain-service'; import { InquireCCNLApiResponse } from './interface/Inquire-ccnl.interface'; import { TodayStockTradeHistoryOutputDto } from './dto/today-stock-trade-history-output.dto'; import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-data.dto'; @@ -12,10 +9,8 @@ import { DailyStockTradeHistoryDataDto } from './dto/daily-stock-trade-history-d @Injectable() export class StockTradeHistoryService { - private readonly logger = new Logger(); - constructor( - private readonly koreaInvestmentService: KoreaInvestmentService, + private readonly koreaInvestmentDomainService: KoreaInvestmentDomainService, ) {} /** @@ -26,29 +21,19 @@ export class StockTradeHistoryService { * @author uuuo3o */ async getTodayStockTradeHistory(stockCode: string) { - try { - const queryParams = { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockCode, - }; + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + }; - const response = await this.requestApi( + const response = + await this.koreaInvestmentDomainService.requestApi( 'FHKST01010300', '/uapi/domestic-stock/v1/quotations/inquire-ccnl', queryParams, ); - return this.formatTodayStockTradeHistoryData(response.output); - } 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; - } + return this.formatTodayStockTradeHistoryData(response.output); } /** @@ -81,31 +66,21 @@ export class StockTradeHistoryService { * @author uuuo3o */ async getDailyStockTradeHistory(stockCode: string) { - try { - const queryParams = { - fid_cond_mrkt_div_code: 'J', - fid_input_iscd: stockCode, - fid_period_div_code: 'D', - fid_org_adj_prc: '0', - }; + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + fid_period_div_code: 'D', + fid_org_adj_prc: '0', + }; - const response = await this.requestApi( + const response = + await this.koreaInvestmentDomainService.requestApi( 'FHKST01010400', '/uapi/domestic-stock/v1/quotations/inquire-daily-price', queryParams, ); - return this.formatDailyStockTradeHistoryData(response.output); - } 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; - } + return this.formatDailyStockTradeHistoryData(response.output); } /** @@ -132,41 +107,4 @@ export class StockTradeHistoryService { return historyData; }); } - - /** - * @private 한국투자 Open API - API 호출용 공통 함수 - * @param {string} trId - API 호출에 사용할 tr_id - * @param {string} apiURL - API 호출에 사용할 URL - * @param {Record} params - API 요청 시 필요한 쿼리 파라미터 DTO - * @returns - API 호출에 대한 응답 데이터 - * - * @author uuuo3o - */ - private async requestApi( - trId: string, - apiURL: string, - params: Record, - ): Promise { - try { - const accessToken = await this.koreaInvestmentService.getAccessToken(); - const headers = getHeader(accessToken, trId); - const url = getFullURL(apiURL); - - 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; - } - } } diff --git a/BE/src/types/express.d.ts b/BE/src/types/express.d.ts index 2060c079..0ad1af45 100644 --- a/BE/src/types/express.d.ts +++ b/BE/src/types/express.d.ts @@ -7,6 +7,7 @@ declare module 'express' { kakaoId?: string; userId?: UUID; email?: string; + nickname?: string; }; } } diff --git a/BE/src/userStock/user-stock.controller.ts b/BE/src/userStock/user-stock.controller.ts deleted file mode 100644 index e1e4c209..00000000 --- a/BE/src/userStock/user-stock.controller.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; - -@Controller('/api/userStock') -@ApiTags('사용자 보유 주식 API') -export class UserStockController {} diff --git a/BE/src/userStock/user-stock.module.ts b/BE/src/userStock/user-stock.module.ts deleted file mode 100644 index a6466976..00000000 --- a/BE/src/userStock/user-stock.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { UserStock } from './user-stock.entity'; -import { UserStockController } from './user-stock.controller'; -import { UserStockRepository } from './user-stock.repository'; -import { UserStockService } from './user-stock.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([UserStock])], - controllers: [UserStockController], - providers: [UserStockRepository, UserStockService], - exports: [UserStockRepository], -}) -export class UserStockModule {} diff --git a/BE/src/userStock/user-stock.repository.ts b/BE/src/userStock/user-stock.repository.ts deleted file mode 100644 index e945a667..00000000 --- a/BE/src/userStock/user-stock.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; -import { UserStock } from './user-stock.entity'; - -@Injectable() -export class UserStockRepository extends Repository { - constructor(@InjectDataSource() private dataSource: DataSource) { - super(UserStock, dataSource.createEntityManager()); - } -} diff --git a/BE/src/userStock/user-stock.service.ts b/BE/src/userStock/user-stock.service.ts deleted file mode 100644 index 1c69dcaf..00000000 --- a/BE/src/userStock/user-stock.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class UserStockService {} diff --git a/BE/src/util/get-full-URL.ts b/BE/src/util/get-full-URL.ts index da79427f..7be7ea50 100644 --- a/BE/src/util/get-full-URL.ts +++ b/BE/src/util/get-full-URL.ts @@ -1,3 +1,7 @@ export const getFullURL = (url: string) => { return `${process.env.KOREA_INVESTMENT_BASE_URL}${url}`; }; + +export const getFullTestURL = (url: string) => { + return `${process.env.KOREA_INVESTMENT_TEST_BASE_URL}${url}`; +}; diff --git a/BE/src/websocket/socket.module.ts b/BE/src/websocket/socket.module.ts deleted file mode 100644 index e7a2dcc7..00000000 --- a/BE/src/websocket/socket.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SocketGateway } from './socket.gateway'; -import { SocketTokenService } from './socket-token.service'; -import { BaseSocketService } from './base-socket.service'; - -@Module({ - providers: [SocketTokenService, SocketGateway, BaseSocketService], - exports: [SocketGateway, BaseSocketService], -}) -export class SocketModule {} diff --git a/FE/Dockerfile b/FE/Dockerfile index e695254d..749bc07a 100644 --- a/FE/Dockerfile +++ b/FE/Dockerfile @@ -25,6 +25,8 @@ RUN npm run build FROM nginx:alpine WORKDIR /app COPY --from=builder /app/dist /usr/share/nginx/html +#time zone 설정 +RUN apk add tzdata && ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file