diff --git a/src/main/java/org/sopt/app/application/appjamrank/AppjamRankCalculator.java b/src/main/java/org/sopt/app/application/appjamrank/AppjamRankCalculator.java new file mode 100644 index 00000000..a0756ca8 --- /dev/null +++ b/src/main/java/org/sopt/app/application/appjamrank/AppjamRankCalculator.java @@ -0,0 +1,151 @@ +package org.sopt.app.application.appjamrank; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.sopt.app.application.playground.dto.PlaygroundProfileInfo; +import org.sopt.app.domain.entity.AppjamUser; +import org.sopt.app.domain.entity.soptamp.Stamp; +import org.sopt.app.domain.enums.TeamNumber; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PUBLIC) +public class AppjamRankCalculator { + + private final List latestStamps; + private final Map uploaderAppjamUserByUserId; + private final Map playgroundProfileByUserId; + + public List calculateRecentTeamRanks(int size) { + return latestStamps.stream() + .map(stamp -> { + AppjamUser uploaderAppjamUser = uploaderAppjamUserByUserId.get(stamp.getUserId()); + if (uploaderAppjamUser == null) { + return null; + } + + PlaygroundProfileInfo.PlaygroundProfile playgroundProfile = + playgroundProfileByUserId.get(stamp.getUserId()); + if (playgroundProfile == null) { + return null; + } + + String firstImageUrl = Optional.ofNullable(stamp.getImages()) + .filter(images -> !images.isEmpty()) + .map(List::getFirst) + .orElse(""); + + return AppjamRankInfo.TeamRank.of( + stamp, + firstImageUrl, + uploaderAppjamUser, + uploaderAppjamUser.getTeamNumber(), + playgroundProfile + ); + }) + .filter(Objects::nonNull) + .limit(size) + .toList(); + } + + public AppjamRankInfo.TodayTeamRankList calculateTodayTeamRanks( + List todayUserRanks, + Map totalPointsByUserId, + List allAppjamUsers, + int size + ) { + Map todayRankByUserId = todayUserRanks.stream() + .collect(Collectors.toMap( + AppjamRankInfo.TodayRank::getUserId, + Function.identity(), + (existing, replacement) -> existing + )); + + Map teamNameByTeamNumber = allAppjamUsers.stream() + .collect(Collectors.toMap( + AppjamUser::getTeamNumber, + AppjamUser::getTeamName, + (existing, replacement) -> existing + )); + + Map> membersByTeamNumber = allAppjamUsers.stream() + .collect(Collectors.groupingBy(AppjamUser::getTeamNumber)); + + List teamAggregates = membersByTeamNumber.entrySet().stream() + .map(entry -> aggregateTeam( + entry.getKey(), + entry.getValue(), + teamNameByTeamNumber.getOrDefault(entry.getKey(), ""), + todayRankByUserId, + totalPointsByUserId + )) + .sorted(Comparator + .comparingLong(TeamAggregate::todayPoints).reversed() + .thenComparing(TeamAggregate::firstCertifiedAtToday, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(TeamAggregate::teamNumber) + ) + .limit(size) + .toList(); + + AtomicInteger rankCounter = new AtomicInteger(1); + List ranks = teamAggregates.stream() + .map(teamAggregate -> AppjamRankInfo.TodayTeamRank.of( + rankCounter.getAndIncrement(), + teamAggregate.teamNumber(), + teamAggregate.teamName(), + teamAggregate.todayPoints(), + teamAggregate.totalPoints() + )) + .toList(); + + return AppjamRankInfo.TodayTeamRankList.of(ranks); + } + + + private TeamAggregate aggregateTeam( + TeamNumber teamNumber, + List teamMembers, + String teamName, + Map todayRankByUserId, + Map totalPointsByUserId + ) { + long todayPointsSum = 0L; + long totalPointsSum = 0L; + LocalDateTime firstCertifiedAtToday = null; + + for (AppjamUser teamMember : teamMembers) { + Long userId = teamMember.getUserId(); + + AppjamRankInfo.TodayRank todayRank = todayRankByUserId.get(userId); + if (todayRank != null) { + todayPointsSum += todayRank.getTodayPoints(); + + LocalDateTime certifiedAt = todayRank.getFirstCertifiedAtToday(); + if (certifiedAt != null && (firstCertifiedAtToday == null || certifiedAt.isBefore(firstCertifiedAtToday))) { + firstCertifiedAtToday = certifiedAt; + } + } + + totalPointsSum += totalPointsByUserId.getOrDefault(userId, 0L); + } + + return new TeamAggregate(teamNumber, teamName, todayPointsSum, totalPointsSum, firstCertifiedAtToday); + } + + private record TeamAggregate( + TeamNumber teamNumber, + String teamName, + long todayPoints, + long totalPoints, + LocalDateTime firstCertifiedAtToday + ) {} +} diff --git a/src/main/java/org/sopt/app/application/appjamrank/AppjamRankInfo.java b/src/main/java/org/sopt/app/application/appjamrank/AppjamRankInfo.java new file mode 100644 index 00000000..5e7f4a28 --- /dev/null +++ b/src/main/java/org/sopt/app/application/appjamrank/AppjamRankInfo.java @@ -0,0 +1,147 @@ +package org.sopt.app.application.appjamrank; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.sopt.app.application.playground.dto.PlaygroundProfileInfo; +import org.sopt.app.domain.entity.AppjamUser; +import org.sopt.app.domain.entity.soptamp.Stamp; +import org.sopt.app.domain.enums.TeamNumber; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AppjamRankInfo { + + @Getter + @Builder + public static class RankAggregate { + + private final List latestStamps; + private final List uploaderUserIds; + private final Map uploaderAppjamUserByUserId; + + public static RankAggregate of( + List latestStamps, + List uploaderUserIds, + Map uploaderAppjamUserByUserId + ) { + return RankAggregate.builder() + .latestStamps(latestStamps) + .uploaderUserIds(uploaderUserIds) + .uploaderAppjamUserByUserId(uploaderAppjamUserByUserId) + .build(); + } + + public static RankAggregate empty() { + return RankAggregate.builder() + .latestStamps(List.of()) + .uploaderUserIds(List.of()) + .uploaderAppjamUserByUserId(Map.of()) + .build(); + } + } + + @Getter + @Builder + public static class TeamRank { + private final Long stampId; + private final Long missionId; + private final Long userId; + private final String imageUrl; + private final LocalDateTime createdAt; + private final String userName; + private final String userProfileImage; + private final String teamName; + private final TeamNumber teamNumber; + + public static TeamRank of( + Stamp stamp, + String firstImageUrl, + AppjamUser uploaderAppjamUser, + TeamNumber teamNumber, + PlaygroundProfileInfo.PlaygroundProfile playgroundProfile + ) { + return TeamRank.builder() + .stampId(stamp.getId()) + .missionId(stamp.getMissionId()) + .userId(stamp.getUserId()) + .imageUrl(firstImageUrl) + .createdAt(stamp.getCreatedAt()) + .userName(playgroundProfile.getName()) + .userProfileImage(Optional.ofNullable(playgroundProfile.getProfileImage()).orElse("")) + .teamName(uploaderAppjamUser.getTeamName()) + .teamNumber(teamNumber) + .build(); + } + } + + @Getter + @Builder + @ToString + public static class TodayRank { + private final Long userId; + private final long todayPoints; + private final LocalDateTime firstCertifiedAtToday; + + public static TodayRank of(Long userId, long todayPoints, LocalDateTime firstCertifiedAtToday) { + return TodayRank.builder() + .userId(userId) + .todayPoints(todayPoints) + .firstCertifiedAtToday(firstCertifiedAtToday) + .build(); + } + } + + @Getter + @Builder + public static class RankList { + private final List ranks; + + public static RankList of(List ranks) { + return RankList.builder().ranks(ranks).build(); + } + } + + @Getter + @Builder + public static class TodayTeamRank { + private final int rank; + private final TeamNumber teamNumber; + private final String teamName; + private final long todayPoints; + private final long totalPoints; + + public static TodayTeamRank of( + int rank, + TeamNumber teamNumber, + String teamName, + long todayPoints, + long totalPoints + ) { + return TodayTeamRank.builder() + .rank(rank) + .teamNumber(teamNumber) + .teamName(teamName) + .todayPoints(todayPoints) + .totalPoints(totalPoints) + .build(); + } + } + + @Getter + @Builder + public static class TodayTeamRankList { + private final List ranks; + + public static TodayTeamRankList of(List ranks) { + return TodayTeamRankList.builder().ranks(ranks).build(); + } + } +} diff --git a/src/main/java/org/sopt/app/application/appjamrank/AppjamRankService.java b/src/main/java/org/sopt/app/application/appjamrank/AppjamRankService.java new file mode 100644 index 00000000..b639dcf7 --- /dev/null +++ b/src/main/java/org/sopt/app/application/appjamrank/AppjamRankService.java @@ -0,0 +1,63 @@ +package org.sopt.app.application.appjamrank; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.sopt.app.domain.entity.AppjamUser; +import org.sopt.app.domain.entity.soptamp.Stamp; +import org.sopt.app.interfaces.postgres.AppjamUserRepository; +import org.sopt.app.interfaces.postgres.StampRepository; +import org.sopt.app.interfaces.postgres.StampRepositoryCustom; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AppjamRankService { + private final StampRepository stampRepository; + private final AppjamUserRepository appjamUserRepository; + + public AppjamRankInfo.RankAggregate findRecentTeamRanks(Pageable pageable) { + + List latestStamps = stampRepository.findLatestStamps(pageable); + if (latestStamps.isEmpty()) { + return AppjamRankInfo.RankAggregate.empty(); + } + + List uploaderUserIds = latestStamps.stream() + .map(Stamp::getUserId) + .distinct() + .toList(); + + List uploaderAppjamUsers = appjamUserRepository.findAllByUserIdIn(uploaderUserIds); + + Map uploaderAppjamUserByUserId = uploaderAppjamUsers.stream() + .collect(Collectors.toMap( + AppjamUser::getUserId, + Function.identity(), + (existing, replacement) -> existing + )); + + return AppjamRankInfo.RankAggregate.of( + latestStamps, + uploaderUserIds, + uploaderAppjamUserByUserId + ); + } + + public List findTodayUserRankSources( + LocalDateTime todayStart, + LocalDateTime tomorrowStart + ) { + return stampRepository.findTodayUserRankSources(todayStart, tomorrowStart); + } + + public List findAllAppjamUsers() { + return appjamUserRepository.findAll(); + } +} diff --git a/src/main/java/org/sopt/app/application/app_service/AppServiceBadgeManager.java b/src/main/java/org/sopt/app/application/appservice/AppServiceBadgeManager.java similarity index 50% rename from src/main/java/org/sopt/app/application/app_service/AppServiceBadgeManager.java rename to src/main/java/org/sopt/app/application/appservice/AppServiceBadgeManager.java index 0847174d..6a4003b9 100644 --- a/src/main/java/org/sopt/app/application/app_service/AppServiceBadgeManager.java +++ b/src/main/java/org/sopt/app/application/appservice/AppServiceBadgeManager.java @@ -1,6 +1,6 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; -import org.sopt.app.application.app_service.dto.AppServiceBadgeInfo; +import org.sopt.app.application.appservice.dto.AppServiceBadgeInfo; public interface AppServiceBadgeManager { diff --git a/src/main/java/org/sopt/app/application/app_service/AppServiceBadgeService.java b/src/main/java/org/sopt/app/application/appservice/AppServiceBadgeService.java similarity index 81% rename from src/main/java/org/sopt/app/application/app_service/AppServiceBadgeService.java rename to src/main/java/org/sopt/app/application/appservice/AppServiceBadgeService.java index d91a4bd0..e9f9f1ff 100644 --- a/src/main/java/org/sopt/app/application/app_service/AppServiceBadgeService.java +++ b/src/main/java/org/sopt/app/application/appservice/AppServiceBadgeService.java @@ -1,10 +1,10 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import java.util.Map; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceBadgeInfo; -import org.sopt.app.application.app_service.dto.AppServiceEntryStatusResponse; -import org.sopt.app.application.app_service.dto.AppServiceInfo; +import org.sopt.app.application.appservice.dto.AppServiceBadgeInfo; +import org.sopt.app.application.appservice.dto.AppServiceEntryStatusResponse; +import org.sopt.app.application.appservice.dto.AppServiceInfo; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/sopt/app/application/app_service/AppServiceName.java b/src/main/java/org/sopt/app/application/appservice/AppServiceName.java similarity index 95% rename from src/main/java/org/sopt/app/application/app_service/AppServiceName.java rename to src/main/java/org/sopt/app/application/appservice/AppServiceName.java index 2fab89eb..b2960ff5 100644 --- a/src/main/java/org/sopt/app/application/app_service/AppServiceName.java +++ b/src/main/java/org/sopt/app/application/appservice/AppServiceName.java @@ -1,4 +1,4 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/org/sopt/app/application/app_service/AppServiceService.java b/src/main/java/org/sopt/app/application/appservice/AppServiceService.java similarity index 91% rename from src/main/java/org/sopt/app/application/app_service/AppServiceService.java rename to src/main/java/org/sopt/app/application/appservice/AppServiceService.java index 719d4f49..d8339f9b 100755 --- a/src/main/java/org/sopt/app/application/app_service/AppServiceService.java +++ b/src/main/java/org/sopt/app/application/appservice/AppServiceService.java @@ -1,9 +1,9 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import java.util.Comparator; import java.util.List; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceInfo; +import org.sopt.app.application.appservice.dto.AppServiceInfo; import org.sopt.app.domain.entity.AppService; import org.sopt.app.interfaces.postgres.AppServiceRepository; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/sopt/app/application/app_service/DefaultBadgeManager.java b/src/main/java/org/sopt/app/application/appservice/DefaultBadgeManager.java similarity index 79% rename from src/main/java/org/sopt/app/application/app_service/DefaultBadgeManager.java rename to src/main/java/org/sopt/app/application/appservice/DefaultBadgeManager.java index 82aca65c..817a281f 100644 --- a/src/main/java/org/sopt/app/application/app_service/DefaultBadgeManager.java +++ b/src/main/java/org/sopt/app/application/appservice/DefaultBadgeManager.java @@ -1,7 +1,7 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceBadgeInfo; +import org.sopt.app.application.appservice.dto.AppServiceBadgeInfo; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/sopt/app/application/app_service/FortuneBadgeManager.java b/src/main/java/org/sopt/app/application/appservice/FortuneBadgeManager.java similarity index 85% rename from src/main/java/org/sopt/app/application/app_service/FortuneBadgeManager.java rename to src/main/java/org/sopt/app/application/appservice/FortuneBadgeManager.java index cd84b6eb..c81562ed 100644 --- a/src/main/java/org/sopt/app/application/app_service/FortuneBadgeManager.java +++ b/src/main/java/org/sopt/app/application/appservice/FortuneBadgeManager.java @@ -1,7 +1,7 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceBadgeInfo; +import org.sopt.app.application.appservice.dto.AppServiceBadgeInfo; import org.sopt.app.application.fortune.FortuneService; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/sopt/app/application/app_service/OperationConfigService.java b/src/main/java/org/sopt/app/application/appservice/OperationConfigService.java similarity index 94% rename from src/main/java/org/sopt/app/application/app_service/OperationConfigService.java rename to src/main/java/org/sopt/app/application/appservice/OperationConfigService.java index a2ab6068..a2b8898e 100644 --- a/src/main/java/org/sopt/app/application/app_service/OperationConfigService.java +++ b/src/main/java/org/sopt/app/application/appservice/OperationConfigService.java @@ -1,4 +1,4 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import lombok.RequiredArgsConstructor; import org.sopt.app.common.config.OperationConfig; diff --git a/src/main/java/org/sopt/app/application/app_service/PokeBadgeManager.java b/src/main/java/org/sopt/app/application/appservice/PokeBadgeManager.java similarity index 89% rename from src/main/java/org/sopt/app/application/app_service/PokeBadgeManager.java rename to src/main/java/org/sopt/app/application/appservice/PokeBadgeManager.java index cae80be5..edd5a763 100644 --- a/src/main/java/org/sopt/app/application/app_service/PokeBadgeManager.java +++ b/src/main/java/org/sopt/app/application/appservice/PokeBadgeManager.java @@ -1,7 +1,7 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceBadgeInfo; +import org.sopt.app.application.appservice.dto.AppServiceBadgeInfo; import org.sopt.app.application.poke.PokeHistoryService; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/sopt/app/application/app_service/SoptampBadgeManager.java b/src/main/java/org/sopt/app/application/appservice/SoptampBadgeManager.java similarity index 89% rename from src/main/java/org/sopt/app/application/app_service/SoptampBadgeManager.java rename to src/main/java/org/sopt/app/application/appservice/SoptampBadgeManager.java index abd5b5d2..e31b10fa 100644 --- a/src/main/java/org/sopt/app/application/app_service/SoptampBadgeManager.java +++ b/src/main/java/org/sopt/app/application/appservice/SoptampBadgeManager.java @@ -1,7 +1,7 @@ -package org.sopt.app.application.app_service; +package org.sopt.app.application.appservice; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceBadgeInfo; +import org.sopt.app.application.appservice.dto.AppServiceBadgeInfo; import org.sopt.app.application.soptamp.SoptampUserService; import org.sopt.app.domain.enums.Part; import org.sopt.app.domain.enums.SoptPart; diff --git a/src/main/java/org/sopt/app/application/app_service/dto/AppServiceBadgeInfo.java b/src/main/java/org/sopt/app/application/appservice/dto/AppServiceBadgeInfo.java similarity index 92% rename from src/main/java/org/sopt/app/application/app_service/dto/AppServiceBadgeInfo.java rename to src/main/java/org/sopt/app/application/appservice/dto/AppServiceBadgeInfo.java index 2303e9f2..92887259 100644 --- a/src/main/java/org/sopt/app/application/app_service/dto/AppServiceBadgeInfo.java +++ b/src/main/java/org/sopt/app/application/appservice/dto/AppServiceBadgeInfo.java @@ -1,4 +1,4 @@ -package org.sopt.app.application.app_service.dto; +package org.sopt.app.application.appservice.dto; import lombok.*; diff --git a/src/main/java/org/sopt/app/application/app_service/dto/AppServiceEntryStatusResponse.java b/src/main/java/org/sopt/app/application/appservice/dto/AppServiceEntryStatusResponse.java similarity index 92% rename from src/main/java/org/sopt/app/application/app_service/dto/AppServiceEntryStatusResponse.java rename to src/main/java/org/sopt/app/application/appservice/dto/AppServiceEntryStatusResponse.java index 8bb903d2..df2694b7 100644 --- a/src/main/java/org/sopt/app/application/app_service/dto/AppServiceEntryStatusResponse.java +++ b/src/main/java/org/sopt/app/application/appservice/dto/AppServiceEntryStatusResponse.java @@ -1,7 +1,7 @@ -package org.sopt.app.application.app_service.dto; +package org.sopt.app.application.appservice.dto; import lombok.*; -import org.sopt.app.application.app_service.AppServiceName; +import org.sopt.app.application.appservice.AppServiceName; @Builder @Getter diff --git a/src/main/java/org/sopt/app/application/app_service/dto/AppServiceInfo.java b/src/main/java/org/sopt/app/application/appservice/dto/AppServiceInfo.java similarity index 93% rename from src/main/java/org/sopt/app/application/app_service/dto/AppServiceInfo.java rename to src/main/java/org/sopt/app/application/appservice/dto/AppServiceInfo.java index 17ba05c8..c944514a 100755 --- a/src/main/java/org/sopt/app/application/app_service/dto/AppServiceInfo.java +++ b/src/main/java/org/sopt/app/application/appservice/dto/AppServiceInfo.java @@ -1,4 +1,4 @@ -package org.sopt.app.application.app_service.dto; +package org.sopt.app.application.appservice.dto; import lombok.*; import org.sopt.app.domain.entity.AppService; diff --git a/src/main/java/org/sopt/app/facade/AppjamRankFacade.java b/src/main/java/org/sopt/app/facade/AppjamRankFacade.java new file mode 100644 index 00000000..930c2037 --- /dev/null +++ b/src/main/java/org/sopt/app/facade/AppjamRankFacade.java @@ -0,0 +1,113 @@ +package org.sopt.app.facade; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.sopt.app.application.appjamrank.AppjamRankInfo; +import org.sopt.app.application.appjamrank.AppjamRankCalculator; +import org.sopt.app.application.appjamrank.AppjamRankService; +import org.sopt.app.application.playground.PlaygroundAuthService; +import org.sopt.app.application.playground.dto.PlaygroundProfileInfo; +import org.sopt.app.application.rank.RankCacheService; +import org.sopt.app.domain.entity.AppjamUser; +import org.sopt.app.interfaces.postgres.StampRepositoryCustom; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AppjamRankFacade { + + private final PlaygroundAuthService playgroundAuthService; + private final AppjamRankService appjamRankService; + private final RankCacheService rankCacheService; + + @Transactional(readOnly = true) + public AppjamRankInfo.RankList findRecentTeamRanks(int size) { + Pageable pageable = PageRequest.of(0, size); + + AppjamRankInfo.RankAggregate aggregate = appjamRankService.findRecentTeamRanks(pageable); + if (aggregate.getLatestStamps().isEmpty()) { + return AppjamRankInfo.RankList.of(List.of()); + } + + List playgroundProfiles = playgroundAuthService.getPlaygroundMemberProfiles(aggregate.getUploaderUserIds()); + + Map playgroundProfileByUserId = playgroundProfiles.stream() + .collect(Collectors.toMap( + PlaygroundProfileInfo.PlaygroundProfile::getMemberId, + Function.identity(), + (existing, replacement) -> existing + )); + + AppjamRankCalculator calculator = new AppjamRankCalculator( + aggregate.getLatestStamps(), + aggregate.getUploaderAppjamUserByUserId(), + playgroundProfileByUserId + ); + + List ranks = calculator.calculateRecentTeamRanks(size); + return AppjamRankInfo.RankList.of(ranks); + } + + @Transactional(readOnly = true) + public AppjamRankInfo.TodayTeamRankList findTodayTeamRanks(int size) { + + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + List todayUserRanks = findTodayUserRanks(todayStart, tomorrowStart); + Map totalPointsByUserId = buildTotalPointsByUserId(); + List allAppjamUsers = appjamRankService.findAllAppjamUsers(); + + AppjamRankCalculator calculator = new AppjamRankCalculator( + List.of(), + Map.of(), + Map.of() + ); + + return calculator.calculateTodayTeamRanks( + todayUserRanks, + totalPointsByUserId, + allAppjamUsers, + size + ); + } + + private List findTodayUserRanks(LocalDateTime todayStart, LocalDateTime tomorrowStart) { + List sources = + appjamRankService.findTodayUserRankSources(todayStart, tomorrowStart); + + return sources.stream() + .map(source -> AppjamRankInfo.TodayRank.of( + source.userId(), + source.todayPoints(), + source.firstCertifiedAtToday() + )) + .toList(); + } + + private Map buildTotalPointsByUserId() { + Set> ranking = rankCacheService.getRanking(); + if (ranking == null || ranking.isEmpty()) { + return Map.of(); + } + + return ranking.stream() + .filter(tuple -> tuple.getValue() != null) + .collect(Collectors.toMap( + ZSetOperations.TypedTuple::getValue, + tuple -> tuple.getScore() == null ? 0L : tuple.getScore().longValue(), + (existing, replacement) -> existing + )); + } +} diff --git a/src/main/java/org/sopt/app/facade/AppjamtampFacade.java b/src/main/java/org/sopt/app/facade/AppjamtampFacade.java index 92519aa0..d56ac9ac 100644 --- a/src/main/java/org/sopt/app/facade/AppjamtampFacade.java +++ b/src/main/java/org/sopt/app/facade/AppjamtampFacade.java @@ -11,6 +11,7 @@ import org.sopt.app.application.stamp.StampInfo.AppjamtampView; import org.sopt.app.application.stamp.StampService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -22,6 +23,7 @@ public class AppjamtampFacade { private final ClapService clapService; private final AppjamUserService appjamUserService; + @Transactional(readOnly = true) public AppjamtampView getAppjamtamps(Long requestUserId, Long missionId, String nickname) { val owner = soptampUserFinder.findByNickname(nickname); val ownerUserId = owner.getUserId(); diff --git a/src/main/java/org/sopt/app/facade/HomeFacade.java b/src/main/java/org/sopt/app/facade/HomeFacade.java index c2d68413..4a698681 100755 --- a/src/main/java/org/sopt/app/facade/HomeFacade.java +++ b/src/main/java/org/sopt/app/facade/HomeFacade.java @@ -6,13 +6,12 @@ import java.util.Map; import java.util.stream.Collectors; -import org.sopt.app.application.app_service.AppServiceBadgeService; -import org.sopt.app.application.app_service.AppServiceName; -import org.sopt.app.application.app_service.AppServiceService; -import org.sopt.app.application.app_service.OperationConfigService; -import org.sopt.app.application.app_service.dto.AppServiceEntryStatusResponse; -import org.sopt.app.application.app_service.dto.AppServiceInfo; -import org.sopt.app.application.description.DescriptionInfo.MainDescription; +import org.sopt.app.application.appservice.AppServiceBadgeService; +import org.sopt.app.application.appservice.AppServiceName; +import org.sopt.app.application.appservice.AppServiceService; +import org.sopt.app.application.appservice.OperationConfigService; +import org.sopt.app.application.appservice.dto.AppServiceEntryStatusResponse; +import org.sopt.app.application.appservice.dto.AppServiceInfo; import org.sopt.app.application.description.DescriptionService; import org.sopt.app.application.meeting.MeetingResponse; import org.sopt.app.application.meeting.MeetingService; @@ -25,11 +24,8 @@ import org.sopt.app.common.config.OperationConfig; import org.sopt.app.common.config.OperationConfigCategory; import org.sopt.app.common.utils.ActivityDurationCalculator; -import org.sopt.app.domain.entity.User; import org.sopt.app.domain.enums.UserStatus; import org.sopt.app.presentation.home.MeetingParamRequest; -import org.sopt.app.presentation.home.response.CoffeeChatResponse; -import org.sopt.app.presentation.home.response.EmploymentPostResponse; import org.sopt.app.presentation.home.response.FloatingButtonResponse; import org.sopt.app.presentation.home.response.HomeDescriptionResponse; import org.sopt.app.presentation.home.response.ReviewFormResponse; @@ -37,7 +33,6 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import lombok.val; @Service @RequiredArgsConstructor diff --git a/src/main/java/org/sopt/app/facade/UserFacade.java b/src/main/java/org/sopt/app/facade/UserFacade.java index 80ef5779..b306b74b 100755 --- a/src/main/java/org/sopt/app/facade/UserFacade.java +++ b/src/main/java/org/sopt/app/facade/UserFacade.java @@ -3,7 +3,6 @@ import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; -import lombok.val; import org.sopt.app.application.fortune.FortuneService; import org.sopt.app.application.friend.FriendService; @@ -11,14 +10,13 @@ import org.sopt.app.application.platform.dto.PlatformUserInfoResponse; import org.sopt.app.application.playground.PlaygroundAuthService; import org.sopt.app.application.notification.NotificationService; -import org.sopt.app.application.app_service.AppServiceService; +import org.sopt.app.application.appservice.AppServiceService; import org.sopt.app.application.playground.dto.PlaygroundProfileInfo; import org.sopt.app.application.poke.PokeService; import org.sopt.app.application.stamp.ClapService; import org.sopt.app.application.stamp.StampService; import org.sopt.app.application.user.UserInfo; import org.sopt.app.application.user.UserService; -import org.sopt.app.domain.entity.User; import org.sopt.app.domain.enums.Friendship; import org.sopt.app.domain.enums.UserStatus; import org.sopt.app.presentation.user.UserResponse; diff --git a/src/main/java/org/sopt/app/interfaces/postgres/AppjamUserRepository.java b/src/main/java/org/sopt/app/interfaces/postgres/AppjamUserRepository.java index 7468d48f..1cda0ccc 100644 --- a/src/main/java/org/sopt/app/interfaces/postgres/AppjamUserRepository.java +++ b/src/main/java/org/sopt/app/interfaces/postgres/AppjamUserRepository.java @@ -1,5 +1,6 @@ package org.sopt.app.interfaces.postgres; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.sopt.app.domain.entity.AppjamUser; @@ -13,4 +14,8 @@ public interface AppjamUserRepository extends JpaRepository { Optional findTopByTeamNumberOrderById(TeamNumber teamNumber); Optional findByUserId(Long userId); + + List findAllByTeamNumberIn(Collection teamNumbers); + + List findAllByUserIdIn(Collection userIds); } diff --git a/src/main/java/org/sopt/app/interfaces/postgres/StampRepository.java b/src/main/java/org/sopt/app/interfaces/postgres/StampRepository.java index 54b06713..96c3fcfd 100755 --- a/src/main/java/org/sopt/app/interfaces/postgres/StampRepository.java +++ b/src/main/java/org/sopt/app/interfaces/postgres/StampRepository.java @@ -1,5 +1,6 @@ package org.sopt.app.interfaces.postgres; +import org.springframework.data.domain.Pageable; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -28,4 +29,10 @@ public interface StampRepository extends JpaRepository, StampReposi """) void increaseViewCount(@Param("stampId") Long stampId); + @Query(""" + select s + from Stamp s + order by s.createdAt desc + """) + List findLatestStamps(Pageable pageable); } diff --git a/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryCustom.java b/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryCustom.java index 6e7f038c..a20c9d16 100644 --- a/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryCustom.java +++ b/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryCustom.java @@ -1,5 +1,8 @@ package org.sopt.app.interfaces.postgres; +import java.time.LocalDateTime; +import java.util.List; + public interface StampRepositoryCustom { /** @@ -9,4 +12,12 @@ public interface StampRepositoryCustom { StampCounts incrementClapCountReturning(Long stampId, int increment); record StampCounts(int clapCount, long version) {} + + List findTodayUserRankSources(LocalDateTime todayStart, LocalDateTime tomorrowStart); + + record AppjamTodayRankSource( + Long userId, + long todayPoints, + LocalDateTime firstCertifiedAtToday + ) {} } diff --git a/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryImpl.java b/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryImpl.java index 2da84c94..84513319 100644 --- a/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryImpl.java +++ b/src/main/java/org/sopt/app/interfaces/postgres/StampRepositoryImpl.java @@ -1,9 +1,16 @@ package org.sopt.app.interfaces.postgres; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; +import org.sopt.app.common.exception.BadRequestException; +import org.sopt.app.common.response.ErrorCode; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Repository; @@ -36,4 +43,54 @@ public StampCounts incrementClapCountReturning(Long stampId, int increment) { long newVersion = ((Number)row[1]).longValue(); return new StampCounts(newClapCount, newVersion); } + + private static final int TODAY_POINT_PER_STAMP = 1000; + + @Override + public List findTodayUserRankSources(LocalDateTime todayStart, LocalDateTime tomorrowStart) { + final String sql = String.format(""" + SELECT + s.user_id AS user_id, + (COUNT(*) * :todayPointPerStamp) AS today_points, + MIN(s.created_at) AS first_certified_at_today + FROM %s.stamp s + WHERE s.created_at >= :todayStart + AND s.created_at < :tomorrowStart + GROUP BY s.user_id + ORDER BY today_points DESC, first_certified_at_today ASC + """, schema); + + Query query = em.createNativeQuery(sql); + query.setParameter("todayStart", todayStart); + query.setParameter("tomorrowStart", tomorrowStart); + query.setParameter("todayPointPerStamp", TODAY_POINT_PER_STAMP); + + @SuppressWarnings("unchecked") + List rows = query.getResultList(); + + return rows.stream() + .map(row -> new AppjamTodayRankSource( + ((Number) row[0]).longValue(), + ((Number) row[1]).longValue(), + toLocalDateTime(row[2]) + )) + .toList(); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime; + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime(); + } + if (value instanceof OffsetDateTime offsetDateTime) { + return offsetDateTime.toLocalDateTime(); + } + + throw new BadRequestException(ErrorCode.INVALID_PARAMETER); + } } diff --git a/src/main/java/org/sopt/app/interfaces/postgres/soptamp_point/SoptampPointRepositoryImpl.java b/src/main/java/org/sopt/app/interfaces/postgres/soptamp_point/SoptampPointRepositoryImpl.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/org/sopt/app/presentation/appjamrank/AppjamRankController.java b/src/main/java/org/sopt/app/presentation/appjamrank/AppjamRankController.java new file mode 100644 index 00000000..cc3c324c --- /dev/null +++ b/src/main/java/org/sopt/app/presentation/appjamrank/AppjamRankController.java @@ -0,0 +1,55 @@ +package org.sopt.app.presentation.appjamrank; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +import org.sopt.app.application.appjamrank.AppjamRankInfo; +import org.sopt.app.facade.AppjamRankFacade; +import org.sopt.app.presentation.appjamtamp.AppjamtampResponseMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v2/appjamrank") +@SecurityRequirement(name = "Authorization") +public class AppjamRankController { + + private final AppjamRankFacade appjamRankFacade; + + private final AppjamtampResponseMapper appjamtampResponseMapper; + + @Operation(summary = "앱잼팀 랭킹 최근 인증한 미션 TOP 조회하기") + @GetMapping("/recent") + public ResponseEntity getRecentTeamRanks( + @RequestParam(defaultValue = "3") @Min(1) int size + ) { + AppjamRankInfo.RankList appjamRankList = appjamRankFacade.findRecentTeamRanks(size); + AppjamRankResponse.AppjamtampRankListResponse response = appjamtampResponseMapper.of(appjamRankList); + + return ResponseEntity.ok(response); + } + + @Operation(summary = "앱잼팀 오늘의 득점 랭킹 TOP10 조회하기") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "success"), + @ApiResponse(responseCode = "500", description = "server error") + }) + @GetMapping("/today") + public ResponseEntity getTodayTeamRanks( + @RequestParam(defaultValue = "10") @Min(1) int size + ) { + AppjamRankInfo.TodayTeamRankList appjamTodayTeamRankList = appjamRankFacade.findTodayTeamRanks(size); + AppjamRankResponse.AppjamTodayRankListResponse response = appjamtampResponseMapper.of(appjamTodayTeamRankList); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/org/sopt/app/presentation/appjamrank/AppjamRankResponse.java b/src/main/java/org/sopt/app/presentation/appjamrank/AppjamRankResponse.java new file mode 100644 index 00000000..2323e2f3 --- /dev/null +++ b/src/main/java/org/sopt/app/presentation/appjamrank/AppjamRankResponse.java @@ -0,0 +1,86 @@ +package org.sopt.app.presentation.appjamrank; + +import java.time.LocalDateTime; +import java.util.List; + +import org.sopt.app.domain.enums.TeamNumber; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AppjamRankResponse { + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PUBLIC) + public static class AppjamtampRankResponse { + + @Schema(description = "스탬프 아이디", example = "1") + private Long stampId; + + @Schema(description = "미션 아이디", example = "2") + private Long missionId; + + @Schema(description = "유저 아이디", example = "123") + private Long userId; + + @Schema(description = "스탬프 이미지 (첫 번째 이미지)", example = "https://image.example.com/stamp.jpg") + private String imageUrl; + + @Schema(description = "스탬프 생성 시간", example = "2025-12-11T12:34:56") + private LocalDateTime createdAt; + + @Schema(description = "업로드한 유저 이름", example = "이지훈") + private String userName; + + @Schema(description = "업로드한 유저 프로필 이미지", example = "https://image.example.com/profile.jpg") + private String userProfileImage; + + @Schema(description = "앱잼 팀 이름", example = "로코코") + private String teamName; + + @Schema(description = "앱잼 팀 번호", example = "FIRST") + private TeamNumber teamNumber; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PUBLIC) + public static class AppjamtampRankListResponse { + + @Schema(description = "최근 인증한 앱잼 스탬프 TOP3 목록") + private List ranks; + } + + @Schema(description = "앱잼 팀 오늘의 득점 랭킹 응답") + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PUBLIC) + public static class AppjamTodayTeamRankResponse { + + @Schema(description = "랭킹 순위", example = "1") + private int rank; + + @Schema(description = "팀 이름", example = "로코코") + private String teamName; + + @Schema(description = "오늘 획득한 점수", example = "1000") + private long todayPoints; + + @Schema(description = "누적 점수", example = "3000") + private long totalPoints; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + @AllArgsConstructor(access = AccessLevel.PUBLIC) + public static class AppjamTodayRankListResponse { + + @Schema(description = "오늘의 앱잼 팀 랭킹 TOP10 목록") + private List ranks; + } +} diff --git a/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampController.java b/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampController.java index 457b4783..10c5d2e6 100644 --- a/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampController.java +++ b/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampController.java @@ -61,5 +61,4 @@ public ResponseEntity getMissions( val response = appjamtampResponseMapper.of(result); return ResponseEntity.ok(response); } - } \ No newline at end of file diff --git a/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponse.java b/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponse.java index 90233e5e..1ebee5a2 100644 --- a/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponse.java +++ b/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponse.java @@ -88,5 +88,4 @@ public boolean isMine() { return isMine; } } - } diff --git a/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponseMapper.java b/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponseMapper.java index 92688626..06d6b2a3 100644 --- a/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponseMapper.java +++ b/src/main/java/org/sopt/app/presentation/appjamtamp/AppjamtampResponseMapper.java @@ -1,12 +1,16 @@ package org.sopt.app.presentation.appjamtamp; +import java.util.List; + import org.mapstruct.InjectionStrategy; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; +import org.sopt.app.application.appjamrank.AppjamRankInfo; import org.sopt.app.application.mission.MissionInfo.AppjamMissionInfo; import org.sopt.app.application.mission.MissionInfo.AppjamMissionInfos; import org.sopt.app.application.stamp.StampInfo.AppjamtampView; +import org.sopt.app.presentation.appjamrank.AppjamRankResponse; import org.sopt.app.presentation.appjamtamp.AppjamtampResponse.AppjamMissionResponse; import org.sopt.app.presentation.appjamtamp.AppjamtampResponse.AppjamMissionResponses; @@ -19,10 +23,33 @@ public interface AppjamtampResponseMapper { AppjamMissionResponses of(AppjamMissionInfos missionList); + AppjamRankResponse.AppjamtampRankResponse toResponse(AppjamRankInfo.TeamRank teamRank); + // TeamMissionInfo to TeamMissionResponse @Mapping(source = "completed", target = "isCompleted") AppjamMissionResponse toResponse(AppjamMissionInfo info); + default AppjamRankResponse.AppjamtampRankListResponse of(AppjamRankInfo.RankList appjamRankList) { + List ranks = appjamRankList.getRanks().stream() + .map(this::toResponse) + .toList(); + + return new AppjamRankResponse.AppjamtampRankListResponse(ranks); + } + + default AppjamRankResponse.AppjamTodayRankListResponse of(AppjamRankInfo.TodayTeamRankList todayTeamRankList) { + List ranks = todayTeamRankList.getRanks().stream() + .map(teamRank -> new AppjamRankResponse.AppjamTodayTeamRankResponse( + teamRank.getRank(), + teamRank.getTeamName(), + teamRank.getTodayPoints(), + teamRank.getTotalPoints() + )) + .toList(); + + return new AppjamRankResponse.AppjamTodayRankListResponse(ranks); + } + default AppjamtampResponse.AppjamtampView of(AppjamtampView appjamtampView) { return AppjamtampResponse.AppjamtampView.builder() .id(appjamtampView.getId()) @@ -42,5 +69,4 @@ default AppjamtampResponse.AppjamtampView of(AppjamtampView appjamtampView) { .isMine(appjamtampView.isMine()) .build(); } - } diff --git a/src/main/java/org/sopt/app/presentation/home/HomeController.java b/src/main/java/org/sopt/app/presentation/home/HomeController.java index e7bdbab3..eb92f071 100644 --- a/src/main/java/org/sopt/app/presentation/home/HomeController.java +++ b/src/main/java/org/sopt/app/presentation/home/HomeController.java @@ -7,11 +7,9 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import java.util.List; import lombok.RequiredArgsConstructor; -import org.sopt.app.application.app_service.dto.AppServiceEntryStatusResponse; -import org.sopt.app.application.meeting.MeetingResponse; +import org.sopt.app.application.appservice.dto.AppServiceEntryStatusResponse; import org.sopt.app.application.playground.dto.PlaygroundPopularPostsResponse; import org.sopt.app.application.playground.dto.PlaygroundRecentPostsResponse; -import org.sopt.app.domain.entity.User; import org.sopt.app.facade.HomeFacade; import org.sopt.app.presentation.home.response.*; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/org/sopt/app/presentation/user/UserResponse.java b/src/main/java/org/sopt/app/presentation/user/UserResponse.java index c047da01..493ae204 100755 --- a/src/main/java/org/sopt/app/presentation/user/UserResponse.java +++ b/src/main/java/org/sopt/app/presentation/user/UserResponse.java @@ -10,7 +10,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; -import org.sopt.app.application.app_service.dto.AppServiceInfo; +import org.sopt.app.application.appservice.dto.AppServiceInfo; import org.sopt.app.application.playground.dto.PlaygroundProfileInfo.PlaygroundProfile; import org.sopt.app.domain.enums.SoptPart;