Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement read portfolio event #95

Merged
merged 14 commits into from
Apr 21, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ public PortfolioResponse createPortfolio(final PortfolioRequest request) {
List<PortfolioStock> portfolioStocks =
request.tickerShares()
.stream()
.map(tickerShare -> new PortfolioStock(
getStockByTicker(tickerShare.ticker()).getId(),
tickerShare.share())
)
.map(it -> new PortfolioStock(getStockByTicker(it.ticker()).getId(), it.share()))
.toList();

return new PortfolioResponse(
Expand All @@ -57,14 +54,14 @@ public PortfolioResponse createPortfolio(final PortfolioRequest request) {
);
}

@Transactional(readOnly = true)
public List<SectorRatioResponse> analyzeSectorRatio(final UUID portfolioId) {
List<PortfolioStock> portfolioStocks = getPortfolio(portfolioId).portfolioStocks();
List<StockShare> stockShares = portfolioStocks
.stream()
.map(ps -> new StockShare(getStock(ps.getStockId()), ps.getShares()))
.toList();
Map<Sector, SectorInfo> sectorInfoMap = sectorAnalysisService.calculateSectorRatios(stockShares);

return SectorRatioResponse.fromMap(sectorInfoMap);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nexters.payout.apiserver.portfolio.application.handler;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EventFacade {

private final PortfolioEventHandler portfolioEventHandler;

@EventListener
void publishReadPortfolioEvent(final ReadPortfolioEvent event) {
try {
portfolioEventHandler.handleReadPortfolioEvent(event);
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("[ReadPortfolioEvent] optimistic lock exception!", e);
publishReadPortfolioEvent(event);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package nexters.payout.apiserver.portfolio.application.handler;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import nexters.payout.domain.portfolio.domain.Portfolio;
import nexters.payout.domain.portfolio.domain.exception.PortfolioNotFoundException;
import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Transactional
public class PortfolioEventHandler {

private final PortfolioRepository portfolioRepository;

void handleReadPortfolioEvent(final ReadPortfolioEvent event) {
Portfolio portfolio = portfolioRepository.findById(event.portfolioId())
.orElseThrow(() -> new PortfolioNotFoundException(event.portfolioId()));
portfolio.incrementHits();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package nexters.payout.apiserver.portfolio.application.handler;

import java.util.UUID;


public record ReadPortfolioEvent(
UUID portfolioId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse;
import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse;
import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse;
import nexters.payout.apiserver.stock.application.dto.request.SectorRatioRequest;
import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import nexters.payout.apiserver.portfolio.application.handler.ReadPortfolioEvent;


import java.util.List;
import java.util.UUID;
Expand All @@ -23,6 +25,7 @@
public class PortfolioController implements PortfolioControllerDocs {

private final PortfolioQueryService portfolioQueryService;
private final ApplicationEventPublisher applicationEventPublisher;

@PostMapping
public ResponseEntity<PortfolioResponse> createPortfolio(@RequestBody @Valid final PortfolioRequest portfolioRequest) {
Expand All @@ -41,6 +44,9 @@ public ResponseEntity<YearlyDividendResponse> getYearlyDividends(@PathVariable("

@GetMapping("/{id}/sector-ratio")
public ResponseEntity<List<SectorRatioResponse>> getSectorRatios(@PathVariable("id") final UUID portfolioId) {
return ResponseEntity.ok(portfolioQueryService.analyzeSectorRatio(portfolioId));
List<SectorRatioResponse> result = portfolioQueryService.analyzeSectorRatio(portfolioId);
applicationEventPublisher.publishEvent(new ReadPortfolioEvent(portfolioId));
log.info(String.format("publish read portfolio event [%s]", portfolioId));
return ResponseEntity.ok(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse;
import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse;
import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse;
import nexters.payout.apiserver.portfolio.application.handler.ReadPortfolioEvent;
import nexters.payout.apiserver.portfolio.common.GivenFixtureTest;
import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse;
import nexters.payout.apiserver.stock.application.dto.response.StockResponse;
Expand All @@ -23,6 +24,7 @@
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;

import java.time.LocalDate;
import java.time.ZoneOffset;
Expand All @@ -39,13 +41,18 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class PortfolioQueryServiceTest extends GivenFixtureTest {

@Mock
private PortfolioRepository portfolioRepository;

@Mock
private ApplicationEventPublisher applicationEventPublisher;

@InjectMocks
private PortfolioQueryService portfolioQueryService;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package nexters.payout.apiserver.portfolio.application.handler;

import io.restassured.RestAssured;
import io.restassured.common.mapper.TypeRef;
import io.restassured.http.ContentType;
import nexters.payout.apiserver.portfolio.common.IntegrationTest;
import nexters.payout.core.time.InstantProvider;
import nexters.payout.domain.DividendFixture;
import nexters.payout.domain.PortfolioFixture;
import nexters.payout.domain.StockFixture;
import nexters.payout.domain.portfolio.domain.Portfolio;
import nexters.payout.domain.portfolio.domain.PortfolioStock;
import nexters.payout.domain.stock.domain.Sector;
import nexters.payout.domain.stock.domain.Stock;
import org.junit.jupiter.api.Test;

import java.time.*;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;

import static nexters.payout.domain.StockFixture.AAPL;
import static nexters.payout.domain.StockFixture.TSLA;
import static org.apache.http.HttpStatus.SC_OK;
import static org.assertj.core.api.Assertions.assertThat;

class PortfolioEventHandlerTest extends IntegrationTest {

@Test
void 포트폴리오_조회시_조회수가_늘어난다() {
// given
Portfolio portfolio = stockAndDividendAndPortfolioGiven();
portfolioRepository.flush();

// when
RestAssured
.given()
.log().all()
.contentType(ContentType.JSON)
.when().get(String.format("api/portfolios/%s/sector-ratio", portfolio.getId()))
.then().log().all()
.statusCode(SC_OK)
.extract()
.as(new TypeRef<>(){});

// then
assertThat(portfolioRepository.findById(portfolio.getId()).get().getHits()).isEqualTo(1);
}

@Test
void 동시에_포트폴리오를_조회하면_정상적으로_조회수가_늘어난다() throws InterruptedException {
// given
Portfolio portfolio = stockAndDividendAndPortfolioGiven();
portfolioRepository.flush();
int threadCount = 100;
CountDownLatch latch = new CountDownLatch(threadCount);

// when
for (int i = 0; i < threadCount; i++) {
Thread thread = new Thread(new ReadPortfolioTask(portfolio.getId(), latch));
thread.start();
}
latch.await();

// then
assertThat(portfolioRepository.findById(portfolio.getId()).get().getHits()).isEqualTo(100);
}

private Portfolio stockAndDividendAndPortfolioGiven() {
Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY));
Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL));

dividendRepository.save(DividendFixture.createDividend(
aapl.getId(),
2.5,
parseDate(InstantProvider.getLastYear(), 1)));
dividendRepository.save(DividendFixture.createDividend(
aapl.getId(),
2.5,
parseDate(InstantProvider.getLastYear(), 6)));
dividendRepository.save(DividendFixture.createDividend(
tsla.getId(),
3.0,
parseDate(InstantProvider.getLastYear(), 6)));

return portfolioRepository.save(PortfolioFixture.createPortfolio(
LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC),
List.of(new PortfolioStock(aapl.getId(), 2), new PortfolioStock(tsla.getId(), 1))
)
);
}

private Instant parseDate(int year, int month) {
LocalDate date = LocalDate.of(year, month, 1);
ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC"));
return zonedDateTime.toInstant();
}

static class ReadPortfolioTask implements Runnable {

private final UUID id;
private final CountDownLatch latch;

public ReadPortfolioTask(UUID id, CountDownLatch latch) {
this.id = id;
this.latch = latch;
}

@Override
public void run() {
try {
RestAssured
.given()
.log().all()
.contentType(ContentType.JSON)
.when().get(String.format("api/portfolios/%s/sector-ratio", id))
.then().log().all()
.statusCode(SC_OK)
.extract()
.as(new TypeRef<>(){});
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

import static nexters.payout.domain.StockFixture.AAPL;
import static nexters.payout.domain.StockFixture.TSLA;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package nexters.payout.domain.portfolio.domain;

import jakarta.persistence.*;
import lombok.Getter;
import nexters.payout.domain.BaseEntity;

import java.time.Instant;
Expand All @@ -10,13 +11,19 @@


@Entity
@Getter
public class Portfolio extends BaseEntity {

@Embedded
private PortfolioStocks portfolioStocks;

private Instant expireAt;

private Integer hits;

@Version
private Long version = 0L;

public Portfolio() {
super(null);
}
Expand All @@ -25,18 +32,24 @@ public Portfolio(final UUID id, final Instant expireAt, List<PortfolioStock> sto
super(id);
this.portfolioStocks = new PortfolioStocks(stocks);
this.expireAt = expireAt;
this.hits = 0;
}

public Portfolio(final Instant expireAt, List<PortfolioStock> stocks) {
super(null);
this.portfolioStocks = new PortfolioStocks(stocks);
this.expireAt = expireAt;
this.hits = 0;
}

public List<PortfolioStock> portfolioStocks() {
return Collections.unmodifiableList(portfolioStocks.stockShares());
}

public void incrementHits() {
hits++;
}

public boolean isExpired() {
return expireAt.isAfter(Instant.now());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table portfolio
add hits int not null;

alter table portfolio
add version bigint not null default 1;
Loading