diff --git a/.github/workflows/code-analyze-sonarqube.yml b/.github/workflows/code-analyze-sonarqube.yml index a2bf6f8d..7dbb095e 100644 --- a/.github/workflows/code-analyze-sonarqube.yml +++ b/.github/workflows/code-analyze-sonarqube.yml @@ -3,19 +3,21 @@ name: Code Analyze With SonarQube run-name: Run code analyze triggered by ${{github.actor}} on: - pull_request: - types: [opened, reopened, synchronize] - branches: - - main - - dev - paths: - - 'src/**' - push: - branches: - - main - - dev - paths: - - 'src/**' + workflow_dispatch: + +# pull_request: +# types: [opened, reopened, synchronize] +# branches: +# - main +# - dev +# paths: +# - 'src/**' +# push: +# branches: +# - main +# - dev +# paths: +# - 'src/**' jobs: build: diff --git a/src/main/java/com/cleanengine/coin/configuration/bootstrap/IconInitializer.java b/src/main/java/com/cleanengine/coin/configuration/bootstrap/AssetInitializer.java similarity index 87% rename from src/main/java/com/cleanengine/coin/configuration/bootstrap/IconInitializer.java rename to src/main/java/com/cleanengine/coin/configuration/bootstrap/AssetInitializer.java index e4387640..abadb900 100644 --- a/src/main/java/com/cleanengine/coin/configuration/bootstrap/IconInitializer.java +++ b/src/main/java/com/cleanengine/coin/configuration/bootstrap/AssetInitializer.java @@ -1,8 +1,9 @@ package com.cleanengine.coin.configuration.bootstrap; import com.cleanengine.coin.common.annotation.WorkingServerProfile; -import com.cleanengine.coin.order.domain.Asset; import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.order.domain.Asset; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.ApplicationArguments; @@ -25,14 +26,19 @@ @WorkingServerProfile @Slf4j @RequiredArgsConstructor -public class IconInitializer implements ApplicationRunner { +public class AssetInitializer implements ApplicationRunner { private final AssetRepository assetRepository; + private final AssetService assetService; private final ResourceLoader resourceLoader; @Override - public void run(ApplicationArguments args) throws Exception { + public void run(ApplicationArguments args) { List assets = loadAssets(); + updateIfIconAbsentInDB(assets); + assetService.setAssetCache(assets); + } + private void updateIfIconAbsentInDB(List assets) { for(Asset asset : assets){ if(asset.getIcon() != null) continue; byte[] encodedIconBytes = loadEncodedIcon(asset.getTicker()); diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java index 4115e9f7..a0d422ac 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java @@ -2,10 +2,14 @@ import com.cleanengine.coin.order.domain.Asset; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; public interface AssetRepository extends JpaRepository { @Override List findAll(); + + @Query("SELECT a.name FROM Asset a WHERE a.ticker = :ticker") + String findNameById(String ticker); } diff --git a/src/main/java/com/cleanengine/coin/order/application/AssetService.java b/src/main/java/com/cleanengine/coin/order/application/AssetService.java index e013b9dc..2e6c856b 100644 --- a/src/main/java/com/cleanengine/coin/order/application/AssetService.java +++ b/src/main/java/com/cleanengine/coin/order/application/AssetService.java @@ -5,18 +5,35 @@ import com.cleanengine.coin.order.domain.Asset; import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetCacheRepository; import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.trade.entity.Trade; +import com.cleanengine.coin.trade.repository.TradeRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.validation.FieldError; import java.util.List; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor public class AssetService { private final AssetRepository assetRepository; private final AssetCacheRepository assetCacheRepository; + private final TradeRepository tradeRepository; + + private final ConcurrentHashMap currentPriceCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap assetCache = new ConcurrentHashMap<>(); + + public void setAssetCache(List assets) { + assets.forEach(a -> assetCache.putIfAbsent(a.getTicker(), a)); + } + + public String getAssetName(String ticker){ + Asset asset = assetCache.get(ticker); + + return asset == null ? assetRepository.findNameById(ticker) : asset.getName(); + } public AssetInfo getAssetInfo(String ticker){ Optional assetOpt = getAsset(ticker); @@ -33,10 +50,6 @@ public List getAllAssetInfos(){ return assetRepository.findAll().stream().map(AssetInfo::from).toList(); } - public List getAllAssetTickers(){ - return assetRepository.findAll().stream().map(Asset::getTicker).toList(); - } - public boolean isAssetExist(String ticker){ if(assetCacheRepository.isAssetExists(ticker)) return true; @@ -49,4 +62,16 @@ public boolean isAssetExist(String ticker){ protected Optional getAsset(String ticker){ return assetRepository.findById(ticker); } -} + + public Double getCurrentPrice(String ticker) { + return currentPriceCache.computeIfAbsent(ticker, t -> { + Trade recentTrade = tradeRepository.findFirstByTickerOrderByTradeTimeDesc(t); + return recentTrade == null ? null : recentTrade.getPrice(); + }); + } + + public void updateCurrentPrice(String ticker, double price) { + currentPriceCache.put(ticker, price); + } + +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/order/application/event/SpringTradeExecutedUpdateAssetCurrentPriceHandler.java b/src/main/java/com/cleanengine/coin/order/application/event/SpringTradeExecutedUpdateAssetCurrentPriceHandler.java new file mode 100644 index 00000000..0639ee8e --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/application/event/SpringTradeExecutedUpdateAssetCurrentPriceHandler.java @@ -0,0 +1,22 @@ +package com.cleanengine.coin.order.application.event; + +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.trade.application.TradeExecutedEvent; +import com.cleanengine.coin.trade.entity.Trade; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class SpringTradeExecutedUpdateAssetCurrentPriceHandler { + + private final AssetService assetService; + + @TransactionalEventListener + public void onTradeExecutedEvent(TradeExecutedEvent tradeExecutedEvent) { + Trade trade = tradeExecutedEvent.getTrade(); + assetService.updateCurrentPrice(trade.getTicker(), trade.getPrice()); + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java b/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java index 30bc3de2..25c381cc 100644 --- a/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java +++ b/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java @@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.awt.print.Pageable; import java.time.LocalDateTime; import java.util.List; @@ -24,4 +23,6 @@ public interface TradeRepository extends JpaRepository { List findBySellUserIdAndTicker(Integer sellUserId, String ticker); List findTop10ByTickerOrderByTradeTimeDesc(String ticker); List findByTickerAndTradeTimeGreaterThanEqualOrderByTradeTimeDesc(String ticker, LocalDateTime lastTime); + Trade findFirstByTickerOrderByTradeTimeDesc(String ticker); + } diff --git a/src/main/java/com/cleanengine/coin/user/info/application/UserService.java b/src/main/java/com/cleanengine/coin/user/info/application/UserService.java index 625ce8a0..f67a0a41 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/UserService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/UserService.java @@ -1,20 +1,65 @@ package com.cleanengine.coin.user.info.application; +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.domain.OAuth; +import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.OAuthRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import com.cleanengine.coin.user.info.presentation.UserInfoDTO; -import com.cleanengine.coin.user.info.infra.UserRepository; +import com.cleanengine.coin.user.info.presentation.UserWalletDTO; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@RequiredArgsConstructor @Service public class UserService { - private final UserRepository userRepository; + private final AccountRepository accountRepository; + private final WalletRepository walletRepository; + private final OAuthRepository oAuthRepository; + private final AssetService assetService; + + @Transactional(readOnly = true) + public UserInfoDTO retrieveUserInfoByUserId(Integer userId) { + Account account = accountRepository.findByUserId(userId) + .orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다. userId: " + userId)); + OAuth oauth = oAuthRepository.findByUserId(userId) + .orElseThrow(() -> new IllegalStateException("OAuth 정보를 찾을 수 없습니다. userId: " + userId)); + + // TODO : 모든 종목에 대해 없는 지갑은 생성... 근데 어디서? + List wallets = walletRepository.findByAccountId(account.getId()); - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; + // 총 자산 계산 (현금 + (각 코인 보유량 * 현재가)) + double totalWalletValue = wallets.stream() + .mapToDouble(wallet -> + wallet.getSize() * assetService.getCurrentPrice(wallet.getTicker())) + .sum(); + double totalCash = account.getCash() + totalWalletValue; + + List userWalletDTOs = convertToDTO(wallets); + return UserInfoDTO.of(userId, oauth.getEmail(), oauth.getNickname(), oauth.getProvider(), account.getCash(), userWalletDTOs, totalCash); } - public UserInfoDTO retrieveUserInfoByUserId(Integer userId) { - return userRepository.retrieveUserInfoByUserId(userId); + private List convertToDTO(List wallets) { + return wallets.stream() + .map(w -> { + Double currentPrice = assetService.getCurrentPrice(w.getTicker()); + Double roi = currentPrice == null ? null : (currentPrice / w.getBuyPrice() - 1) * 100; + + return UserWalletDTO.of(w.getTicker(), + assetService.getAssetName(w.getTicker()), + w.getAccountId(), + w.getSize(), + w.getBuyPrice(), + roi, + currentPrice); + }) + .toList(); } } diff --git a/src/main/java/com/cleanengine/coin/user/info/infra/OAuthRepository.java b/src/main/java/com/cleanengine/coin/user/info/infra/OAuthRepository.java index 6c0955de..a858d37e 100644 --- a/src/main/java/com/cleanengine/coin/user/info/infra/OAuthRepository.java +++ b/src/main/java/com/cleanengine/coin/user/info/infra/OAuthRepository.java @@ -3,8 +3,12 @@ import com.cleanengine.coin.user.domain.OAuth; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface OAuthRepository extends JpaRepository { OAuth findByProviderAndProviderUserId(String provider, String providerUserId); + Optional findByUserId(Integer userId); + } diff --git a/src/main/java/com/cleanengine/coin/user/info/infra/UserRepository.java b/src/main/java/com/cleanengine/coin/user/info/infra/UserRepository.java index e7d947c8..cd3fd6b3 100644 --- a/src/main/java/com/cleanengine/coin/user/info/infra/UserRepository.java +++ b/src/main/java/com/cleanengine/coin/user/info/infra/UserRepository.java @@ -2,7 +2,6 @@ import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.login.infra.UserOAuthDetails; -import com.cleanengine.coin.user.info.presentation.UserInfoDTO; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -24,20 +23,4 @@ public interface UserRepository extends JpaRepository { """) UserOAuthDetails findUserByOAuthProviderAndProviderId(@Param("provider") String provider, @Param("providerUserId") String providerUserId); - - @Query(""" - SELECT new com.cleanengine.coin.user.info.presentation.UserInfoDTO( - u.id, - o.email, - o.nickname, - o.provider, - a.cash, - null - ) - FROM User u - JOIN OAuth o ON u.id = o.userId - LEFT JOIN Account a ON a.userId = u.id - WHERE u.id = :userId - """) - UserInfoDTO retrieveUserInfoByUserId(Integer userId); } diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java index 0ad1bbf5..171378a4 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java @@ -3,46 +3,33 @@ import com.cleanengine.coin.common.response.ApiResponse; import com.cleanengine.coin.common.response.ErrorResponse; import com.cleanengine.coin.common.response.ErrorStatus; -import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; -import com.cleanengine.coin.user.info.application.AccountService; -import com.cleanengine.coin.user.info.application.WalletService; import com.cleanengine.coin.user.info.application.UserService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - +@RequiredArgsConstructor @RestController public class UserController { private final UserService userService; - private final AccountService accountService; - private final WalletService walletService; - - public UserController(UserService userService, AccountService accountService, WalletService walletService) { - this.userService = userService; - this.accountService = accountService; - this.walletService = walletService; - } + @Operation(summary = "쿠키의 유저ID를 통해 유저 정보와 보유 자산을 불러옵니다.") @GetMapping("/api/userinfo") public ApiResponse retrieveUserInfo() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) { Integer userId = oAuth2User.getUserId(); UserInfoDTO userInfoDTO = userService.retrieveUserInfoByUserId(userId); + if (userInfoDTO == null) { return ApiResponse.fail(ErrorResponse.of(ErrorStatus.UNAUTHORIZED_RESOURCE)); } - Account account = accountService.retrieveAccountByUserId(userId); - List wallets = walletService.findByAccountId(account.getId()); - userInfoDTO.setWallets(wallets); return ApiResponse.success(userInfoDTO, HttpStatus.OK); } diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java index ca91c6ad..5a732caf 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java @@ -1,41 +1,52 @@ package com.cleanengine.coin.user.info.presentation; -import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.application.PlainDoubleSerializer; import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import lombok.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.util.List; @Getter -@Setter @NoArgsConstructor public class UserInfoDTO { + @Schema(description = "User ID", example = "3") private Integer userId; + @Schema(description = "이메일", example = "a@a.com") private String email; + @Schema(description = "닉네임", example = "버황") private String nickname; + @Schema(description = "oauth 제공자", example = "kakao") private String provider; + @Schema(description = "예수금", example = "50000000") @JsonSerialize(using = PlainDoubleSerializer.class) private Double cash; - private List wallets; + @Schema(description = "총 자산", example = "500000000") + @JsonSerialize(using = PlainDoubleSerializer.class) + private Double totalAssetAmount; // 총 자산(cash + wallets 현재가 * 수량) + + @Schema(description = "보유 지갑", example = "[{ticker: BTC, size: 10000, buyPrice: 10000000, roi: 0.001}]") + private List wallets; - private UserInfoDTO(Integer userId, String email, String nickname, String provider, Double cash, List wallets) { + private UserInfoDTO(int userId, String email, String nickname, String provider, double cash, List wallets, double totalAssetAmount) { this.userId = userId; this.email = email; this.nickname = nickname; this.provider = provider; this.cash = cash; this.wallets = wallets; + this.totalAssetAmount = totalAssetAmount; } - public static UserInfoDTO of(Integer userId, String email, String nickname, String provider, Double cash, List wallets) { - return new UserInfoDTO(userId, email, nickname, provider, cash, wallets); + public static UserInfoDTO of(int userId, String email, String nickname, String provider, double cash, List wallets, double totalCash) { + return new UserInfoDTO(userId, email, nickname, provider, cash, wallets, totalCash); } } diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserWalletDTO.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserWalletDTO.java new file mode 100644 index 00000000..db9be648 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserWalletDTO.java @@ -0,0 +1,54 @@ +package com.cleanengine.coin.user.info.presentation; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class UserWalletDTO { + + @Schema(description = "종목 티커", example = "BTC") + private final String ticker; + + @Schema(description = "종목명", example = "비트코인") + private final String name; + + @Schema(description = "계좌 ID", example = "3") + private final Integer accountId; + + @Schema(description = "보유수량", example = "2.5") + private final Double size; + + @Schema(description = "1주 평균 매수 금액", example = "15000") + private final Double buyPrice; // 매수 평단 + + @Schema(description = "수익률", example = "10") + private final Double roi; // 수익률 + + @Schema(description = "현재가(최근 체결가)", example = "16500") + private final Double currentPrice; // 현재가(최근 체결가) + + @Builder + public UserWalletDTO(String ticker, String name, Integer accountId, Double size, Double buyPrice, Double roi, Double currentPrice) { + this.ticker = ticker; + this.name = name; + this.accountId = accountId; + this.size = size; + this.buyPrice = buyPrice; + this.roi = roi; + this.currentPrice = currentPrice; + } + + public static UserWalletDTO of(String ticker, String name, Integer accountId, Double size, Double buyPrice, Double roi, Double currentPrice) { + return UserWalletDTO.builder() + .ticker(ticker) + .name(name) + .accountId(accountId) + .size(size) + .buyPrice(buyPrice) + .roi(roi) + .currentPrice(currentPrice) + .build(); + } + +} diff --git a/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java index dcf80af7..0917c64b 100644 --- a/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java +++ b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java @@ -87,7 +87,7 @@ public void testRetrieveUserInfoSuccess() throws Exception { customOAuth2User, null, authorities ); - UserInfoDTO userInfoDTO = UserInfoDTO.of(userId, email, nickname, provider, cash, null); + UserInfoDTO userInfoDTO = UserInfoDTO.of(userId, email, nickname, provider, cash, null, 0.0); when(userService.retrieveUserInfoByUserId(userId)).thenReturn(userInfoDTO); Account account = Account.of(userId, cash); @@ -98,10 +98,6 @@ public void testRetrieveUserInfoSuccess() throws Exception { .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess", is(true))) .andExpect(MockMvcResultMatchers.jsonPath("$.data.cash", is((int) cash))); - - verify(userService, times(1)).retrieveUserInfoByUserId(userId); - verify(accountService, times(1)).retrieveAccountByUserId(userId); - verify(walletService, times(1)).findByAccountId(account.getId()); } @Test