Skip to content

feat(ranking): Phase 5 개인/학교/우리학교 랭킹 API#5

Open
ca5tlechan wants to merge 1 commit into
mainfrom
feat/phase-5-ranking
Open

feat(ranking): Phase 5 개인/학교/우리학교 랭킹 API#5
ca5tlechan wants to merge 1 commit into
mainfrom
feat/phase-5-ranking

Conversation

@ca5tlechan

@ca5tlechan ca5tlechan commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Phase 5 — 개인/학교/우리학교 랭킹 API

CLAUDE.md §7 Phase 5 / §5.2·§5.3. 여기서 처음으로 랭킹 캡·이름 마스킹·동점 처리가 적용된다(마이페이지=실제시간과 대비).

엔드포인트 (전부 인증 필요, period 기본 this_week, 잘못된 값 400)

  • GET /api/rankings/individual?period=this_week|last_week|this_month|last_month|this_year — 포디움(13)/리스트(410)/내 순위
  • GET /api/rankings/school?period=... — 학교 평균 + 활동 인원수(최소 5명 미달 제외)
  • GET /api/rankings/school/mine?period=... — 우리 학교 내부 개인 랭킹(무소속이면 available:false)

핵심 규칙

  • 랭킹 캡(§3.6e): 하루 16h 캡 + (주간 period만) 주 84h 캡. StudyTimeAggregator.rankingTotalSeconds 재사용. (마이페이지는 캡 미적용이었음.)
  • 이름 마스킹(§3.3): 김O현(학교축약명), 무소속 (무소속), 동명이인 name_seq>1 접미 숫자. NameMasker(순수) + 단위테스트.
  • 개인 동점(§3.5): 합계 같으면 기간 내 마지막 체크아웃 이른 쪽 상위. 내 순위는 top10 밖이어도 항상 표시, 기록 없으면 myRank:null.
  • 학교(§3.4/§3.5): 평균 = 활동 인원(기간 기록 있는 학생) 합계/인원수, 최소 5명 필터, 동점 = 인원수 desc → 학교명 asc. 무소속 제외.
  • 기간(§3.2): 스터디 날짜(04:00) 범위로 환산(RankingPeriod).

구현 메모

  • 파일럿 규모라 per-user 집계를 가져와 Java에서 캡·정렬·마스킹·내순위 계산. N+1 회피 프로젝션 쿼리(userId/seconds, userId/lastCheckout, users fetch join).
  • period 파라미터는 Converter<String,RankingPeriod> → 잘못된 값이면 MethodArgumentTypeMismatchException → 400 ApiError.

검증

  • 단위/통합(H2): 마스킹(2/3/4/1자·무소속·접미·폴백), 개인(캡·정렬·내순위·동점), 학교(평균·최소인원·무소속제외), 우리학교(무소속 unavailable·필터), 컨트롤러 MockMvc(401·period 400·기본 this_week).
  • 실 Postgres: 개인 김O현(서울대) 57600(23h→16h캡) myRank 1, 학교 서울대 · 5명 · 평균 14400, 우리학교 필터, period=nope → 400.

범위 밖(다음)

프론트(Phase 6~9) 또는 Phase 10(04:00 자동마감·경고·Web Push 배치). 인사이트 텍스트·데이터 부족 처리는 이후.

Summary by CodeRabbit

  • 새 기능

    • 개인 및 학교 랭킹 조회 기능을 추가했습니다.
    • 주간·월간·연간 등 다양한 기간별 랭킹을 확인할 수 있습니다.
    • 내 학교 구성원만의 개인 랭킹을 조회할 수 있습니다.
    • 랭킹 표시 이름은 개인정보 보호를 위해 일부 마스킹됩니다.
    • 학교가 없거나 참가 인원이 부족한 경우 해당 상태를 안내합니다.
  • 개선

    • 개인·학교 랭킹을 상위권과 일반 목록으로 구분해 제공합니다.
    • 동점자 및 학교별 평균 활동 시간에 따른 정렬 기준을 적용했습니다.

- GET /api/rankings/individual|school|school/mine?period=... (기본 this_week)
- 랭킹 캡 적용(하루16h/주84h §3.6e) — 마이페이지 실제시간과 대비, StudyTimeAggregator 재사용
- 이름 마스킹 김O현(학교축약명)/무소속/동명이인 접미 (NameMasker)
- 개인: 포디움/리스트/내순위(top10밖도, 기록없으면 null), 동점=마지막체크아웃 이른순
- 학교: 활동인원 평균, 최소 5명 필터, 인원수, 동점=인원desc→학교명asc, 무소속 제외
- 우리학교: 내 school_id 필터, 무소속이면 available=false
- period converter→400, N+1 방지 프로젝션 쿼리
- 테스트: NameMasker/RankingService/컨트롤러 MockMvc

