Skip to content
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
@@ -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<RankingResponseDTO> data = rankingService.getFriendRankings(currentUser.getName(), year, month);
return ResponseUtil.buildResponse(200, "랭킹 목록 조회 완료", data);
}

}
17 changes: 17 additions & 0 deletions src/main/java/org/example/studylog/dto/RankingResponseDTO.java
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions src/main/java/org/example/studylog/entity/UserMonthlyStat.java
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions src/main/java/org/example/studylog/event/RecordCreatedEvent.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Friend, Long> {
Expand All @@ -14,4 +16,7 @@ public interface FriendRepository extends JpaRepository<Friend, Long> {
Optional<Friend> findByUserAndFriend(User user, User friend);

long countByUser(User user);

@Query("SELECT f.friend.id FROM Friend f WHERE f.user.id = :userId")
List<Long> findFriendIdsByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.example.studylog.repository.custom;

import org.example.studylog.dto.RankingResponseDTO;

import java.util.List;

public interface RankingRepositoryCustom {
List<RankingResponseDTO> findFriendRankings(int year, int month, List<Long> userIds);
void incrementOrInsert(Long userId, int year, int month);
}
Original file line number Diff line number Diff line change
@@ -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<RankingResponseDTO> findFriendRankings(
int year,
int month,
List<Long> 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();
}
}
}
49 changes: 49 additions & 0 deletions src/main/java/org/example/studylog/service/RankingService.java
Original file line number Diff line number Diff line change
@@ -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<RankingResponseDTO> 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<Long> userIds = new ArrayList<>(friendRepository.findFriendIdsByUserId(currentUser.getId()));
userIds.add(currentUser.getId());

List<RankingResponseDTO> rankings = rankingRepository.findFriendRankings(targetYear, targetMonth, userIds);

// isMe 필드 추가
rankings.forEach(r -> {
if (r.getId().equals(currentUser.getId())) {
r.setMe(true);
}
});

return rankings;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 생성
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading