Skip to content

Commit 28d0449

Browse files
KoSeonJeclaude
andauthored
feat: 총동연 월별 1위 동아리 목록 조회 API 구현 (#385)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43aa2f3 commit 28d0449

File tree

14 files changed

+642
-11
lines changed

14 files changed

+642
-11
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package ddingdong.ddingdongBE.domain.feed.api;
2+
3+
import ddingdong.ddingdongBE.domain.feed.controller.dto.response.AdminFeedRankingWinnerResponse;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import io.swagger.v3.oas.annotations.media.ArraySchema;
6+
import io.swagger.v3.oas.annotations.media.Content;
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
9+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
11+
import jakarta.validation.constraints.Max;
12+
import jakarta.validation.constraints.Min;
13+
import java.util.List;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.web.bind.annotation.GetMapping;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RequestParam;
18+
import org.springframework.web.bind.annotation.ResponseStatus;
19+
20+
@Tag(name = "Feed - Admin", description = "Feed Admin API")
21+
@RequestMapping("/server/admin/feeds")
22+
public interface AdminFeedApi {
23+
24+
@Operation(summary = "총동연 월별 1위 동아리 목록 조회 API")
25+
@ApiResponse(responseCode = "200", description = "월별 1위 동아리 목록 조회 성공",
26+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = AdminFeedRankingWinnerResponse.class))))
27+
@ResponseStatus(HttpStatus.OK)
28+
@SecurityRequirement(name = "AccessToken")
29+
@GetMapping("/ranking/last")
30+
List<AdminFeedRankingWinnerResponse> getMonthlyWinners(
31+
@RequestParam("year") @Min(value = 2000, message = "year는 2000 이상이어야 합니다.") @Max(value = 2100, message = "year는 2100 이하여야 합니다.") int year
32+
);
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package ddingdong.ddingdongBE.domain.feed.controller;
2+
3+
import ddingdong.ddingdongBE.domain.feed.api.AdminFeedApi;
4+
import ddingdong.ddingdongBE.domain.feed.controller.dto.response.AdminFeedRankingWinnerResponse;
5+
import ddingdong.ddingdongBE.domain.feed.service.FeedRankingService;
6+
import java.util.List;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.validation.annotation.Validated;
9+
import org.springframework.web.bind.annotation.RestController;
10+
11+
@RestController
12+
@RequiredArgsConstructor
13+
@Validated
14+
public class AdminFeedController implements AdminFeedApi {
15+
16+
private final FeedRankingService feedRankingService;
17+
18+
@Override
19+
public List<AdminFeedRankingWinnerResponse> getMonthlyWinners(int year) {
20+
return feedRankingService.getMonthlyWinners(year).stream()
21+
.map(AdminFeedRankingWinnerResponse::from)
22+
.toList();
23+
}
24+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package ddingdong.ddingdongBE.domain.feed.controller.dto.response;
2+
3+
import ddingdong.ddingdongBE.domain.feed.service.dto.query.FeedRankingWinnerQuery;
4+
import lombok.Builder;
5+
6+
@Builder
7+
public record AdminFeedRankingWinnerResponse(
8+
String clubName,
9+
long feedCount,
10+
long viewCount,
11+
long likeCount,
12+
long commentCount,
13+
long score,
14+
int targetYear,
15+
int targetMonth
16+
) {
17+
18+
public static AdminFeedRankingWinnerResponse from(FeedRankingWinnerQuery query) {
19+
return AdminFeedRankingWinnerResponse.builder()
20+
.clubName(query.clubName())
21+
.feedCount(query.feedCount())
22+
.viewCount(query.viewCount())
23+
.likeCount(query.likeCount())
24+
.commentCount(query.commentCount())
25+
.score(query.score())
26+
.targetYear(query.targetYear())
27+
.targetMonth(query.targetMonth())
28+
.build();
29+
}
30+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package ddingdong.ddingdongBE.domain.feed.repository;
22

33
import ddingdong.ddingdongBE.domain.feed.entity.FeedMonthlyRanking;
4+
import java.util.List;
45
import org.springframework.data.jpa.repository.JpaRepository;
56

67
public interface FeedMonthlyRankingRepository extends JpaRepository<FeedMonthlyRanking, Long> {
78

89
boolean existsByTargetYearAndTargetMonth(int targetYear, int targetMonth);
10+
11+
List<FeedMonthlyRanking> findByTargetYearAndRankingOrderByTargetMonthAsc(int targetYear, int ranking);
912
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package ddingdong.ddingdongBE.domain.feed.service;
2+
3+
import ddingdong.ddingdongBE.domain.feed.service.dto.query.FeedRankingWinnerQuery;
4+
import java.util.List;
5+
6+
public interface FeedRankingService {
7+
8+
List<FeedRankingWinnerQuery> getMonthlyWinners(int year);
9+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package ddingdong.ddingdongBE.domain.feed.service;
2+
3+
import ddingdong.ddingdongBE.domain.feed.entity.FeedMonthlyRanking;
4+
import ddingdong.ddingdongBE.domain.feed.repository.FeedMonthlyRankingRepository;
5+
import ddingdong.ddingdongBE.domain.feed.service.dto.query.FeedRankingWinnerQuery;
6+
import java.util.List;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
@Service
12+
@RequiredArgsConstructor
13+
@Transactional(readOnly = true)
14+
public class GeneralFeedRankingService implements FeedRankingService {
15+
16+
private static final int WINNER_RANKING = 1;
17+
18+
private final FeedMonthlyRankingRepository feedMonthlyRankingRepository;
19+
20+
@Override
21+
public List<FeedRankingWinnerQuery> getMonthlyWinners(int year) {
22+
List<FeedMonthlyRanking> rankings =
23+
feedMonthlyRankingRepository.findByTargetYearAndRankingOrderByTargetMonthAsc(year, WINNER_RANKING);
24+
25+
return rankings.stream()
26+
.map(FeedRankingWinnerQuery::from)
27+
.toList();
28+
}
29+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package ddingdong.ddingdongBE.domain.feed.service.dto.query;
2+
3+
import ddingdong.ddingdongBE.domain.feed.entity.FeedMonthlyRanking;
4+
import lombok.Builder;
5+
6+
@Builder
7+
public record FeedRankingWinnerQuery(
8+
String clubName,
9+
long feedCount,
10+
long viewCount,
11+
long likeCount,
12+
long commentCount,
13+
long score,
14+
int targetYear,
15+
int targetMonth
16+
) {
17+
18+
public static FeedRankingWinnerQuery from(FeedMonthlyRanking entity) {
19+
return FeedRankingWinnerQuery.builder()
20+
.clubName(entity.getClubName())
21+
.feedCount(entity.getFeedCount())
22+
.viewCount(entity.getViewCount())
23+
.likeCount(entity.getLikeCount())
24+
.commentCount(entity.getCommentCount())
25+
.score(entity.getScore())
26+
.targetYear(entity.getTargetYear())
27+
.targetMonth(entity.getTargetMonth())
28+
.build();
29+
}
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package ddingdong.ddingdongBE.common.fixture;
2+
3+
import ddingdong.ddingdongBE.domain.feed.entity.FeedMonthlyRanking;
4+
5+
public class FeedMonthlyRankingFixture {
6+
7+
public static FeedMonthlyRanking create(Long clubId, String clubName,
8+
long feedCount, long viewCount, long likeCount, long commentCount,
9+
int targetYear, int targetMonth, int ranking) {
10+
FeedMonthlyRanking entity = FeedMonthlyRanking.builder()
11+
.clubId(clubId)
12+
.clubName(clubName)
13+
.feedCount(feedCount)
14+
.viewCount(viewCount)
15+
.likeCount(likeCount)
16+
.commentCount(commentCount)
17+
.targetYear(targetYear)
18+
.targetMonth(targetMonth)
19+
.build();
20+
entity.assignRanking(ranking);
21+
return entity;
22+
}
23+
24+
public static FeedMonthlyRanking createWinner(Long clubId, String clubName,
25+
int targetYear, int targetMonth) {
26+
return create(clubId, clubName, 10, 100, 50, 20, targetYear, targetMonth, 1);
27+
}
28+
29+
public static FeedMonthlyRanking createWithRanking(Long clubId, String clubName,
30+
int targetYear, int targetMonth, int ranking) {
31+
return create(clubId, clubName, 5, 50, 25, 10, targetYear, targetMonth, ranking);
32+
}
33+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package ddingdong.ddingdongBE.domain.feed.controller;
2+
3+
import static io.restassured.RestAssured.given;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
6+
import ddingdong.ddingdongBE.auth.controller.dto.request.SignInRequest;
7+
import ddingdong.ddingdongBE.auth.controller.dto.response.SignInResponse;
8+
import ddingdong.ddingdongBE.common.fixture.FeedMonthlyRankingFixture;
9+
import ddingdong.ddingdongBE.common.fixture.UserFixture;
10+
import ddingdong.ddingdongBE.common.support.NonTxTestContainerSupport;
11+
import ddingdong.ddingdongBE.domain.feed.controller.dto.response.AdminFeedRankingWinnerResponse;
12+
import ddingdong.ddingdongBE.domain.feed.repository.FeedMonthlyRankingRepository;
13+
import ddingdong.ddingdongBE.domain.user.entity.User;
14+
import ddingdong.ddingdongBE.domain.user.repository.UserRepository;
15+
import io.restassured.RestAssured;
16+
import io.restassured.http.ContentType;
17+
import java.util.List;
18+
import org.junit.jupiter.api.BeforeEach;
19+
import org.junit.jupiter.api.DisplayName;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.beans.factory.annotation.Autowired;
22+
import org.springframework.boot.test.context.SpringBootTest;
23+
import org.springframework.boot.test.web.server.LocalServerPort;
24+
import org.springframework.security.crypto.password.PasswordEncoder;
25+
import org.springframework.transaction.annotation.Propagation;
26+
import org.springframework.transaction.annotation.Transactional;
27+
28+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
29+
class AdminFeedControllerE2ETest extends NonTxTestContainerSupport {
30+
31+
@LocalServerPort
32+
private int port;
33+
34+
@Autowired
35+
private UserRepository userRepository;
36+
37+
@Autowired
38+
private FeedMonthlyRankingRepository feedMonthlyRankingRepository;
39+
40+
@Autowired
41+
private PasswordEncoder passwordEncoder;
42+
43+
private String adminToken;
44+
45+
@BeforeEach
46+
@Transactional(propagation = Propagation.REQUIRES_NEW)
47+
void setUp() {
48+
RestAssured.port = port;
49+
50+
User admin = userRepository.save(
51+
UserFixture.createAdminUser(passwordEncoder.encode("1234")));
52+
53+
feedMonthlyRankingRepository.saveAll(List.of(
54+
FeedMonthlyRankingFixture.createWinner(1L, "1월 우승 동아리", 2025, 1),
55+
FeedMonthlyRankingFixture.createWinner(2L, "2월 우승 동아리", 2025, 2),
56+
FeedMonthlyRankingFixture.createWinner(3L, "3월 우승 동아리", 2025, 3)
57+
));
58+
59+
adminToken = getAuthToken(admin.getAuthId(), "1234");
60+
}
61+
62+
@DisplayName("총동연 월별 1위 동아리 목록 조회 API - 성공")
63+
@Test
64+
void getMonthlyWinners_success() {
65+
List<AdminFeedRankingWinnerResponse> response = given()
66+
.contentType(ContentType.JSON)
67+
.header("Authorization", "Bearer " + adminToken)
68+
.queryParam("year", 2025)
69+
.when()
70+
.get("/server/admin/feeds/ranking/last")
71+
.then()
72+
.statusCode(200)
73+
.extract()
74+
.jsonPath()
75+
.getList(".", AdminFeedRankingWinnerResponse.class);
76+
77+
assertThat(response).hasSize(3);
78+
assertThat(response.get(0).clubName()).isEqualTo("1월 우승 동아리");
79+
assertThat(response.get(0).targetMonth()).isEqualTo(1);
80+
assertThat(response.get(2).clubName()).isEqualTo("3월 우승 동아리");
81+
assertThat(response.get(2).targetMonth()).isEqualTo(3);
82+
}
83+
84+
private String getAuthToken(String authId, String password) {
85+
SignInResponse signInResponse = given()
86+
.contentType(ContentType.JSON)
87+
.body(new SignInRequest(authId, password))
88+
.when()
89+
.post("/server/auth/sign-in")
90+
.then()
91+
.statusCode(200)
92+
.extract()
93+
.as(SignInResponse.class);
94+
return signInResponse.getToken();
95+
}
96+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package ddingdong.ddingdongBE.domain.feed.controller.dto.response;
2+
3+
import static org.assertj.core.api.SoftAssertions.assertSoftly;
4+
5+
import ddingdong.ddingdongBE.domain.feed.service.dto.query.FeedRankingWinnerQuery;
6+
import org.junit.jupiter.api.DisplayName;
7+
import org.junit.jupiter.api.Test;
8+
9+
class AdminFeedRankingWinnerResponseTest {
10+
11+
@DisplayName("from - FeedRankingWinnerQuery의 모든 필드를 올바르게 매핑한다")
12+
@Test
13+
void from_mapsAllFields() {
14+
// given
15+
FeedRankingWinnerQuery query = FeedRankingWinnerQuery.builder()
16+
.clubName("테스트 동아리")
17+
.feedCount(10)
18+
.viewCount(100)
19+
.likeCount(50)
20+
.commentCount(20)
21+
.score(450)
22+
.targetYear(2025)
23+
.targetMonth(3)
24+
.build();
25+
26+
// when
27+
AdminFeedRankingWinnerResponse response = AdminFeedRankingWinnerResponse.from(query);
28+
29+
// then
30+
assertSoftly(softly -> {
31+
softly.assertThat(response.clubName()).isEqualTo("테스트 동아리");
32+
softly.assertThat(response.feedCount()).isEqualTo(10);
33+
softly.assertThat(response.viewCount()).isEqualTo(100);
34+
softly.assertThat(response.likeCount()).isEqualTo(50);
35+
softly.assertThat(response.commentCount()).isEqualTo(20);
36+
softly.assertThat(response.score()).isEqualTo(450);
37+
softly.assertThat(response.targetYear()).isEqualTo(2025);
38+
softly.assertThat(response.targetMonth()).isEqualTo(3);
39+
});
40+
}
41+
}

0 commit comments

Comments
 (0)