diff --git a/src/main/java/org/example/studylog/controller/RankingController.java b/src/main/java/org/example/studylog/controller/RankingController.java new file mode 100644 index 0000000..3145dec --- /dev/null +++ b/src/main/java/org/example/studylog/controller/RankingController.java @@ -0,0 +1,45 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.RankingResponseDTO; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.service.RankingService; +import org.example.studylog.util.ResponseUtil; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class RankingController { + + private final RankingService rankingService; + + @Operation(summary = "랭킹 조회", description = "현재 월의 기록순 랭킹을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "랭킹 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = RankingResponseDTO.class))) + }) + @GetMapping("/rankings") + public ResponseEntity getRanking(@AuthenticationPrincipal CustomOAuth2User currentUser, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month) { + log.info("랭킹 목록 조회 요청: 사용자={}", currentUser.getName()); + List data = rankingService.getFriendRankings(currentUser.getName(), year, month); + return ResponseUtil.buildResponse(200, "랭킹 목록 조회 완료", data); + } + +} diff --git a/src/main/java/org/example/studylog/dto/RankingResponseDTO.java b/src/main/java/org/example/studylog/dto/RankingResponseDTO.java new file mode 100644 index 0000000..66bfec4 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/RankingResponseDTO.java @@ -0,0 +1,17 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class RankingResponseDTO { + private Long id; + private String nickname; + private String profileImage; + private String code; + private int recordCount; + private boolean isMe; +} diff --git a/src/main/java/org/example/studylog/entity/UserMonthlyStat.java b/src/main/java/org/example/studylog/entity/UserMonthlyStat.java new file mode 100644 index 0000000..9341c0c --- /dev/null +++ b/src/main/java/org/example/studylog/entity/UserMonthlyStat.java @@ -0,0 +1,30 @@ +package org.example.studylog.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.example.studylog.entity.user.User; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "user_monthly_stat", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "year", "month"})) +public class UserMonthlyStat { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private int year; + private int month; + + @Column(nullable = false) + private int recordCount; +} diff --git a/src/main/java/org/example/studylog/event/RecordCreatedEvent.java b/src/main/java/org/example/studylog/event/RecordCreatedEvent.java new file mode 100644 index 0000000..4a475e6 --- /dev/null +++ b/src/main/java/org/example/studylog/event/RecordCreatedEvent.java @@ -0,0 +1,12 @@ +package org.example.studylog.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RecordCreatedEvent { + private final Long userId; + private final int year; + private final int month; +} diff --git a/src/main/java/org/example/studylog/event/listener/RecordEventListener.java b/src/main/java/org/example/studylog/event/listener/RecordEventListener.java new file mode 100644 index 0000000..5be84a9 --- /dev/null +++ b/src/main/java/org/example/studylog/event/listener/RecordEventListener.java @@ -0,0 +1,27 @@ +package org.example.studylog.event.listener; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.event.RecordCreatedEvent; +import org.example.studylog.repository.custom.RankingRepositoryImpl; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecordEventListener { + + private final RankingRepositoryImpl rankingRepository; + + @Async + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleRecordCreated(RecordCreatedEvent event){ + log.info("랭킹 집계 이벤트 발행: USER={}, YEAR={}, MONTH={}", event.getUserId(), event.getYear(), event.getMonth()); + rankingRepository.incrementOrInsert(event.getUserId(), event.getYear(), event.getMonth()); + } +} diff --git a/src/main/java/org/example/studylog/repository/FriendRepository.java b/src/main/java/org/example/studylog/repository/FriendRepository.java index 1e2763f..3bc669f 100644 --- a/src/main/java/org/example/studylog/repository/FriendRepository.java +++ b/src/main/java/org/example/studylog/repository/FriendRepository.java @@ -4,7 +4,9 @@ import org.example.studylog.entity.Friend; import org.example.studylog.entity.user.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.Optional; public interface FriendRepository extends JpaRepository { @@ -14,4 +16,7 @@ public interface FriendRepository extends JpaRepository { Optional findByUserAndFriend(User user, User friend); long countByUser(User user); + + @Query("SELECT f.friend.id FROM Friend f WHERE f.user.id = :userId") + List findFriendIdsByUserId(Long userId); } diff --git a/src/main/java/org/example/studylog/repository/custom/RankingRepositoryCustom.java b/src/main/java/org/example/studylog/repository/custom/RankingRepositoryCustom.java new file mode 100644 index 0000000..a8cbb77 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/custom/RankingRepositoryCustom.java @@ -0,0 +1,10 @@ +package org.example.studylog.repository.custom; + +import org.example.studylog.dto.RankingResponseDTO; + +import java.util.List; + +public interface RankingRepositoryCustom { + List findFriendRankings(int year, int month, List userIds); + void incrementOrInsert(Long userId, int year, int month); +} diff --git a/src/main/java/org/example/studylog/repository/custom/RankingRepositoryImpl.java b/src/main/java/org/example/studylog/repository/custom/RankingRepositoryImpl.java new file mode 100644 index 0000000..77ff3d6 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/custom/RankingRepositoryImpl.java @@ -0,0 +1,88 @@ +package org.example.studylog.repository.custom; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.example.studylog.dto.RankingResponseDTO; +import org.example.studylog.entity.QUserMonthlyStat; +import org.example.studylog.entity.UserMonthlyStat; +import org.example.studylog.entity.user.QUser; +import org.example.studylog.entity.user.User; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class RankingRepositoryImpl implements RankingRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + @Override + public List findFriendRankings( + int year, + int month, + List userIds + ) { + QUserMonthlyStat stat = QUserMonthlyStat.userMonthlyStat; + QUser user = QUser.user; + + return queryFactory + .select(Projections.constructor( + RankingResponseDTO.class, + user.id, + user.nickname, + user.profileImage, + user.code, + stat.recordCount, + Expressions.constant(false) + )) + .from(stat) + .join(stat.user, user) + .where( + user.id.in(userIds), + stat.year.eq(year), + stat.month.eq(month) + ) + .orderBy(stat.recordCount.desc(), user.id.asc()) + .fetch(); + } + + @Override + @Transactional + public void incrementOrInsert(Long userId, int year, int month) { + QUserMonthlyStat stat = QUserMonthlyStat.userMonthlyStat; + + // 이미 존재하는지 확인 + UserMonthlyStat existing = queryFactory + .selectFrom(stat) + .where( + stat.user.id.eq(userId), + stat.year.eq(year), + stat.month.eq(month) + ) + .fetchOne(); + + if (existing != null) { + //있으면 recordCount + 1 + queryFactory.update(stat) + .set(stat.recordCount, stat.recordCount.add(1)) + .where(stat.id.eq(existing.getId())) + .execute(); + } else { + // 없으면 새로 insert + UserMonthlyStat newStat = UserMonthlyStat.builder() + .user(User.builder().id(userId).build()) // FK만 세팅 + .year(year) + .month(month) + .recordCount(1) + .build(); + + queryFactory.insert(stat) + .columns(stat.user, stat.year, stat.month, stat.recordCount) + .values(newStat.getUser(), newStat.getYear(), newStat.getMonth(), newStat.getRecordCount()) + .execute(); + } + } +} diff --git a/src/main/java/org/example/studylog/service/RankingService.java b/src/main/java/org/example/studylog/service/RankingService.java new file mode 100644 index 0000000..c9595c5 --- /dev/null +++ b/src/main/java/org/example/studylog/service/RankingService.java @@ -0,0 +1,49 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import org.example.studylog.dto.RankingResponseDTO; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.FriendRepository; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.repository.custom.RankingRepositoryImpl; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RankingService { + + private final FriendRepository friendRepository; + private final RankingRepositoryImpl rankingRepository; + private final UserRepository userRepository; + + public List getFriendRankings( + String oauthId, + Integer year, + Integer month) { + LocalDate now = LocalDate.now(); + int targetYear = (year != null) ? year : now.getYear(); + int targetMonth = (month != null) ? month : now.getMonthValue(); + + // 현재 유저 DB에서 조회 + User currentUser = userRepository.findByOauthId(oauthId); + + // 친구 ID 목록에 현재 유저 ID 포함 + List userIds = new ArrayList<>(friendRepository.findFriendIdsByUserId(currentUser.getId())); + userIds.add(currentUser.getId()); + + List rankings = rankingRepository.findFriendRankings(targetYear, targetMonth, userIds); + + // isMe 필드 추가 + rankings.forEach(r -> { + if (r.getId().equals(currentUser.getId())) { + r.setMe(true); + } + }); + + return rankings; + } +} diff --git a/src/main/java/org/example/studylog/service/StudyRecordService.java b/src/main/java/org/example/studylog/service/StudyRecordService.java index a2900e9..673def1 100644 --- a/src/main/java/org/example/studylog/service/StudyRecordService.java +++ b/src/main/java/org/example/studylog/service/StudyRecordService.java @@ -10,6 +10,7 @@ import org.example.studylog.entity.StudyRecord; import org.example.studylog.entity.Streak; import org.example.studylog.entity.user.User; +import org.example.studylog.event.RecordCreatedEvent; import org.example.studylog.event.RecordEvent; import org.example.studylog.repository.CategoryRepository; import org.example.studylog.repository.StudyRecordRepository; @@ -113,6 +114,8 @@ public CreateStudyRecordResponseDTO createStudyRecord(User user, CreateStudyReco log.info("기록 생성 이벤트 발행: USER={}, ID={}", user.getOauthId(), savedStudyRecord.getId()); user.incrementRecordCount(); eventPublisher.publishEvent(new RecordEvent(user)); + LocalDate now = LocalDate.now(); + eventPublisher.publishEvent(new RecordCreatedEvent(user.getId(), now.getYear(), now.getMonthValue())); log.info("기록 생성 이벤트 종료: USER={}, ID={}", user.getOauthId(), savedStudyRecord.getId()); // 4. 응답 DTO 생성 diff --git a/src/main/resources/db/migration/V2__backfill_user_monthly_stat_from_existing_records.sql b/src/main/resources/db/migration/V2__backfill_user_monthly_stat_from_existing_records.sql new file mode 100644 index 0000000..49a01bc --- /dev/null +++ b/src/main/resources/db/migration/V2__backfill_user_monthly_stat_from_existing_records.sql @@ -0,0 +1,10 @@ +INSERT INTO user_monthly_stat (user_id, year, month, record_count) +SELECT + r.user_id, + EXTRACT(YEAR FROM r.create_date)::INT AS year, + EXTRACT(MONTH FROM r.create_date)::INT AS month, + COUNT(*) AS record_count +FROM study_record r +GROUP BY r.user_id, year, month +ON CONFLICT (user_id, year, month) + DO UPDATE SET record_count = EXCLUDED.record_count;