diff --git a/src/main/java/org/sopt/app/application/rank/AuthPartMemberCountReader.java b/src/main/java/org/sopt/app/application/rank/AuthPartMemberCountReader.java new file mode 100644 index 00000000..ead20eb0 --- /dev/null +++ b/src/main/java/org/sopt/app/application/rank/AuthPartMemberCountReader.java @@ -0,0 +1,40 @@ +package org.sopt.app.application.rank; + +import java.util.EnumMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.sopt.app.domain.enums.SoptPart; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthPartMemberCountReader { + + private final JdbcTemplate jdbcTemplate; + + public Map getCurrentGenerationPartMemberCounts(Long generation) { + String sql = """ + SELECT part, COUNT(*) AS member_count + FROM auth_prod.user_activity_histories + WHERE generation = ? + AND is_sopt = true + AND role = 'MEMBER' + AND part IN ('IOS', 'ANDROID', 'DESIGN', 'PLAN', 'SERVER', 'WEB') + GROUP BY part + """; + + return jdbcTemplate.query(sql, rs -> { + Map result = new EnumMap<>(SoptPart.class); + + while (rs.next()) { + result.put( + SoptPart.valueOf(rs.getString("part")), + rs.getLong("member_count") + ); + } + + return result; + }, generation); + } +} diff --git a/src/main/java/org/sopt/app/application/rank/SoptampPartRankCalculator.java b/src/main/java/org/sopt/app/application/rank/SoptampPartRankCalculator.java index b3c99960..5febab52 100755 --- a/src/main/java/org/sopt/app/application/rank/SoptampPartRankCalculator.java +++ b/src/main/java/org/sopt/app/application/rank/SoptampPartRankCalculator.java @@ -1,32 +1,92 @@ package org.sopt.app.application.rank; +import static java.util.Map.Entry.comparingByValue; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Comparator; +import java.util.EnumMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.sopt.app.application.soptamp.SoptampPointInfo.PartRank; import org.sopt.app.application.soptamp.SoptampUserInfo; import org.sopt.app.domain.enums.Part; - +import org.sopt.app.domain.enums.SoptPart; @RequiredArgsConstructor(access = AccessLevel.PUBLIC) public class SoptampPartRankCalculator { - private final List userInfos; + private static final int POINT_SCALE = 2; + private static final RoundingMode POINT_ROUNDING_MODE = RoundingMode.HALF_UP; + private static final BigDecimal ZERO_POINT = BigDecimal.ZERO.setScale(POINT_SCALE, POINT_ROUNDING_MODE); - private final PartScores partScores = new PartScores(); + private final List userInfos; + private final Map partMemberCounts; public List calculatePartRank() { - userInfos.forEach(this::calculatePartScore); - return Part.getPartsByReturnOrder().stream().map(part -> PartRank.builder() + PartScores partScores = new PartScores(); + userInfos.forEach(userInfo -> addPartScore(userInfo, partScores)); + + Map averagePoints = calculateAveragePoints(partScores); + Map ranks = calculateRanks(averagePoints); + + return Part.getPartsByReturnOrder().stream() + .map(part -> PartRank.builder() .part(part.getPartName()) - .rank(partScores.getRank(part)) - .points(partScores.getPoints(part)) - .build()).toList(); + .rank(ranks.get(part)) + .points(averagePoints.get(part)) + .build()) + .toList(); + } + + private void addPartScore(SoptampUserInfo userInfo, PartScores partScores) { + Part part = SoptPart.toPart(userInfo.getPart()); + if (part == null) { + return; + } + partScores.addPartScore(part, userInfo.getTotalPoints()); } - private void calculatePartScore(SoptampUserInfo userInfo) { - Part.getAllParts().stream() - .filter(part -> userInfo.getNickname().startsWith(part.getPartName())) - .forEach(part -> partScores.addPartScore(part, userInfo.getTotalPoints())); + private Map calculateAveragePoints(PartScores partScores) { + Map averagePoints = new EnumMap<>(Part.class); + + for (Part part : Part.getPartsByReturnOrder()) { + long totalScore = partScores.getPoints(part); + long memberCount = partMemberCounts.getOrDefault(SoptPart.valueOf(part.name()), 0L); + + BigDecimal averagePoint = memberCount == 0 ? ZERO_POINT + : BigDecimal.valueOf(totalScore) + .divide(BigDecimal.valueOf(memberCount), POINT_SCALE, POINT_ROUNDING_MODE); + + averagePoints.put(part, averagePoint); + } + + return averagePoints; + } + + private Map calculateRanks(Map averagePoints) { + List> sortedParts = averagePoints.entrySet().stream() + .sorted(comparingByValue(Comparator.reverseOrder())) + .toList(); + + Map ranks = new EnumMap<>(Part.class); + BigDecimal previousPoint = null; + int currentRank = 0; + + for (int i = 0; i < sortedParts.size(); i++) { + Entry entry = sortedParts.get(i); + + if (previousPoint == null || entry.getValue().compareTo(previousPoint) != 0) { + currentRank = i + 1; + previousPoint = entry.getValue(); + } + + ranks.put(entry.getKey(), currentRank); + } + + return ranks; } } diff --git a/src/main/java/org/sopt/app/application/soptamp/SoptampPointInfo.java b/src/main/java/org/sopt/app/application/soptamp/SoptampPointInfo.java index 84242a97..edb55654 100755 --- a/src/main/java/org/sopt/app/application/soptamp/SoptampPointInfo.java +++ b/src/main/java/org/sopt/app/application/soptamp/SoptampPointInfo.java @@ -1,5 +1,6 @@ package org.sopt.app.application.soptamp; +import java.math.BigDecimal; import lombok.*; @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -31,6 +32,6 @@ public static Main of(Integer rank, SoptampUserInfo soptampUserInfo) { public static class PartRank { private String part; private Integer rank; - private Long points; + private BigDecimal points; } } diff --git a/src/main/java/org/sopt/app/application/soptamp/SoptampUserFinder.java b/src/main/java/org/sopt/app/application/soptamp/SoptampUserFinder.java index 0f50fcc2..16c81aaf 100644 --- a/src/main/java/org/sopt/app/application/soptamp/SoptampUserFinder.java +++ b/src/main/java/org/sopt/app/application/soptamp/SoptampUserFinder.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.sopt.app.common.exception.BadRequestException; import org.sopt.app.common.response.ErrorCode; @@ -20,6 +21,7 @@ public class SoptampUserFinder { private final SoptampUserRepository soptampUserRepository; + @Getter @Value("${sopt.current.generation}") private Long currentGeneration; diff --git a/src/main/java/org/sopt/app/facade/RankFacade.java b/src/main/java/org/sopt/app/facade/RankFacade.java index a7206fd4..4ee48fbb 100755 --- a/src/main/java/org/sopt/app/facade/RankFacade.java +++ b/src/main/java/org/sopt/app/facade/RankFacade.java @@ -9,6 +9,7 @@ import org.sopt.app.common.exception.BadRequestException; import org.sopt.app.common.response.ErrorCode; import org.sopt.app.domain.enums.Part; +import org.sopt.app.domain.enums.SoptPart; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.stereotype.Service; @@ -20,6 +21,7 @@ public class RankFacade { private final SoptampUserFinder soptampUserFinder; private final RankCacheService rankCacheService; + private final AuthPartMemberCountReader authPartMemberCountReader; @Value("${makers.app.soptamp.appjam-mode:false}") private boolean appjamMode; @@ -102,7 +104,8 @@ public List findAllPartRanks() { throw new BadRequestException(ErrorCode.INVALID_APPJAM_SEASON_REQUEST); } List soptampUserInfos = soptampUserFinder.findAllOfCurrentGeneration(); - SoptampPartRankCalculator soptampPartRankCalculator = new SoptampPartRankCalculator(soptampUserInfos); + Map partMemberCounts = authPartMemberCountReader.getCurrentGenerationPartMemberCounts(soptampUserFinder.getCurrentGeneration()); + SoptampPartRankCalculator soptampPartRankCalculator = new SoptampPartRankCalculator(soptampUserInfos, partMemberCounts); return soptampPartRankCalculator.calculatePartRank(); } @@ -112,10 +115,12 @@ public PartRank findPartRank(Part part) { throw new BadRequestException(ErrorCode.INVALID_APPJAM_SEASON_REQUEST); } List soptampUserInfos = soptampUserFinder.findAllOfCurrentGeneration(); - SoptampPartRankCalculator soptampPartRankCalculator = new SoptampPartRankCalculator(soptampUserInfos); + Map partMemberCounts = authPartMemberCountReader.getCurrentGenerationPartMemberCounts(soptampUserFinder.getCurrentGeneration()); + SoptampPartRankCalculator soptampPartRankCalculator = new SoptampPartRankCalculator(soptampUserInfos, partMemberCounts); return soptampPartRankCalculator.calculatePartRank().stream() - .filter(partRank -> partRank.getPart().equals(part.getPartName())) - .findFirst().orElseThrow(); + .filter(partRank -> partRank.getPart().equals(part.getPartName())) + .findFirst() + .orElseThrow(); } @Transactional(readOnly = true) diff --git a/src/test/java/org/sopt/app/common/fixtures/SoptampUserFixture.java b/src/test/java/org/sopt/app/common/fixtures/SoptampUserFixture.java index 420accf2..c98d1fb0 100755 --- a/src/test/java/org/sopt/app/common/fixtures/SoptampUserFixture.java +++ b/src/test/java/org/sopt/app/common/fixtures/SoptampUserFixture.java @@ -154,4 +154,12 @@ public static PlatformUserInfoResponse getPlatformUserInfoResponse( ); } + public static final Map PART_MEMBER_COUNT_MAP = Map.of( + SoptPart.PLAN, 10L, + SoptPart.DESIGN, 10L, + SoptPart.WEB, 10L, + SoptPart.IOS, 10L, + SoptPart.ANDROID, 10L, + SoptPart.SERVER, 30L + ); } diff --git a/src/test/java/org/sopt/app/facade/RankFacadeTest.java b/src/test/java/org/sopt/app/facade/RankFacadeTest.java index 96036e2a..d79db2ba 100644 --- a/src/test/java/org/sopt/app/facade/RankFacadeTest.java +++ b/src/test/java/org/sopt/app/facade/RankFacadeTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; +import static org.sopt.app.common.fixtures.SoptampUserFixture.CURRENT_GENERATION; +import static org.sopt.app.common.fixtures.SoptampUserFixture.PART_MEMBER_COUNT_MAP; import static org.sopt.app.common.fixtures.SoptampUserFixture.SERVER_PART_SOPTAMP_USER; import static org.sopt.app.common.fixtures.SoptampUserFixture.SERVER_PART_SOPTAMP_USER_INFO_LIST; import static org.sopt.app.common.fixtures.SoptampUserFixture.SOPTAMP_PROFILE_MESSAGE_CACHE; @@ -18,6 +20,8 @@ import static org.sopt.app.domain.enums.Part.DESIGN; import static org.sopt.app.domain.enums.Part.IOS; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.Collections; import java.util.List; import org.assertj.core.groups.Tuple; @@ -29,12 +33,14 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.app.application.rank.AuthPartMemberCountReader; import org.sopt.app.application.rank.RankCacheService; import org.sopt.app.application.soptamp.SoptampPointInfo.Main; import org.sopt.app.application.soptamp.SoptampPointInfo.PartRank; import org.sopt.app.application.soptamp.SoptampUserFinder; import org.sopt.app.domain.entity.soptamp.SoptampUser; import org.sopt.app.domain.enums.Part; +import org.sopt.app.domain.enums.SoptPart; @ExtendWith(MockitoExtension.class) class RankFacadeTest { @@ -45,6 +51,9 @@ class RankFacadeTest { @Mock private RankCacheService rankCacheService; + @Mock + private AuthPartMemberCountReader authPartMemberCountReader; + @InjectMocks private RankFacade rankFacade; @@ -215,7 +224,7 @@ void SUCCESS_findCurrentRanksByPart() { List
result = rankFacade.findCurrentRanksByPart(Part.SERVER); // then - assertThat(result) + assertThat(result) .hasSize(SERVER_PART_SOPTAMP_USER_INFO_LIST.size()) .extracting(Main::getRank, Main::getNickname, Main::getPoint) .containsExactlyInAnyOrder( @@ -248,34 +257,45 @@ class FindAllPartRanksTest{ void setUp(){ // given when(soptampUserFinder.findAllOfCurrentGeneration()).thenReturn(SOPTAMP_USER_INFO_LIST); + when(soptampUserFinder.getCurrentGeneration()).thenReturn(CURRENT_GENERATION); + when(authPartMemberCountReader.getCurrentGenerationPartMemberCounts(CURRENT_GENERATION)) + .thenReturn(PART_MEMBER_COUNT_MAP); //when result = rankFacade.findAllPartRanks(); } - @Test @DisplayName("SUCCESS 파트별 솝탬프 포인트 랭킹 조회 시 기-디-웹-아-안-서 순서대로 조회함") void SUCCESS_findAllPartRanks_sortedByPart() { + BigDecimal planPoint = BigDecimal.ZERO.setScale(2); + BigDecimal designPoint = BigDecimal.valueOf(SOPTAMP_USER_4.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.DESIGN)), 2, RoundingMode.HALF_UP); + BigDecimal webPoint = BigDecimal.ZERO.setScale(2); + BigDecimal iosPoint = BigDecimal.valueOf(SOPTAMP_USER_3.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.IOS)), 2, RoundingMode.HALF_UP); + BigDecimal androidPoint = BigDecimal.valueOf(SOPTAMP_USER_2.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.ANDROID)), 2, RoundingMode.HALF_UP); + BigDecimal serverPoint = BigDecimal.valueOf(SERVER_PART_SOPTAMP_USER.stream().mapToLong(SoptampUser::getTotalPoints).sum()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.SERVER)), 2, RoundingMode.HALF_UP); + //then assertThat(result) - .extracting(PartRank::getPart) - .containsExactly(Part.PLAN.getPartName(), - Part.DESIGN.getPartName(), - Part.WEB.getPartName(), - Part.IOS.getPartName(), - Part.ANDROID.getPartName(), - Part.SERVER.getPartName()); + .extracting(PartRank::getPart, PartRank::getRank, PartRank::getPoints) + .containsExactly( + Tuple.tuple(Part.PLAN.getPartName(), 5, planPoint), + Tuple.tuple(Part.DESIGN.getPartName(), 2, designPoint), + Tuple.tuple(Part.WEB.getPartName(), 5, webPoint), + Tuple.tuple(Part.IOS.getPartName(), 2, iosPoint), + Tuple.tuple(Part.ANDROID.getPartName(), 4, androidPoint), + Tuple.tuple(Part.SERVER.getPartName(), 1, serverPoint) + ); } @Test @DisplayName("SUCCESS 파트별 솝탬프 포인트가 동점일 경우 랭킹도 동점으로 조회됨") void SUCCESS_findAllPartRanks_whenTiedPart() { - Long tiedPoint = SOPTAMP_USER_3.getTotalPoints(); + BigDecimal tiedPoint = BigDecimal.valueOf(SOPTAMP_USER_3.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.IOS)), 2, RoundingMode.HALF_UP); // then List tiedPart = result.stream() - .filter(partRank -> partRank.getPoints().equals(tiedPoint)) + .filter(partRank -> partRank.getPoints().compareTo(tiedPoint) == 0) .toList(); assertThat(tiedPart) @@ -290,10 +310,12 @@ void SUCCESS_findAllPartRanks_whenTiedPart() { @Test @DisplayName("SUCCESS 이전에 솝탬프 포인트가 동점인 파트가 존재했을 경우 다음 순위는 동점 파트 수를 건너뛰고 계산됨") void SUCCESS_findAllPartRanks_whenAfterTiedPart(){ + BigDecimal androidPoint = BigDecimal.valueOf(SOPTAMP_USER_2.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.ANDROID)), 2, RoundingMode.HALF_UP); + // then assertThat(result) - .extracting(PartRank::getPart, PartRank::getRank) - .contains(Tuple.tuple(Part.ANDROID.getPartName(), 4)); + .extracting(PartRank::getPart, PartRank::getRank, PartRank::getPoints) + .contains(Tuple.tuple(Part.ANDROID.getPartName(), 4, androidPoint)); } } @@ -305,6 +327,9 @@ class FindPartRankTest{ @BeforeEach void setUp(){ when(soptampUserFinder.findAllOfCurrentGeneration()).thenReturn(SOPTAMP_USER_INFO_LIST); + when(soptampUserFinder.getCurrentGeneration()).thenReturn(CURRENT_GENERATION); + when(authPartMemberCountReader.getCurrentGenerationPartMemberCounts(CURRENT_GENERATION)) + .thenReturn(PART_MEMBER_COUNT_MAP); } @Test @@ -313,12 +338,12 @@ void SUCCESS_findPartRank() { // when PartRank result = rankFacade.findPartRank(Part.SERVER); + BigDecimal serverPoint = BigDecimal.valueOf(SERVER_PART_SOPTAMP_USER.stream().mapToLong(SoptampUser::getTotalPoints).sum()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.SERVER)), 2, RoundingMode.HALF_UP); + // then assertThat(result) .extracting(PartRank::getPart, PartRank::getRank, PartRank::getPoints) - .contains(Part.SERVER.getPartName(), 1, SERVER_PART_SOPTAMP_USER.stream() - .mapToLong(SoptampUser::getTotalPoints) - .sum()); + .contains(Part.SERVER.getPartName(), 1, serverPoint); } @Test @@ -328,14 +353,17 @@ void SUCCESS_findPartRank_whenTiedPart() { PartRank designResult = rankFacade.findPartRank(DESIGN); PartRank iosResult = rankFacade.findPartRank(IOS); + BigDecimal designPoint = BigDecimal.valueOf(SOPTAMP_USER_4.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.DESIGN)), 2, RoundingMode.HALF_UP); + BigDecimal iosPoint = BigDecimal.valueOf(SOPTAMP_USER_3.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.IOS)), 2, RoundingMode.HALF_UP); + // then assertThat(designResult) .extracting(PartRank::getPart, PartRank::getRank, PartRank::getPoints) - .contains(Part.DESIGN.getPartName(), 2, SOPTAMP_USER_4.getTotalPoints()); + .contains(Part.DESIGN.getPartName(), 2, designPoint); assertThat(iosResult) .extracting(PartRank::getPart, PartRank::getRank, PartRank::getPoints) - .contains(Part.IOS.getPartName(), 2, SOPTAMP_USER_3.getTotalPoints()); + .contains(Part.IOS.getPartName(), 2, iosPoint); } @Test @@ -344,10 +372,12 @@ void SUCCESS_findPartRank_whenAfterTiedPart() { // when PartRank result = rankFacade.findPartRank(ANDROID); + BigDecimal androidPoint = BigDecimal.valueOf(SOPTAMP_USER_2.getTotalPoints()).divide(BigDecimal.valueOf(PART_MEMBER_COUNT_MAP.get(SoptPart.ANDROID)), 2, RoundingMode.HALF_UP); + // then assertThat(result) .extracting(PartRank::getPart, PartRank::getRank, PartRank::getPoints) - .contains(ANDROID.getPartName(), 4, SOPTAMP_USER_2.getTotalPoints()); + .contains(ANDROID.getPartName(), 4, androidPoint); } } @@ -419,7 +449,5 @@ void SUCCESS_findUserRank_whenTiedUser() { assertThat(resultUser4).isBetween(3L, 4L); } } - } - -} \ No newline at end of file +}