실 Postgres 검증: 23h→16h캡·마스킹·내순위, 학교평균 14400(5명), 우리학교 필터, period 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

개인·학교 랭킹 조회 기능을 추가했다. 기간별 공부 시간과 체크아웃 정보를 집계하고, 이름·학교 정보를 포함한 응답을 구성하며, 인증 기반 REST 엔드포인트와 입력 변환 및 통합 테스트를 제공한다.

Changes

랭킹 기능

Layer / File(s) Summary
랭킹 계약과 표시 규칙
backend/src/main/java/com/studycafe/ranking/ranking/RankingPeriod.java, backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java, backend/src/main/java/com/studycafe/ranking/ranking/dto/*, backend/src/test/java/com/studycafe/ranking/ranking/NameMaskerTest.java
랭킹 기간 계산 enum과 개인·학교 응답 DTO를 추가하고, 이름 마스킹 및 학교 라벨 생성 규칙을 구현·검증한다.
랭킹 집계와 저장소 조회
backend/src/main/java/com/studycafe/ranking/repository/*, backend/src/main/java/com/studycafe/ranking/ranking/RankingService.java, backend/src/test/java/com/studycafe/ranking/ranking/RankingServiceTest.java
기간별 사용자 공부 시간과 마지막 체크아웃을 조회해 캡 적용 개인 랭킹, 최소 인원 조건이 적용된 학교 랭킹, 내 학교 랭킹을 계산한다.
랭킹 API와 입력 변환
backend/src/main/java/com/studycafe/ranking/ranking/RankingController.java, backend/src/main/java/com/studycafe/ranking/ranking/StringToRankingPeriodConverter.java, backend/src/test/java/com/studycafe/ranking/ranking/RankingControllerMockMvcTest.java
인증 사용자 기반 개인·학교 랭킹 엔드포인트와 period 문자열 변환을 추가하고 인증, 기본값, 잘못된 입력, 무소속 응답을 검증한다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RankingController
  participant RankingService
  participant DailyStudyRecordRepository
  participant CheckInSessionRepository
  Client->>RankingController: 랭킹 API 요청과 period 전달
  RankingController->>RankingService: 개인·학교 랭킹 조회 호출
  RankingService->>DailyStudyRecordRepository: 기간별 사용자 공부 시간 조회
  RankingService->>CheckInSessionRepository: 사용자별 마지막 체크아웃 조회
  RankingService-->>RankingController: 랭킹 응답 DTO 반환
  RankingController-->>Client: JSON 응답 반환
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 랭킹 개인/학교/우리학교 API 추가라는 변경 사항을 잘 요약한 관련성 높은 제목입니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/phase-5-ranking

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java`:
- Around line 30-33: Update the schoolPart selection in NameMasker to treat a
blank School.getShortName() the same as null, falling back to School.getName()
so masked names never include empty parentheses.

In `@backend/src/main/java/com/studycafe/ranking/ranking/RankingPeriod.java`:
- Around line 12-46: RankingPeriod의 start()와 end() 경계 계산을 전용 RankingPeriodTest로
검증하세요. THIS_WEEK, LAST_WEEK, THIS_MONTH, LAST_MONTH, THIS_YEAR를 포함하고 연초·연말·월말 및
주 경계 날짜를 고정된 LocalDate로 테스트해 각 기간의 시작일과 종료일이 정확한지 확인하세요. 기존 RankingServiceTest에만
의존하지 말고 04:00 스터디 날짜 분할 규칙과 관련된 경계 회귀를 직접 검증하세요.

In `@backend/src/test/java/com/studycafe/ranking/ranking/RankingServiceTest.java`:
- Around line 73-89: RankingServiceTest lacks coverage for individual ranking
pagination and the weekly 84-hour cap. Add tests targeting
RankingService.buildIndividual or the existing individual ranking flow that
create at least 11 users and verify only the top 3 podium entries plus ranks
4–10 are exposed, then add a THIS_WEEK case verifying accumulated time is capped
at 84 hours while preserving ordering and displayed values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 79c2dc44-fd34-49d8-8318-e36e1e3a69f1

📥 Commits

Reviewing files that changed from the base of the PR and between e56b613 and f0b3d76.

📒 Files selected for processing (16)
  • backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java
  • backend/src/main/java/com/studycafe/ranking/ranking/RankingController.java
  • backend/src/main/java/com/studycafe/ranking/ranking/RankingPeriod.java
  • backend/src/main/java/com/studycafe/ranking/ranking/RankingService.java
  • backend/src/main/java/com/studycafe/ranking/ranking/StringToRankingPeriodConverter.java
  • backend/src/main/java/com/studycafe/ranking/ranking/dto/IndividualRankingResponse.java
  • backend/src/main/java/com/studycafe/ranking/ranking/dto/RankEntry.java
  • backend/src/main/java/com/studycafe/ranking/ranking/dto/SchoolEntry.java
  • backend/src/main/java/com/studycafe/ranking/ranking/dto/SchoolMineResponse.java
  • backend/src/main/java/com/studycafe/ranking/ranking/dto/SchoolRankingResponse.java
  • backend/src/main/java/com/studycafe/ranking/repository/CheckInSessionRepository.java
  • backend/src/main/java/com/studycafe/ranking/repository/DailyStudyRecordRepository.java
  • backend/src/main/java/com/studycafe/ranking/repository/UserRepository.java
  • backend/src/test/java/com/studycafe/ranking/ranking/NameMaskerTest.java
  • backend/src/test/java/com/studycafe/ranking/ranking/RankingControllerMockMvcTest.java
  • backend/src/test/java/com/studycafe/ranking/ranking/RankingServiceTest.java

Comment on lines +30 to +33
School school = user.getSchool();
String schoolPart = (school != null)
? (school.getShortName() != null ? school.getShortName() : school.getName())
: "무소속";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and inspect its structure and nearby code.
git ls-files 'backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java'
wc -l backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java
cat -n backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java

Repository: ca5tlechan/studycafe-ranking

Length of output: 1728


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target utility and its callers to understand whether blank short names are possible and observable.
git ls-files 'backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java' \
  'backend/src/main/java/**/School*.java' \
  'backend/src/main/java/**/User*.java' \
  'backend/src/main/java/**/Ranking*.java' \
  'backend/src/main/java/**/NameMasker*.java'

