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: 랭킹 API 구현 #391

Merged
merged 19 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Tag(name = "8. [댓글]", description = "댓글 관련 API")
@Tag(name = "9. [댓글]", description = "댓글 관련 API")
@RestController
@RequestMapping("/comments")
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.depromeet.domain.image.domain.ImageFileExtension;
import com.depromeet.domain.member.dao.MemberRepository;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.member.domain.MemberStatus;
import com.depromeet.domain.member.domain.Profile;
import com.depromeet.domain.member.dto.request.NicknameCheckRequest;
import com.depromeet.domain.member.dto.request.NicknameUpdateRequest;
Expand Down Expand Up @@ -208,4 +209,9 @@ private String escapeSpecialCharacters(String nickname) {
// 여기서 특수문자를 '_'로 대체할 수 있도록 정규표현식을 활용하여 구현
return nickname == null ? "" : nickname.replaceAll("[^0-9a-zA-Z가-힣 ]", "_");
}

@Transactional(readOnly = true)
public List<Member> findAllNormalMember() {
return memberRepository.findAllByStatusIs(MemberStatus.NORMAL);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public interface MemberRepository extends JpaRepository<Member, Long>, MemberRep
"SELECT m FROM Member m WHERE m.profile.nickname LIKE CONCAT('%', :searchNickname, '%') escape '_' AND m.profile.nickname != :myNickname")
List<Member> nicknameSearch(
@Param("searchNickname") String searchNickname, @Param("myNickname") String myNickname);

List<Member> findAllByStatusIs(MemberStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public interface MissionRepositoryCustom {

List<Mission> findAllFinishedMission(Long memberId);

List<Mission> findAllMissionWithRecords();

List<Mission> findMissionsWithRecordsByDate(LocalDate date, Long memberId);

List<Mission> findMissionsNonCompleteAndInProgress(LocalDateTime today);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ public List<Mission> findMissionsWithRecords(Long memberId) {
return query.fetch();
}

@Override
public List<Mission> findAllMissionWithRecords() {
JPAQuery<Mission> query =
jpaQueryFactory
.selectFrom(mission)
.leftJoin(mission.missionRecords, missionRecord)
.fetchJoin();
return query.fetch();
}

@Override
public List<Mission> findInProgressMissionsWithRecords(Long memberId) {
JPAQuery<Mission> query =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.depromeet.domain.comment.domain.Comment;
import com.depromeet.domain.common.model.BaseTimeEntity;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.mission.domain.Mission;
import com.depromeet.domain.reaction.domain.Reaction;
import com.depromeet.global.error.exception.CustomException;
Expand Down Expand Up @@ -99,4 +100,8 @@ public void updateUploadStatusComplete(String remark, String imageUrl) {
public void updateMissionRecord(String remark) {
this.remark = remark;
}

public Member getMember() {
return mission.getMember();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.depromeet.domain.ranking.api;

import com.depromeet.domain.ranking.application.RankingService;
import com.depromeet.domain.ranking.dto.response.RankingResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "10. [랭킹]", description = "랭킹 관련 API")
@RestController
@RequestMapping("/ranking")
@RequiredArgsConstructor
public class RankingController {

private final RankingService rankingService;

@Operation(summary = "랭킹 조회", description = "랭킹을 조회합니다.")
@GetMapping
public List<RankingResponse> rankingFindAll() {
return rankingService.findAllRanking();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.depromeet.domain.ranking.application;

import com.depromeet.domain.mission.application.MissionService;
import com.depromeet.domain.mission.dao.MissionRepository;
import com.depromeet.domain.mission.domain.Mission;
import com.depromeet.domain.missionRecord.domain.MissionRecord;
import com.depromeet.domain.ranking.dao.RankingRepository;
import com.depromeet.domain.ranking.domain.Ranking;
import com.depromeet.domain.ranking.dto.RankingDto;
import com.depromeet.domain.ranking.dto.response.RankingResponse;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class RankingService {

private final RankingRepository rankingRepository;
private final MissionRepository missionRepository;
private final MissionService missionService;

@Transactional(readOnly = true)
public List<RankingResponse> findAllRanking() {
List<Ranking> rankings = rankingRepository.findTop50ByOrderBySymbolStackDesc();
return IntStream.range(0, rankings.size())
.mapToObj(i -> RankingResponse.of(rankings.get(i), (long) i + 1))
.collect(Collectors.toList());
}

public void updateSymbolStack(List<RankingDto> rankingDtos) {
for (RankingDto rankingDto : rankingDtos) {
Ranking ranking = Ranking.createRanking(rankingDto.symbolStack(), rankingDto.member());
rankingRepository.updateSymbolStackAndMemberId(
ranking.getSymbolStack(), ranking.getMember().getId());
}
}

@Transactional(readOnly = true)
public List<RankingDto> findAllMissionSymbolStack() {
List<Mission> missions = missionRepository.findAllMissionWithRecords();
List<MissionRecord> completedMissionRecords =
missionService.findCompletedMissionRecords(missions);

return completedMissionRecords.stream()
.collect(Collectors.groupingBy(MissionRecord::getMember))
.entrySet()
.stream()
.map(
entry ->
RankingDto.of(
entry.getKey(),
missionService.symbolStackCalculate(entry.getValue())))
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.depromeet.domain.ranking.dao;

import com.depromeet.domain.ranking.domain.Ranking;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface RankingRepository extends JpaRepository<Ranking, Long> {

// 최대 50개의 랭킹을 조회한다.
List<Ranking> findTop50ByOrderBySymbolStackDesc();

@Modifying
@Query(
value =
"INSERT INTO Ranking (member_id, symbol_stack, created_at) "
+ "VALUES (:memberId, :symbolStack, NOW()) "
+ "ON DUPLICATE KEY UPDATE member_id = :memberId, symbol_stack = :symbolStack, updated_at = NOW()",
Comment on lines +18 to +20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOW()를 실행하는 시점에 데이터가 있을때만 업데이트 되는거로 인지했어서 여쭤봅니당

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 데이터가 있을때만 업데이트되어 업데이트 시간만을 NOW()로 갱신해줍니당

nativeQuery = true)
void updateSymbolStackAndMemberId(
@Param("symbolStack") long symbolStack, @Param("memberId") Long memberId);
}
44 changes: 44 additions & 0 deletions src/main/java/com/depromeet/domain/ranking/domain/Ranking.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.depromeet.domain.ranking.domain;

import com.depromeet.domain.common.model.BaseTimeEntity;
import com.depromeet.domain.member.domain.Member;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Comment;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Ranking extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "ranking_id")
private Long id;

@Comment("번개 스택")
private Long symbolStack;

@OneToOne
@JoinColumn(name = "member_id")
private Member member;

@Builder(access = AccessLevel.PRIVATE)
private Ranking(Long symbolStack, Member member) {
this.symbolStack = symbolStack;
this.member = member;
}

public static Ranking createRanking(Long symbolStack, Member member) {
return Ranking.builder().symbolStack(symbolStack).member(member).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.domain.ranking.dto;

import com.depromeet.domain.member.domain.Member;

public record RankingDto(Member member, long symbolStack) {
public static RankingDto of(Member member, long symbolStack) {
return new RankingDto(member, symbolStack);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.depromeet.domain.ranking.dto.response;

import com.depromeet.domain.ranking.domain.Ranking;
import io.swagger.v3.oas.annotations.media.Schema;

public record RankingResponse(
@Schema(description = "사용자 ID", defaultValue = "1") Long memberId,
@Schema(description = "랭킹", defaultValue = "1") long rank,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

멤버 ID에서는 Long을 사용하고, 랭크 및 기타 필드에서는 long을 사용하신 이유가 있나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 memberId는 일관성 있게 Long으로 사용하였고,
long을 사용하는 이유는 굳이 기타 필드에 참조 타입 Long을 사용할 필요가 없다고 생각했어용
만약 rank나 symbolStack이 int의 범위만으로 충분하다면 int로 바꾸는 것도 고려할만하겠네요!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 그래도 박싱 / 언박싱 비용 생각해서 일관성 있게 전부 참조타입으로 맞춰주는게�좋지 않을까 싶네요
이거는 선택적으로 적용하시면 될듯 합니다

@Schema(description = "번개 수", defaultValue = "1") long symbolStack,
@Schema(description = "사용자 nickname", defaultValue = "default nickname") String nickname,
@Schema(description = "프로필 이미지", defaultValue = "profile image url")
String profileImageUrl) {
public static RankingResponse of(Ranking ranking, long rank) {
return new RankingResponse(
ranking.getMember().getId(),
rank,
ranking.getSymbolStack(),
ranking.getMember().getProfile().getNickname(),
ranking.getMember().getProfile().getProfileImageUrl());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ public class PushNotificationConstants {
public static final String PUSH_MISSION_REMIND_CONTENT = "지금부터 미션 인증을 할 수 있어요 🕑";
public static final String PUSH_MISSION_START_REMIND_TITLE = "미션을 시작할 시간이에요!";
public static final String PUSH_MISSION_START_REMIND_CONTENT = "10분만 투자해서 %s 미션을 완료해봐요 🥳";
public static final String PUSH_RANKING_CONTENT = "오늘의 랭킹이 업데이트 되었어요! 🎉";
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import static com.depromeet.global.common.constants.PushNotificationConstants.*;

import com.depromeet.domain.member.application.MemberService;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.mission.application.MissionService;
import com.depromeet.domain.mission.dto.response.MissionRemindPushResponse;
import com.depromeet.domain.notification.application.FcmService;
import com.depromeet.domain.ranking.application.RankingService;
import com.depromeet.domain.ranking.dto.RankingDto;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
Expand All @@ -18,6 +22,8 @@
@Slf4j
public class MissionBatchScheduler {
private final MissionService missionService;
private final MemberService memberService;
private final RankingService rankingService;
private final FcmService fcmService;

// 자정에 schedule 실행
Expand All @@ -27,6 +33,22 @@ public void updateFinishedDurationStatus() {
missionService.updateFinishedDurationStatus();
}

@Scheduled(cron = "0 0 21 * * *", zone = "Asia/Seoul")
public void updateRankingSymbolStack() {
log.info("Ranking Symbol Stack Update batch execute");
List<RankingDto> allMissionSymbolStack = rankingService.findAllMissionSymbolStack();
rankingService.updateSymbolStack(allMissionSymbolStack);

log.info("send All Member Ranking Notification");
List<Member> allNormalMember = memberService.findAllNormalMember();

List<String> tokenList =
allNormalMember.stream().map(member -> member.getFcmInfo().getFcmToken()).toList();
if (!tokenList.isEmpty()) {
fcmService.sendGroupMessageAsync(tokenList, PUSH_SERVICE_TITLE, PUSH_RANKING_CONTENT);
}
}

// 매 10분마다 schedule 실행
@Scheduled(cron = "0 */10 * * * *", zone = "Asia/Seoul")
public void missionRemindPushNotification() {
Expand Down
Loading
Loading