Skip to content

Commit 1504113

Browse files
seongwon030claude
andcommitted
feat: 동아리 클릭 게임 API 추가 (POST /api/game/click, GET /api/game/ranking)
Redis INCR을 활용한 원자적 클릭 수 누적, KST 자정 자동 초기화(ShedLock), 클릭 랭킹 조회 시 rank 필드 및 resetAt(다음 자정 ISO8601) 제공 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6f14800 commit 1504113

5 files changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package moadong.club.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
5+
import lombok.RequiredArgsConstructor;
6+
import moadong.club.payload.request.ClubClickRequest;
7+
import moadong.club.payload.response.ClubClickRankingResponse;
8+
import moadong.club.payload.response.ClubClickResponse;
9+
import moadong.club.service.ClubClickService;
10+
import moadong.global.payload.Response;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.GetMapping;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RequestMapping;
16+
import org.springframework.web.bind.annotation.RestController;
17+
18+
@RestController
19+
@RequestMapping("/api/game")
20+
@RequiredArgsConstructor
21+
@Tag(name = "Game", description = "동아리 클릭 게임 통계")
22+
public class ClubClickController {
23+
24+
private final ClubClickService clubClickService;
25+
26+
@PostMapping("/click")
27+
@Operation(summary = "동아리 클릭 기록", description = "자유 입력 clubName별 클릭 수를 누적합니다. 실제 동아리 DB와 무관합니다.")
28+
public ResponseEntity<?> recordClick(@RequestBody ClubClickRequest request) {
29+
ClubClickResponse result = clubClickService.recordClick(request.clubName());
30+
return Response.ok(result);
31+
}
32+
33+
@GetMapping("/ranking")
34+
@Operation(summary = "동아리 클릭 랭킹 조회",
35+
description = "클릭 수 기준 내림차순 랭킹을 반환합니다. resetAt은 다음 자정(KST) 시간입니다.")
36+
public ResponseEntity<?> getRanking() {
37+
ClubClickRankingResponse result = clubClickService.getRanking();
38+
return Response.ok(result);
39+
}
40+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package moadong.club.payload.request;
2+
3+
public record ClubClickRequest(String clubName, String ctAt) {
4+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package moadong.club.payload.response;
2+
3+
import java.util.List;
4+
5+
public record ClubClickRankingResponse(List<ClubRankItem> clubs, String resetAt) {
6+
7+
public record ClubRankItem(int rank, String clubName, long clickCount) {
8+
}
9+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package moadong.club.payload.response;
2+
3+
public record ClubClickResponse(String clubName, long clickCount) {
4+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package moadong.club.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import moadong.club.payload.response.ClubClickRankingResponse;
6+
import moadong.club.payload.response.ClubClickRankingResponse.ClubRankItem;
7+
import moadong.club.payload.response.ClubClickResponse;
8+
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
10+
import org.springframework.data.redis.core.StringRedisTemplate;
11+
import org.springframework.scheduling.annotation.Scheduled;
12+
import org.springframework.stereotype.Service;
13+
14+
import java.time.ZoneId;
15+
import java.time.ZonedDateTime;
16+
import java.time.format.DateTimeFormatter;
17+
import java.util.ArrayList;
18+
import java.util.Comparator;
19+
import java.util.List;
20+
import java.util.Set;
21+
import java.util.concurrent.atomic.AtomicInteger;
22+
23+
@Slf4j
24+
@Service
25+
@RequiredArgsConstructor
26+
@ConditionalOnProperty(name = "scheduling.enabled", havingValue = "true", matchIfMissing = true)
27+
public class ClubClickService {
28+
29+
private static final String CLICK_KEY_PREFIX = "club:click:";
30+
private static final String CLICK_KEY_PATTERN = CLICK_KEY_PREFIX + "*";
31+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
32+
33+
private final StringRedisTemplate stringRedisTemplate;
34+
35+
public ClubClickResponse recordClick(String clubName) {
36+
String key = CLICK_KEY_PREFIX + clubName;
37+
Long clickCount = stringRedisTemplate.opsForValue().increment(key);
38+
return new ClubClickResponse(clubName, clickCount != null ? clickCount : 0L);
39+
}
40+
41+
public ClubClickRankingResponse getRanking() {
42+
Set<String> keys = stringRedisTemplate.keys(CLICK_KEY_PATTERN);
43+
List<ClubRankItem> ranked = new ArrayList<>();
44+
45+
if (keys != null && !keys.isEmpty()) {
46+
List<ClubRankItem> unsorted = new ArrayList<>();
47+
for (String key : keys) {
48+
String clubName = key.substring(CLICK_KEY_PREFIX.length());
49+
String raw = stringRedisTemplate.opsForValue().get(key);
50+
long count = raw != null ? Long.parseLong(raw) : 0L;
51+
unsorted.add(new ClubRankItem(0, clubName, count));
52+
}
53+
54+
unsorted.sort(Comparator.comparingLong(ClubRankItem::clickCount).reversed());
55+
56+
AtomicInteger rank = new AtomicInteger(1);
57+
for (ClubRankItem item : unsorted) {
58+
ranked.add(new ClubRankItem(rank.getAndIncrement(), item.clubName(), item.clickCount()));
59+
}
60+
}
61+
62+
return new ClubClickRankingResponse(ranked, nextMidnightKst());
63+
}
64+
65+
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
66+
@SchedulerLock(name = "ClubClickReset", lockAtMostFor = "1m", lockAtLeastFor = "1s")
67+
public void resetDailyClicks() {
68+
Set<String> keys = stringRedisTemplate.keys(CLICK_KEY_PATTERN);
69+
if (keys != null && !keys.isEmpty()) {
70+
stringRedisTemplate.delete(keys);
71+
}
72+
log.info("동아리 클릭 수 초기화 완료");
73+
}
74+
75+
private String nextMidnightKst() {
76+
ZonedDateTime nextMidnight = ZonedDateTime.now(KST)
77+
.toLocalDate()
78+
.plusDays(1)
79+
.atStartOfDay(KST);
80+
return nextMidnight.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
81+
}
82+
}

0 commit comments

Comments
 (0)