printf '\n== NameMasker ==\n'
cat -n backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java

printf '\n== References ==\n'
rg -n "shortName|schoolPart|NameMasker|mask" backend/src/main/java -g'*.java'

Repository: ca5tlechan/studycafe-ranking

Length of output: 4931


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for School.shortName declarations, validation, and normalization.
rg -n --hidden --glob '*.java' --glob '*.kt' \
  'shortName|setShortName|`@NotBlank`|`@NotEmpty`|`@Size`|trim\(|blank|normalize' backend/src/main/java

printf '\n== School-related files ==\n'
fd -i 'School*.java' backend/src/main/java

printf '\n== NameMasker and ranking callers ==\n'
rg -n --hidden --glob '*.java' 'NameMasker|schoolPart|무소속' backend/src/main/java

Repository: ca5tlechan/studycafe-ranking

Length of output: 4259


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the school model and any request/response code that populates shortName.
printf '\n== School.java ==\n'
cat -n backend/src/main/java/com/studycafe/ranking/domain/School.java

printf '\n== SchoolController ==\n'
cat -n backend/src/main/java/com/studycafe/ranking/school/SchoolController.java

printf '\n== SchoolResponse ==\n'
cat -n backend/src/main/java/com/studycafe/ranking/school/dto/SchoolResponse.java

printf '\n== School-related DTOs and services ==\n'
rg -n --hidden --glob '*.java' 'shortName|new School\(|setShortName|SchoolResponse|SchoolController' backend/src/main/java/com/studycafe/ranking

Repository: ca5tlechan/studycafe-ranking

Length of output: 6200


