diff --git a/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java new file mode 100644 index 0000000..b686db9 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/controller/ExperimentController.java @@ -0,0 +1,50 @@ +package com.fund.stockProject.experiment.controller; + +import com.fund.stockProject.experiment.dto.ExperimentReportResponse; +import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusDetailResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; +import com.fund.stockProject.experiment.service.ExperimentService; +import com.fund.stockProject.security.principle.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/experiment") +public class ExperimentController { + + private final ExperimentService experimentService; + + @GetMapping("/status") + @Operation(summary = "실험(모의 매수) 현황 API", description = "실험(모의 매수) 현황 조회") + public ResponseEntity> getExperimentStatus(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok().body(experimentService.getExperimentStatus(customUserDetails)); + } + + @GetMapping("/status/{experimentId}/detail") + @Operation(summary = "실험(모의 매수) 현황 상세 보기 API", description = "실험(모의 매수) 현황 상세 보기") + public ResponseEntity> getExperimentStatusDetail(@PathVariable("experimentId") Integer experimentId) { + return ResponseEntity.ok().body(experimentService.getExperimentStatusDetail(experimentId)); + } + + @PostMapping("/{stockId}/buy/{country}") + @Operation(summary = "실험(모의 매수) 종목 매수 API", description = "실험(모의 매수) 종목 매수") + public ResponseEntity> buyExperiment(@AuthenticationPrincipal CustomUserDetails customUserDetails, final @PathVariable("stockId") Integer stockId, final @PathVariable("country") String country) { + return ResponseEntity.ok().body(experimentService.buyExperiment(customUserDetails, stockId, country)); + } + + @GetMapping("/report") + @Operation(summary = "실험 결과 API", description = "실험 결과 조회") + public ResponseEntity> getReport(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok().body(experimentService.getReport(customUserDetails)); + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java b/src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java new file mode 100644 index 0000000..322c723 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/domain/SCORERANGE.java @@ -0,0 +1,33 @@ +package com.fund.stockProject.experiment.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SCORERANGE { + RANGE_0_59("60점 미만"), + RANGE_60_69("60-69점"), + RANGE_70_79("70-79점"), + RANGE_80_89("80-89점"), + RANGE_90_100("90점 이상"); + + private final String range; + + SCORERANGE(String range) { + this.range = range; + } + + @JsonValue + public String getRange() { + return range; + } + + @JsonCreator + public static SCORERANGE fromRange(String range) { + for (SCORERANGE scorerange : SCORERANGE.values()) { + if (scorerange.range.equals(range)) { + return scorerange; + } + } + throw new IllegalArgumentException("Unknown exchange range: " + range); + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentInfoResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentInfoResponse.java new file mode 100644 index 0000000..bf34cf4 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentInfoResponse.java @@ -0,0 +1,25 @@ +package com.fund.stockProject.experiment.dto; + +import com.fund.stockProject.stock.domain.COUNTRY; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentInfoResponse { + + private Integer experimentId; + + private String symbolName; + + private LocalDateTime buyAt; + + private Integer buyPrice; + + private Double roi; + + private String status; + + private COUNTRY country; +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java new file mode 100644 index 0000000..ea61d0b --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentReportResponse.java @@ -0,0 +1,20 @@ +package com.fund.stockProject.experiment.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentReportResponse { + // 1번 결과 데이터 + private long weeklyExperimentCount; // 이번주 진행한 실험 횟수 + private List reportStatisticDtos; // 점수 구간별 평균 수익률 + + // 2번 결과 데이터 + private long totalUserExperiments; // 유저가 진행한 전체 실험 횟수 + private long successUserExperiments; // 유저 실험 중 수익에 성공한 실험 횟수 + private long sameGradeUserRate; // 동일 등급의 전체 유저 비율 + + private List reportPatternDtos; // 인간지표 점수 별 투자 유형 패턴 데이터 +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java new file mode 100644 index 0000000..304c057 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentSimpleResponse.java @@ -0,0 +1,13 @@ +package com.fund.stockProject.experiment.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentSimpleResponse { + private boolean success; + private String message; + private Double price; +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java new file mode 100644 index 0000000..a418bce --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusDetailResponse.java @@ -0,0 +1,31 @@ +package com.fund.stockProject.experiment.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentStatusDetailResponse { + private String symbolName; // 종목명 + private double roi; // 최종 수익률 + private String status; // 거래 상태 + private List tradeInfos; // 거래 내역 + + @Getter + public static class TradeInfo { + private double price; // 가격 + private int score; // 점수 + private LocalDateTime tradeAt; // 거래일 + private double roi; // 수익률 + + @Builder + public TradeInfo(Double price, Integer score, LocalDateTime tradeAt, double roi) { + this.price = price; + this.score = score; + this.tradeAt = tradeAt; + this.roi = roi; + } + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java new file mode 100644 index 0000000..1712a80 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ExperimentStatusResponse.java @@ -0,0 +1,22 @@ +package com.fund.stockProject.experiment.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExperimentStatusResponse { + + private List progressExperiments; // 진행중인 실험 데이터 + + private List completeExperiments; // 완료된 실험 데이터 + + private double avgRoi; // 평균수익률 + + private int totalTradeCount; // 총 실험 수 + + private int progressTradeCount; // 진행중인 실험 수 + + private double successRate; // 성공률 +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java b/src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java new file mode 100644 index 0000000..e0b4fa8 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ReportPatternDto.java @@ -0,0 +1,15 @@ +package com.fund.stockProject.experiment.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class ReportPatternDto { + private double roi; // 수익률 + private int score; // 인간지표 점수 + private LocalDateTime buyAt; // 매수날짜 +} diff --git a/src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java b/src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java new file mode 100644 index 0000000..f18c31a --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/dto/ReportStatisticDto.java @@ -0,0 +1,12 @@ +package com.fund.stockProject.experiment.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReportStatisticDto { + private String scoreRange; + private double totalAvgRoi; + private double userAvgRoi; +} diff --git a/src/main/java/com/fund/stockProject/experiment/entity/Experiment.java b/src/main/java/com/fund/stockProject/experiment/entity/Experiment.java new file mode 100644 index 0000000..2c84df9 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/entity/Experiment.java @@ -0,0 +1,66 @@ +package com.fund.stockProject.experiment.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fund.stockProject.user.entity.User; +import com.fund.stockProject.stock.entity.Stock; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Experiment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stock_id", nullable = false) + private Stock stock; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @JsonIgnore + private LocalDateTime buyAt; + + @JsonIgnore + private LocalDateTime sellAt; + + @Column(nullable = false) + private Double buyPrice; + + @Column + private Double sellPrice; + + @Column(nullable = false) + private Double roi; + + @Column(nullable = false) + private String status; + + @Column(nullable = false) + private int score; + + public void updateExperiment(Double sellPrice, String status, LocalDateTime sellAt, Double roi) { + this.sellPrice = sellPrice; + this.status = status; + this.sellAt = sellAt; + this.roi = roi; + } +} diff --git a/src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java new file mode 100644 index 0000000..97687ff --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/entity/ExperimentTradeItem.java @@ -0,0 +1,44 @@ +package com.fund.stockProject.experiment.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExperimentTradeItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "experiment_id", nullable = false) + private Experiment experiment; + + @JsonIgnore + private LocalDateTime tradeAt; // 거래일 + + @Column(nullable = false) + private Double price; // 가격 + + @Column(nullable = false) + private Integer score; // 점수 + + @Column(nullable = false) + private Double roi; // 수익률 +} diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java new file mode 100644 index 0000000..1a083e5 --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentRepository.java @@ -0,0 +1,99 @@ +package com.fund.stockProject.experiment.repository; + +import com.fund.stockProject.experiment.entity.Experiment; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExperimentRepository extends JpaRepository { + @Query("SELECT e FROM Experiment e JOIN e.user u WHERE u.email = :email ORDER BY e.roi ASC") + List findExperimentsByEmail(@Param("email") String email); // 이메일을 기준으로 해당 유저의 실험정보 조회 + + @Query("SELECT COUNT(e) FROM Experiment e WHERE e.status = :status") + int countExperimentsByStatus(@Param("status") String status); // 상태(진행/완료) 별 실험 개수 + + @Query("SELECT e FROM Experiment e JOIN e.user u WHERE u.email = :email and e.status = :status ORDER BY e.roi ASC") + List findExperimentsByEmailAndStatus(@Param("email") String email, @Param("status") String status); // 이메일과 완료된 실험을 기준으로 해당 유저의 실험정보 조회 + + @Query("SELECT e FROM Experiment e WHERE e.experimentId = :experimentId") + Optional findExperimentByExperimentId(@Param("experimentId") Integer experimentId); // 실험Id 값으로 실험 내용 조회 + + @Query("SELECT e FROM Experiment e WHERE e.stock.id = :stockId AND e.buyAt BETWEEN :startOfDay and :endOfDay") + Optional findExperimentByStockIdForToday(@Param("stockId") Integer stockId, @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); // 금일 진행중인 실험 상세정보 + + @Query("SELECT count(e) FROM Experiment e WHERE e.buyAt BETWEEN :startOfWeek and :endOfWeek") + int countExperimentsForWeek(@Param("stockId") String email, @Param("startOfWeek") LocalDateTime startOfWeek, @Param("endOfWeek") LocalDateTime endOfWeek); + + @Query("SELECT e FROM Experiment e JOIN e.user u WHERE u.email = :email and e.status = :status ORDER BY e.roi ASC") + List findAllExperimentsByEmailAndStatus(@Param("email") String email, @Param("status") String status); // 이메일과 완료된 실험을 기준으로 해당 유저의 실험정보 조회 + + @Query("SELECT e FROM Experiment e WHERE e.buyAt BETWEEN :start AND :end") + List findExperimentsAfterFiveDays(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + @Query("SELECT e FROM Experiment e WHERE e.buyAt > :start AND e.status = :status") + List findProgressExperiments(@Param("start") LocalDateTime start, @Param("status") String status); + + @Query("SELECT AVG(e.roi) FROM Experiment e WHERE e.score BETWEEN :start AND :end AND e.email = :email") + double findUserAvgRoi(@Param("start") int start, @Param("end") int end, @Param("email") String email); + + @Query("SELECT AVG(e.roi) FROM Experiment e WHERE e.score BETWEEN :start AND :end") + double findTotalAvgRoi(@Param("start") int start, @Param("end") int end); + + @Query("SELECT ROUND(IFNULL(profitable.cnt, 0) / total.cnt * 100, 1) AS ratio " + + "FROM ( " + + " SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e " + + " GROUP BY e.email " + + " ) AS total " + + "LEFT JOIN " + + " ( " + + " SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e. " + + " WHERE e.roi > 0 " + + " GROUP BY e.email " + + " ) AS profitable " + + "ON total.email = profitable.email " + + "WHERE total.email = :email ") + double findSuccessExperimentRate(@Param("email") String email); + + @Query("SELECT count(*) " + + "FROM " + + "( " + + " SELECT ROUND(IFNULL(profitable.cnt, 0) / total.cnt * 100, 1) AS ratio " + + " FROM " + + " ( SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e " + + " GROUP BY e.email " + + " ) AS total " + + " LEFT JOIN " + + " ( " + + " SELECT e.email, COUNT(e.email) AS cnt " + + " FROM Experiment e " + + " WHERE e.roi > 0 " + + " GROUP BY e.email " + + " ) AS profitable " + + " ON total.email = profitable.email " + + ") a " + + "WHERE a.ratio BETWEEN :startRange AND :endRange") + int countSameGradeUser(@Param("startRange") int startRange, @Param("endRange") int endRange); + + @Query("SELECT " + + " sub.buy_date, " + + " ROUND(AVG(sub.roi), 1) AS avg_roi, " + + " ROUND(AVG(sub.score), 0) AS avg_score " + + "FROM ( " + + " SELECT " + + " DATE(e.buy_at) AS buy_date, " + + " e.roi, " + + " e.score " + + " FROM Experiment e " + + ") AS sub " + + "GROUP BY sub.buy_date " + + "ORDER BY sub.buy_date ") + List findExperimentGroupByBuyAt(); +} diff --git a/src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java new file mode 100644 index 0000000..7f31cce --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/repository/ExperimentTradeItemRepository.java @@ -0,0 +1,18 @@ +package com.fund.stockProject.experiment.repository; + +import com.fund.stockProject.experiment.entity.ExperimentTradeItem; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExperimentTradeItemRepository extends JpaRepository { + @Query("SELECT e FROM ExperimentTradeItem e WHERE e.experimentId = :experimentId") + List findExperimentTradeItemsByExperimentId(@Param("experimentId") Integer experimentId); + + @Query("SELECT e FROM ExperimentTradeItem e WHERE e.experimentId = :experimentId AND e.tradeAt BETWEEN :start and :end ORDER BY e.tradeAt") + List findExperimentTradeItemsForToday(@Param("experimentId") Integer experimentId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); +} diff --git a/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java new file mode 100644 index 0000000..f6c95bc --- /dev/null +++ b/src/main/java/com/fund/stockProject/experiment/service/ExperimentService.java @@ -0,0 +1,550 @@ +package com.fund.stockProject.experiment.service; + +import com.fund.stockProject.auth.entity.User; +import com.fund.stockProject.auth.repository.UserRepository; +import com.fund.stockProject.experiment.domain.SCORERANGE; +import com.fund.stockProject.experiment.dto.ExperimentReportResponse; +import com.fund.stockProject.experiment.dto.ExperimentSimpleResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusDetailResponse; +import com.fund.stockProject.experiment.dto.ExperimentStatusDetailResponse.TradeInfo; +import com.fund.stockProject.experiment.dto.ExperimentStatusResponse; +import com.fund.stockProject.experiment.dto.ExperimentInfoResponse; +import com.fund.stockProject.experiment.dto.ReportPatternDto; +import com.fund.stockProject.experiment.dto.ReportStatisticDto; +import com.fund.stockProject.experiment.entity.Experiment; +import com.fund.stockProject.experiment.entity.ExperimentTradeItem; +import com.fund.stockProject.experiment.repository.ExperimentRepository; +import com.fund.stockProject.experiment.repository.ExperimentTradeItemRepository; +import com.fund.stockProject.score.entity.Score; +import com.fund.stockProject.score.repository.ScoreRepository; +import com.fund.stockProject.security.principle.CustomUserDetails; +import com.fund.stockProject.stock.domain.COUNTRY; +import com.fund.stockProject.stock.domain.EXCHANGENUM; +import com.fund.stockProject.stock.dto.response.StockInfoResponse; +import com.fund.stockProject.stock.entity.Stock; +import com.fund.stockProject.stock.repository.StockQueryRepository; +import com.fund.stockProject.stock.repository.StockRepository; +import com.fund.stockProject.stock.service.SecurityService; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class ExperimentService { + + private final ExperimentRepository experimentRepository; + private final StockRepository stockRepository; + private final UserRepository userRepository; + private final ScoreRepository scoreRepository; + private final SecurityService securityService; + private final StockQueryRepository stockQueryRepository; + private final ExperimentTradeItemRepository experimentTradeItemRepository; + + /* + * 실험실 - 매수 현황 + * */ + public Mono getExperimentStatus(final CustomUserDetails customUserDetails) { + // 로그인한 유저 관련 모의 투자 정보 조회 + final List experimentsByUserId = experimentRepository.findExperimentsByEmail( + customUserDetails.getEmail()); + + if (experimentsByUserId.isEmpty()) { + return Mono.empty(); + } + + // 진행중인 모의 투자 종목 + final List progressExperimentsInfo = new ArrayList<>(); + // 완료된 모의 투자 종목 + final List completeExperimentsInfo = new ArrayList<>(); + + // 로그인한 유저 관련 모의 투자 정보 조회 진행/완료 리스트에 저장 + for (final Experiment experiment : experimentsByUserId) { + final Optional bySymbol = stockRepository.findBySymbol(experiment.getStock().getSymbol()); + + if (bySymbol.isEmpty()) { + throw new NoSuchElementException("No Stock Found"); + } + + final Stock stock = bySymbol.get(); + + StockInfoResponse stockInfoKorea = securityService.getSecurityStockInfoKorea( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ).block(); + + if (experiment.getStatus().equals("PROGRESS")) { + progressExperimentsInfo.add(ExperimentInfoResponse.builder() + .experimentId(experiment.getId()) + .roi(experiment.getRoi()) + .buyAt(experiment.getBuyAt()) + .symbolName(stock.getSymbolName()) + .status(experiment.getStatus()) + .country(stockInfoKorea.getCountry()) + .build()); + + continue; + } + + completeExperimentsInfo.add(ExperimentInfoResponse.builder() + .experimentId(experiment.getId()) + .roi(experiment.getRoi()) + .buyAt(experiment.getBuyAt()) + .symbolName(stock.getSymbolName()) + .status(experiment.getStatus()) + .country(stockInfoKorea.getCountry()) + .build()); + } + + final int countByStatusCompleted = experimentRepository.countExperimentsByStatus("PROGRESS"); // 진행중인 실험 수 + + final double averageRoi = experimentsByUserId.stream() + .mapToDouble(Experiment::getRoi) // 각 ROI 값을 double로 추출 + .average() // OptionalDouble 반환 + .orElse(0.0); + + final long count = experimentsByUserId.stream().filter(p -> p.getSellPrice() - p.getBuyPrice() > 0).count(); // 모의투자에 성공한 종목 개수 + double successRate = ((double) count / experimentsByUserId.size()) * 100; + + final ExperimentStatusResponse experimentStatusResponse = ExperimentStatusResponse.builder() + .progressExperiments(progressExperimentsInfo) // 진행중인 실험 정보 + .completeExperiments(completeExperimentsInfo) // 완료된 실험 정보 + .avgRoi(averageRoi) // 평균 수익률 + .totalTradeCount(experimentsByUserId.size()) // 총 실험 수 (전체 모의투자 개수) + .progressTradeCount(countByStatusCompleted) // 진행중인 실험 수 (진행중인 모의투자 개수) + .successRate(successRate) // 성공률 + .build(); + + return Mono.just(experimentStatusResponse); + } + + /* + * 실험실 - 종목 매수 현황 자세히 보기 + * */ + public Mono getExperimentStatusDetail(final Integer experimentId) { + // 자세히 보기 선택한 실험 데이터 조회 + final Experiment experiment = experimentRepository.findExperimentByExperimentId(experimentId).get(); + // 실험 데이터에 해당하는 자동 모의 실험 내역 조회 + final List experimentTradeItems = experimentTradeItemRepository.findExperimentTradeItemsByExperimentId(experimentId); + // 가장 최근 수익률 조회 + final ExperimentTradeItem recentExperimentTradeItem = experimentTradeItems.get(experimentTradeItems.size() - 1); + + // 최종 수익률 계산: ((판매가 - 매수가) / 매수가) * 100 + double roi = ((recentExperimentTradeItem.getPrice() - experiment.getBuyPrice()) / experiment.getBuyPrice()) * 100; + + final List tradeInfos = new ArrayList<>(); + + for (final ExperimentTradeItem experimentTradeItem : experimentTradeItems) { + tradeInfos.add(TradeInfo.builder() + .price(experimentTradeItem.getPrice()) + .tradeAt(experimentTradeItem.getTradeAt()) + .score(experimentTradeItem.getScore()) + .roi(experimentTradeItem.getRoi()) + .build() + ); + } + + return Mono.just(ExperimentStatusDetailResponse.builder() + .tradeInfos(tradeInfos) + .roi(roi) + .status(experiment.getStatus()) + .symbolName(experiment.getStock().getSymbolName()) + .build()); + } + + private COUNTRY getCountryFromExchangeNum(EXCHANGENUM exchangenum) { + return List.of(EXCHANGENUM.KOSPI, EXCHANGENUM.KOSDAQ, EXCHANGENUM.KOREAN_ETF).contains(exchangenum) ? COUNTRY.KOREA : COUNTRY.OVERSEA; + } + + public Mono buyExperiment(final CustomUserDetails customUserDetails, final Integer stockId, String country) { + final Optional stockById = stockRepository.findStockById(stockId); + final Optional userById = userRepository.findByEmail(customUserDetails.getEmail()); + + final Stock stock = stockById.get(); + final User user = userById.get(); + + final LocalDateTime now = LocalDateTime.now(); // 현재 날짜와 시간 + final LocalTime current = now.toLocalTime(); // 현재 시간 + final LocalDateTime startOfToday = now.toLocalDate().atStartOfDay(); // 오늘 시작 시간 + final LocalDateTime endOfToday = now.toLocalDate().atTime(LocalTime.MAX); // 오늘 마지막 시간 + final DayOfWeek dayOfWeek = now.getDayOfWeek(); // 요일 + Double price = 0.0d; + + final Score findByStockIdAndDate = scoreRepository.findByStockIdAndDate(stockId, LocalDate.now()).get(); + int score = 9999; + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea2( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ); + + if (securityStockInfoKorea.blockOptional().isEmpty()) { + return Mono.empty(); + } + + final StockInfoResponse stockInfoResponse = securityStockInfoKorea.block(); + + if (stockInfoResponse.getCountry().equals(COUNTRY.KOREA)) { + score = findByStockIdAndDate.getScoreKorea(); + final Optional experimentByStockIdAndBuyAt = experimentRepository.findExperimentByStockIdForToday(stockId, startOfToday, endOfToday); + + // 하루에 같은 종목 중복 구매 불가 처리 + if (experimentByStockIdAndBuyAt.isPresent()) { + return Mono.just(ExperimentSimpleResponse.builder() + .message("같은 종목 중복 구매") + .success(false) + .price(0.0d) + .build() + ); + } + + LocalTime koreaEndTime = LocalTime.of(17, 0); + + // 종가 결정 + if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { + price = stockInfoResponse.getYesterdayPrice(); + } else { + price = current.isBefore(koreaEndTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getPrice(); + } + } else { + // 해외 주식 로직 + score = findByStockIdAndDate.getScoreOversea(); + + // 해당 구간에 이미 매수한 경우 중복 매수 방지 + Optional existingItem = experimentRepository.findExperimentByStockIdForToday(stockId, startOfToday, endOfToday); + + if (existingItem.isPresent()) { + return Mono.just(ExperimentSimpleResponse.builder() + .message("같은 종목 중복 구매") + .success(false) + .price(0.0d) + .build() + ); + } + + LocalTime overseasEndTime = LocalTime.of(6, 0); + // 종가 결정 + price = current.isBefore(overseasEndTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getPrice(); + + // 종가 결정 + if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { + price = stockInfoResponse.getYesterdayPrice(); + } else { + price = current.isBefore(overseasEndTime) ? stockInfoResponse.getYesterdayPrice() : stockInfoResponse.getPrice(); + } + } + + List scores = stock.getScores(); + + // 저장할 실험 데이터 생성 + final Experiment experiment = Experiment.builder() + .user(user) + .stock(stock) + .status("PROGRESS") + .buyAt(now) + .buyPrice(price) + .score(score) + .build(); + + // 모의 매수한 실험 데이터 저장 + experimentRepository.save(experiment); + + final ExperimentTradeItem experimentTradeItem = ExperimentTradeItem.builder() + .experiment(experiment) + .price(price) + .roi(0.0d) + .score(experiment.getScore()) + .tradeAt(now) + .build(); + + experimentTradeItemRepository.save(experimentTradeItem); + + return Mono.just(ExperimentSimpleResponse.builder() + .message("모의 매수 성공") + .success(true) + .price(price) + .build() + ); + } + + // 매수결과 조회 + public Mono getReport(CustomUserDetails customUserDetails) { + final String email = customUserDetails.getEmail(); + + // 인간지표 점수대별 평균 수익률 + final List reportStatisticDtos = new ArrayList<>(); + + // 1. 60점 이하 평균 수익률 + final double totalAvgRoi_0_59 = experimentRepository.findTotalAvgRoi(0, 59); + final double userAvgRoi_0_59 = experimentRepository.findUserAvgRoi(0, 59, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_0_59) + .userAvgRoi(userAvgRoi_0_59) + .scoreRange(SCORERANGE.RANGE_0_59.getRange()) + .build()); + + // 2. 60~69점 평균 수익률 + final double totalAvgRoi_60_69 = experimentRepository.findTotalAvgRoi(60, 69); + final double userAvgRoi_60_69 = experimentRepository.findUserAvgRoi(60, 69, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_60_69) + .userAvgRoi(userAvgRoi_60_69) + .scoreRange(SCORERANGE.RANGE_60_69.getRange()) + .build()); + + // 3. 70~79점 평균 수익률 + final double totalAvgRoi_70_79 = experimentRepository.findTotalAvgRoi(70, 79); + final double userAvgRoi_70_79 = experimentRepository.findUserAvgRoi(70, 79, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_70_79) + .userAvgRoi(userAvgRoi_70_79) + .scoreRange(SCORERANGE.RANGE_70_79.getRange()) + .build()); + + // 3. 80~89점 평균 수익률 + final double totalAvgRoi_80_89 = experimentRepository.findTotalAvgRoi(80, 89); + final double userAvgRoi_80_89 = experimentRepository.findUserAvgRoi(80, 89, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_80_89) + .userAvgRoi(userAvgRoi_80_89) + .scoreRange(SCORERANGE.RANGE_80_89.getRange()) + .build()); + + // 5. 90이상 평균 수익률 + final double totalAvgRoi_90_100 = experimentRepository.findTotalAvgRoi(90, 100); + final double userAvgRoi_90_100 = experimentRepository.findUserAvgRoi(90, 100, email); + + reportStatisticDtos.add(ReportStatisticDto.builder() + .totalAvgRoi(totalAvgRoi_90_100) + .userAvgRoi(userAvgRoi_90_100) + .scoreRange(SCORERANGE.RANGE_90_100.getRange()) + .build()); + + LocalDateTime now = LocalDateTime.now(); + + // 이번 주 월요일 00:00:00 + LocalDateTime startOfWeek = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .toLocalDate() + .atStartOfDay(); + + // 이번 주 금요일 23:59:59.999999999 + LocalDateTime endOfWeek = now.with(TemporalAdjusters.nextOrSame(DayOfWeek.FRIDAY)) + .toLocalDate() + .atTime(LocalTime.MAX); + + // 이번주 진행 실험 횟수 + final int weeklyExperimentCount = experimentRepository.countExperimentsForWeek(email, + startOfWeek, endOfWeek); + + // 특정 사용자가 진행한 총 실험 + final List experimentsByEmailAndStatus = experimentRepository.findExperimentsByEmailAndStatus( + email, "COMPLETED"); + + // 전체 진행한 실험 개수 + long totalExperimentCount = experimentsByEmailAndStatus.size(); + + // 성공한 수익률 데이터 + long successExperimentCount = experimentsByEmailAndStatus.stream() + .filter(experiment -> experiment.getRoi() > 0).count(); + + // 유저 성공률 데이터 조회 + final double successExperimentRate = experimentRepository.findSuccessExperimentRate(email); + int startRange = 0; + int endRange; + + if (0 < successExperimentRate && successExperimentRate <= 20) { + endRange = 20; + } else if (successExperimentRate <= 40) { + startRange = 21; + endRange = 40; + } else if (successExperimentRate <= 60) { + startRange = 41; + endRange = 60; + } else if (successExperimentRate <= 80) { + startRange = 61; + endRange = 80; + } else { + startRange = 81; + endRange = 100; + } + + // 동일 등급 전체 유저 비율 계산 + final int countSameGradeUser = experimentRepository.countSameGradeUser(startRange, endRange); + final long userCount = userRepository.count(); + long sameGradeUserRage = (countSameGradeUser * 100L / userCount); + + final List reportPatternDtos = new ArrayList<>(); + + // 인간지표 점수 별 투자 유형 패턴 데이터 + final List experimentGroupByBuyAt = experimentRepository.findExperimentGroupByBuyAt(); + + for (final Experiment experiment : experimentGroupByBuyAt) { + reportPatternDtos.add( + ReportPatternDto.builder() + .score(experiment.getScore()) + .buyAt(experiment.getBuyAt()) + .roi(experiment.getRoi()) + .build() + ); + } + + // 투자 패턴 그래프 데이터 + final ExperimentReportResponse experimentReportResponse = ExperimentReportResponse.builder() + .weeklyExperimentCount(weeklyExperimentCount) + .reportStatisticDtos(reportStatisticDtos) + .sameGradeUserRate(sameGradeUserRage) + .successUserExperiments(successExperimentCount) + .totalUserExperiments(totalExperimentCount) + .reportPatternDtos(reportPatternDtos) + .build(); + + return Mono.just(experimentReportResponse); + } + + // 영업일 기준 실험 진행한 기간이 5일째인 실험 데이터 조회 + @Transactional(readOnly = true) + public List findExperimentsAfter5BusinessDays() { + LocalDate fiveBusinessDaysAgo = calculatePreviousBusinessDate(LocalDate.now()); + LocalDateTime start = fiveBusinessDaysAgo.atStartOfDay(); + LocalDateTime end = fiveBusinessDaysAgo.atTime(LocalTime.MAX); + + final List ExperimentsAfter5BusinessDays = experimentRepository.findExperimentsAfterFiveDays(start, end); + + if (ExperimentsAfter5BusinessDays.isEmpty()) { + return new ArrayList<>(); + } + + return ExperimentsAfter5BusinessDays; + } + + // 5영업일 전 날짜 찾는 함수 + private LocalDate calculatePreviousBusinessDate(LocalDate fromDate) { + int daysCounted = 0; + LocalDate date = fromDate; + + while (daysCounted < 5) { + date = date.minusDays(1); + DayOfWeek day = date.getDayOfWeek(); + + // 주말 제외 + if (day != DayOfWeek.SATURDAY && day != DayOfWeek.SUNDAY) { + daysCounted++; + } + } + + return date; + } + + // 자동판매 - 실험 데이터 수정 + public void updateExperiment(Experiment experiment) { + try { + final Stock stock = experiment.getStock(); + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea + ( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ); + + if (securityStockInfoKorea.blockOptional().isPresent()) { + final Double price = securityStockInfoKorea.block().getPrice(); + final Double roi = ((experiment.getBuyPrice() - price) % experiment.getBuyPrice()) * 100; + + experiment.updateExperiment(price, "COMPLETE", LocalDateTime.now(), roi); + } + + } catch (Exception e) { + System.err.println("Failed to autoSell"); + } + } + + // 실험 진행이 5영업일이 지나지 않은 실험 데이터 조회 + public List findExperimentsPrevious5BusinessDays() { + final LocalDate now = LocalDate.now(); + LocalDate fiveBusinessDaysAgo = calculatePreviousBusinessDate(now); + LocalDateTime start = fiveBusinessDaysAgo.atTime(LocalTime.MAX); + + final List ExperimentsAfter5BusinessDays = experimentRepository.findProgressExperiments(start, "PROGRESS"); + + if (ExperimentsAfter5BusinessDays.isEmpty()) { + return new ArrayList<>(); + } + + return ExperimentsAfter5BusinessDays; + } + + public void saveExperiment(Experiment experiment) { + + } + + public void saveExperimentTradeItem(Experiment experiment) { + final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime startOfToday = now.toLocalDate().atStartOfDay(); // 오늘 시작 시간 + final LocalDateTime endOfToday = now.toLocalDate().atTime(LocalTime.MAX); // 오늘 마지막 시간 + + final List experimentTradeItemsByExperimentId = experimentTradeItemRepository.findExperimentTradeItemsForToday(experiment.getId(), startOfToday, endOfToday); + + if (experimentTradeItemsByExperimentId.isEmpty()) { + final Stock stock = experiment.getStock(); + + final Mono securityStockInfoKorea = securityService.getSecurityStockInfoKorea + ( + stock.getId(), + stock.getSymbolName(), + stock.getSecurityName(), + stock.getSymbol(), + stock.getExchangeNum(), + getCountryFromExchangeNum(stock.getExchangeNum()) + ); + + final StockInfoResponse stockInfoResponse = securityStockInfoKorea.block(); + + final Double price = stockInfoResponse.getPrice(); + double roi = ((price - experiment.getBuyPrice()) / experiment.getBuyPrice()) * 100; + final Score findByStockIdAndDate = scoreRepository.findByStockIdAndDate(experiment.getStock().getId(), LocalDate.now()).get(); + int score = 9999; + + if (stockInfoResponse.getCountry().equals(COUNTRY.KOREA)) { + score = findByStockIdAndDate.getScoreKorea(); + } else { + score = findByStockIdAndDate.getScoreOversea(); + } + + final ExperimentTradeItem experimentTradeItem = ExperimentTradeItem.builder() + .tradeAt(now) + .score(score) + .roi(roi) + .price(price) + .experiment(experiment) + .build(); + + experimentTradeItemRepository.save(experimentTradeItem); + } + } +} diff --git a/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java b/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java index d19d684..50d24a9 100644 --- a/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java +++ b/src/main/java/com/fund/stockProject/global/config/SecurityConfig.java @@ -5,14 +5,19 @@ import com.fund.stockProject.security.filter.JwtAuthenticationFilter; import com.fund.stockProject.security.util.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutFilter; @@ -68,9 +73,24 @@ public class SecurityConfig { "/stock/{id}/info/{country}", "/stock/category/{category}/{country}", "/stock/rankings/hot", - "/stock/summary/{symbol}/{country}" + "/stock/summary/{symbol}/{country}", + "/experiment/status", + "/experiment/{id}/buy/{country}", + "/experiment/status/{id}/detail", + "/experiment/report" }; + @Bean + public AsyncTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor(); + delegate.setCorePoolSize(4); + delegate.setMaxPoolSize(8); + delegate.setQueueCapacity(100); + delegate.initialize(); + // 작업 실행 시 SecurityContext를 복사/복원 + return new DelegatingSecurityContextAsyncTaskExecutor(delegate); + } + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); @@ -80,38 +100,27 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())); - - // 기본 설정 비활성화 - http - .csrf(AbstractHttpConfigurer::disable); // CSRF 보호 비활성화 (JWT 사용 시 필요 없음) - http - .formLogin(AbstractHttpConfigurer::disable); http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable); - - // 경로 권한 설정 http .authorizeHttpRequests((auth) -> auth - .requestMatchers(PUBLIC_API_PATHS).permitAll() // 이 경로들은 모두 인증 없이 접근 허용 - .requestMatchers(SWAGGER_API_PATHS).permitAll() // Swagger 관련 경로는 모두 인증 없이 접근 허용 - .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요 + .requestMatchers(PUBLIC_API_PATHS).permitAll() + .requestMatchers(SWAGGER_API_PATHS).permitAll() + .anyRequest().authenticated() ); - - // 세션 설정 + // 부분 stateful: 필요 시 세션 생성 & SecurityContext 자동 저장 http .sessionManagement((session) -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .sessionFixation().none()); // 세션 고정 방지 비활성화, JWT 기반 인증이므로 - - // JWT 검증 필터 등록 - Bean으로 주입받은 인스턴스 사용 + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .sessionFixation().migrateSession()) + .securityContext(sc -> sc.requireExplicitSave(false)); http .addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class); - - // 로그인 예외 시 실행 http.exceptionHandling(exception -> exception .authenticationEntryPoint(customAuthenticationEntryPoint) ); - return http.build(); } } diff --git a/src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java new file mode 100644 index 0000000..1577751 --- /dev/null +++ b/src/main/java/com/fund/stockProject/global/scheduler/AutoSellScheduler.java @@ -0,0 +1,54 @@ +package com.fund.stockProject.global.scheduler; + +import com.fund.stockProject.experiment.entity.Experiment; +import com.fund.stockProject.experiment.service.ExperimentService; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AutoSellScheduler { + + private ExperimentService experimentService; + + /** + * 모의투자 자동매매 스케줄러 + */ + @Scheduled(cron = "0 30 23 ? * MON-FRI", zone = "Asia/Seoul") + public void processAutoSell() { + LocalDate today = LocalDate.now(); + + // 모의 매수 5일차 종목 조회 + final List experimentsAfter5BusinessDays = experimentService.findExperimentsAfter5BusinessDays(); + + for (Experiment experiment : experimentsAfter5BusinessDays) { + try { + experimentService.updateExperiment(experiment); + } catch (Exception e) { + System.err.println("Error processing autoSell " + experiment.getId() + " - " + e.getMessage()); + } + } + + } + + @Scheduled(cron = "0 0 18 * * ?", zone = "Asia/Seoul") + public void processProgressExperiment() { + LocalDate today = LocalDate.now(); + + // 모의 매수 5일차 미만의 실험 진행중인 종목 조회 + final List experimentsPrevious5BusinessDays = experimentService.findExperimentsPrevious5BusinessDays(); + + // 영업일 기준 데이터 저장 + for (Experiment experiment : experimentsPrevious5BusinessDays) { + try { + experimentService.saveExperimentTradeItem(experiment); + } catch (Exception e) { + System.err.println("Error processing Progress Experiment " + experiment.getId() + " - " + e.getMessage()); + } + } + + } +} diff --git a/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java b/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java index 08a7caf..b97321e 100644 --- a/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java +++ b/src/main/java/com/fund/stockProject/global/scheduler/BatchScheduler.java @@ -9,12 +9,10 @@ import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; import org.springframework.batch.core.repository.JobRestartException; -import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component -@EnableScheduling @RequiredArgsConstructor public class BatchScheduler { diff --git a/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java index cf731ce..d776d93 100644 --- a/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/fund/stockProject/security/filter/JwtAuthenticationFilter.java @@ -17,6 +17,7 @@ import org.springframework.lang.NonNull; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -63,32 +64,28 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull FilterChain filterChain) throws ServletException, IOException { - try { - // 이미 인증된 경우는 그대로 다음 필터로 진행 - var currentAuth = SecurityContextHolder.getContext().getAuthentication(); - if (currentAuth != null && currentAuth.isAuthenticated() && !(currentAuth instanceof AnonymousAuthenticationToken)) { - filterChain.doFilter(request, response); - return; - } - - // 토큰 추출 - String accessToken = resolveToken(request); - - // 토큰이 있으면 검증 및 컨텍스트 설정 - if (accessToken != null) { - processJwtAuthentication(accessToken); + String accessToken = resolveToken(request); + Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication(); + + if (accessToken != null) { + try { + if (!isReusable(currentAuth, accessToken)) { + processJwtAuthentication(accessToken); // JWT 파싱/검증 + } + } catch (ExpiredJwtException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) { + log.warn("JWT 검증 실패: {}", e.getMessage()); + SecurityContextHolder.clearContext(); // 토큰 자체 문제일 때만 초기화 } + } - // 체인은 정확히 한 번만 호출 - filterChain.doFilter(request, response); + // 비즈니스 예외는 여기서 잡지 않고 그대로 상위로 전달 (double dispatch 방지) + filterChain.doFilter(request, response); + } - } catch (Exception e) { - // 예외 상황에서만 컨텍스트 정리 후 스프링에 위임 - log.error("JWT 인증 처리 중 오류", e); - SecurityContextHolder.clearContext(); - throw e; - } - // finally 블록 없음: getAsyncContext() 접근 금지 + 중복 체인 호출 방지 + @Override + protected boolean shouldNotFilterAsyncDispatch() { + // async 재디스패치에도 필터 실행하여 SecurityContext 재구성 + return false; } /** @@ -104,11 +101,13 @@ private void processJwtAuthentication(String accessToken) { String email = jwtUtil.getEmail(accessToken); String role = jwtUtil.getRole(accessToken); - // ROLE_ 접두어 부여 + // ROLE_ 접두어 중복 방지 및 중복 권한 제거 List authorities = Stream.of(role.split(",")) .map(String::trim) .filter(r -> !r.isEmpty()) - .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .distinct() + .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); User user = findUserByEmailOptimized(email); @@ -167,4 +166,16 @@ private User findUserByEmailOptimized(String email) { return null; } } + + private boolean isReusable(Authentication auth, String token) { + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) return false; + if (!(auth.getPrincipal() instanceof CustomUserDetails principal)) return false; + try { + if (!JWT_CATEGORY_ACCESS.equals(jwtUtil.getCategory(token))) return false; + String emailFromToken = jwtUtil.getEmail(token); + return principal.getEmail().equals(emailFromToken); + } catch (Exception e) { + return false; + } + } } diff --git a/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java b/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java index a70f78c..c2ec212 100644 --- a/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java +++ b/src/main/java/com/fund/stockProject/stock/dto/response/StockInfoResponse.java @@ -30,4 +30,6 @@ public class StockInfoResponse { private Double priceDiff; private Double priceDiffPerCent; + + private Double yesterdayPrice; } diff --git a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java index 7a92567..2a7848b 100644 --- a/src/main/java/com/fund/stockProject/stock/service/SecurityService.java +++ b/src/main/java/com/fund/stockProject/stock/service/SecurityService.java @@ -34,6 +34,67 @@ public class SecurityService { private final WebClient webClient; private final ObjectMapper objectMapper; + /** + * 국내, 해외 주식 정보 조회 + */ + public Mono getSecurityStockInfoKorea2(Integer id, String symbolName, String securityName, String symbol, EXCHANGENUM exchangenum, COUNTRY country) { + if (country == COUNTRY.KOREA) { + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/uapi/domestic-stock/v1/quotations/inquire-price-2") + .queryParam("fid_cond_mrkt_div_code", "J") + .queryParam("fid_input_iscd", symbol) + .build()) + .headers(httpHeaders -> { + HttpHeaders headers = securityHttpConfig.createSecurityHeaders(); // 항상 최신 헤더 가져오기 + headers.set("tr_id", "FHKST01010100"); // 추가 헤더 설정 + httpHeaders.addAll(headers); + }) + .retrieve() + .bodyToMono(String.class) + .flatMap(response -> parseFStockInfoKorea2(response, id, symbolName, securityName, symbol, exchangenum, country)); + } else if (country == COUNTRY.OVERSEA) { + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/uapi/overseas-price/v1/quotations/price") + .queryParam("AUTH", "") + .queryParam("EXCD", exchangenum.name()) + .queryParam("SYMB", symbol) + .build()) + .headers(httpHeaders -> { + HttpHeaders headers = securityHttpConfig.createSecurityHeaders(); // 항상 최신 헤더 가져오기 + headers.set("tr_id", "HHDFS00000300"); // 추가 헤더 설정 + httpHeaders.addAll(headers); + }) + .retrieve() + .bodyToMono(String.class) + .flatMap(response -> parseFStockInfoOversea(response, id, symbolName, securityName, symbol, exchangenum, country)); + } else { + return Mono.error(new UnsupportedOperationException("COUNTRY 입력 에러")); + } + } + + private Mono parseFStockInfoKorea2(String response, Integer id, String symbolName, String securityName, String symbol, EXCHANGENUM exchangenum, COUNTRY country) { + try { + JsonNode rootNode = objectMapper.readTree(response); + JsonNode outputNode = rootNode.get("output"); + StockInfoResponse stockInfoResponse = new StockInfoResponse(); + + if (outputNode != null) { + stockInfoResponse.setStockId(id); + stockInfoResponse.setSymbolName(symbolName); + stockInfoResponse.setSecurityName(securityName); + stockInfoResponse.setSymbol(symbol); + stockInfoResponse.setExchangeNum(exchangenum); + stockInfoResponse.setCountry(country); + stockInfoResponse.setYesterdayPrice(outputNode.get("stck_prdy_clpr").asDouble()); // 전일 종가 + stockInfoResponse.setPrice(outputNode.get("stck_prpr").asDouble()); // 현재가 + } + + return Mono.just(stockInfoResponse); + } catch (Exception e) { + return Mono.error(new UnsupportedOperationException("국내 종목 정보가 없습니다")); + } + } + /** * 국내, 해외 주식 정보 조회 */ @@ -85,9 +146,6 @@ private Mono parseFStockInfoKorea(String response, Integer id stockInfoResponse.setSymbol(symbol); stockInfoResponse.setExchangeNum(exchangenum); stockInfoResponse.setCountry(country); - stockInfoResponse.setPrice(outputNode.get("stck_prpr").asDouble()); - stockInfoResponse.setPriceDiff(outputNode.get("prdy_vrss").asDouble()); - stockInfoResponse.setPriceDiffPerCent(outputNode.get("prdy_ctrt").asDouble()); } return Mono.just(stockInfoResponse); @@ -109,7 +167,8 @@ private Mono parseFStockInfoOversea(String response, Integer stockInfoResponse.setCountry(country); stockInfoResponse.setSymbol(symbol); stockInfoResponse.setExchangeNum(exchangenum); - stockInfoResponse.setPrice(outputNode.get("last").asDouble()); + stockInfoResponse.setYesterdayPrice(outputNode.get("base").asDouble()); // 전일종가 + stockInfoResponse.setPrice(outputNode.get("last").asDouble()); // 현재가 // 해외는 diff가 절대값이므로 절대값에 따라 음수로 변경 if(outputNode.get("rate").asDouble() < 0) { stockInfoResponse.setPriceDiff(outputNode.get("diff").asDouble() * -1); @@ -122,7 +181,7 @@ private Mono parseFStockInfoOversea(String response, Integer return Mono.just(stockInfoResponse); } catch (Exception e) { - return Mono.error(new UnsupportedOperationException("국내 종목 정보가 없습니다")); + return Mono.error(new UnsupportedOperationException("해외 종목 정보가 없습니다")); } }