shortName이 빈 문자열이면 name으로 폴백하게 바꾸자. School.shortName은 nullable이고 blank 제약도 없어서 null만 체크하면 김O현()처럼 괄호만 남는 표기가 나올 수 있습니다. isBlank()까지 확인해 school.getName()으로 대체하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/main/java/com/studycafe/ranking/ranking/NameMasker.java` around
lines 30 - 33, Update the schoolPart selection in NameMasker to treat a blank
School.getShortName() the same as null, falling back to School.getName() so
masked names never include empty parentheses.

Comment on lines +12 to +46
public enum RankingPeriod {
THIS_WEEK,
LAST_WEEK,
THIS_MONTH,
LAST_MONTH,
THIS_YEAR;

public boolean isWeekly() {
return this == THIS_WEEK || this == LAST_WEEK;
}

public LocalDate start(LocalDate today) {
return switch (this) {
case THIS_WEEK -> monday(today);
case LAST_WEEK -> monday(today).minusWeeks(1);
case THIS_MONTH -> today.withDayOfMonth(1);
case LAST_MONTH -> YearMonth.from(today).minusMonths(1).atDay(1);
case THIS_YEAR -> today.withDayOfYear(1);
};
}

public LocalDate end(LocalDate today) {
return switch (this) {
case THIS_WEEK -> monday(today).plusDays(6);
case LAST_WEEK -> monday(today).minusWeeks(1).plusDays(6);
case THIS_MONTH -> YearMonth.from(today).atEndOfMonth();
case LAST_MONTH -> YearMonth.from(today).minusMonths(1).atEndOfMonth();
case THIS_YEAR -> today.withDayOfYear(1).plusYears(1).minusDays(1);
};
}

private static LocalDate monday(LocalDate date) {
return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i rankingperiodtest --type f

Repository: ca5tlechan/studycafe-ranking

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files matching RankingPeriod or RankingService tests =="
git ls-files | rg 'RankingPeriod|RankingServiceTest|RankingPeriodTest|ranking.*Test|Test\.java$'

echo
echo "== ranking-related test usages =="
rg -n "THIS_YEAR|THIS_MONTH|LAST_MONTH|THIS_WEEK|LAST_WEEK|RankingPeriod|04:00|스터디 날짜|84h|10분" backend/src/test/java backend/src/main/java -S

Repository: ca5tlechan/studycafe-ranking

Length of output: 15445


RankingPeriod 경계값 테스트를 보강하세요.
start()/end() 계산은 맞지만, 현재 RankingPeriodTest가 없고 RankingServiceTestTHIS_YEAR만 사용합니다. LAST_WEEK/THIS_MONTH/LAST_MONTH 포함해서 연초·연말·월말 경계 케이스를 전용 테스트로 추가하면 04:00 스터디 날짜 분할 규칙 회귀를 더 잘 막을 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/main/java/com/studycafe/ranking/ranking/RankingPeriod.java`
around lines 12 - 46, RankingPeriod의 start()와 end() 경계 계산을 전용 RankingPeriodTest로
검증하세요. THIS_WEEK, LAST_WEEK, THIS_MONTH, LAST_MONTH, THIS_YEAR를 포함하고 연초·연말·월말 및
주 경계 날짜를 고정된 LocalDate로 테스트해 각 기간의 시작일과 종료일이 정확한지 확인하세요. 기존 RankingServiceTest에만
의존하지 말고 04:00 스터디 날짜 분할 규칙과 관련된 경계 회귀를 직접 검증하세요.

Source: Path instructions

Comment on lines +73 to +89
@Test
@DisplayName("개인: 하루 16h 캡 + 정렬 + 마스킹 + 내 순위")
void individual_cap_order_mask() {
User a = user("a", "김민현", 1, schoolA);
User b = user("b", "이철수", 1, null);
record(a, 23 * H); // 23h → 16h 캡
record(b, 10 * H);

IndividualRankingResponse r = rankingService.individual(a.getId(), RankingPeriod.THIS_YEAR);

assertEquals(2, r.podium().size());
assertEquals("김O현(에이대)", r.podium().get(0).displayName());
assertEquals(16 * H, r.podium().get(0).seconds()); // 캡 적용
assertTrue(r.podium().get(0).isMe());
assertEquals("이O수(무소속)", r.podium().get(1).displayName());
assertEquals(1, r.myRank().rank());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

10명 초과 시 podium/list 분할, 주 84h 캡 테스트 보강 권장.

현재 테스트는 THIS_YEAR만 사용해 하루 16h 캡·마스킹·동점 tie-break·최소인원 필터는 잘 검증하지만, RankingService.buildIndividualPODIUM=3/LIST_MAX=10 슬라이싱(11명 이상일 때 4~10위만 노출되는지)과 THIS_WEEK 경로에서의 주 84h 캡 적용은 이 파일에서 검증되지 않습니다. 랭킹 캡/표시 규칙이 CLAUDE.md 도메인 규칙의 핵심이므로 회귀 방지를 위해 테스트 추가를 권장합니다.

As per path instructions, 하루 16h/주 84h 랭킹 캡 로직의 정확성을 특히 꼼꼼히 검증해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/test/java/com/studycafe/ranking/ranking/RankingServiceTest.java`
around lines 73 - 89, RankingServiceTest lacks coverage for individual ranking
pagination and the weekly 84-hour cap. Add tests targeting
RankingService.buildIndividual or the existing individual ranking flow that
create at least 11 users and verify only the top 3 podium entries plus ranks
4–10 are exposed, then add a THIS_WEEK case verifying accumulated time is capped
at 84 hours while preserving ordering and displayed values.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant