Skip to content

Commit d1354be

Browse files
soonge2yunabyte
andauthored
release: v3.2.0 (#270)
* feat: 랭킹 점수 스케줄러 구현 및 수정된 투표 추적 기능 추가 (#263) * feat: 랭킹 갱신 대상(댓글/참여 발생)인 투표를 Redis ZSet에 기록 (#262) * feat: 랭킹 점수 계산 스케줄러 구현 (#262) * feat: 스케줄링 시간 및 타임존 설정 (#262) * feat: Top3 투표 조회 API 구현 (#264) * feat: Top3 투표 응답 DTO 구현 (#261) * feat: Top3 투표 조회 서비스 로직 구현 (#261) * feat: Top3 투표 조회 컨트롤러 구현 (#261) * fix: 전체 그룹의 랭킹 투표 조회 시 공개 그룹 누락 해결 및 불필요 파라미터 제거 (#266) * fix: 랭킹 API 파라미터 type 제거 (#265) * fix: 전체 그룹 랭킹 조회 시 공개 그룹 포함되도록 수정 (#265) * cicd: eks prod CI/CD pipeline 구축 (#268) * fix: 랭킹 투표에 대해 투표 미참여자의 접근 권한 확장 (#269) * fix: 투표 내용/결과 조회 권한 확장 (#267) * fix: 댓글 작성 및 조회 권한 확장 (#267) * refactor: Top3 투표 접근 권한 검증 로직 분리 및 재사용 가능한 유틸 클래스로 이동 (#267) * chore: v3.2.0 명시 --------- Co-authored-by: yunabyte <yunabyte@gmail.com>
1 parent 3033912 commit d1354be

File tree

16 files changed

+571
-15
lines changed

16 files changed

+571
-15
lines changed

.github/workflows/cicd-docker.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ name: SpringBoot Docker CI/CD - Unified
33
on:
44
push:
55
branches:
6-
# - main
6+
# - main
77
- develop
8-
- cicd/**
98
- release/**
109

1110
jobs:
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
name: SpringBoot CI/CD with GitOps
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
build-and-deploy:
10+
runs-on: ubuntu-latest
11+
environment: prod
12+
13+
steps:
14+
- name: Checkout Backend Repository
15+
uses: actions/checkout@v3
16+
with:
17+
persist-credentials: false
18+
19+
- name: Set up JDK 21
20+
uses: actions/setup-java@v3
21+
with:
22+
distribution: temurin
23+
java-version: 21
24+
25+
- name: Make Gradle Executable
26+
run: chmod +x gradlew
27+
28+
- name: Extract Version from build.gradle
29+
id: version
30+
if: github.ref_name == 'main'
31+
run: |
32+
VERSION=$(grep '^version' build.gradle | awk -F"'" '{print $2}')
33+
echo "version=$VERSION"
34+
echo "version=$VERSION" >> $GITHUB_OUTPUT
35+
36+
- name: Create Git Tag (main only)
37+
if: github.ref_name == 'main'
38+
env:
39+
GH_PAT: ${{ secrets.GH_PAT }}
40+
run: |
41+
TAG=${{ steps.version.outputs.version }}
42+
git remote set-url origin https://x-access-token:${GH_PAT}@github.com/${{ github.repository }}
43+
git fetch --tags
44+
if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
45+
echo "✅ Tag $TAG already exists"
46+
else
47+
git config user.name "GitHub Actions"
48+
git config user.email "github-actions@users.noreply.github.com"
49+
git tag $TAG
50+
git push origin $TAG
51+
echo "✅ Created Git tag: $TAG"
52+
fi
53+
54+
- name: Build JAR (skip tests)
55+
run: ./gradlew clean build -x test
56+
57+
- name: Docker Login
58+
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
59+
60+
- name: Set Image Name and Tag
61+
id: tagger
62+
run: |
63+
REF_NAME=${{ github.ref_name }}
64+
COMMIT_SHA=${{ github.sha }}
65+
66+
# 기본값
67+
IMAGE_NAME=4moa/moa-be
68+
TAG=$(echo "$COMMIT_SHA" | cut -c1-7)
69+
70+
# main 브랜치인 경우 별도 태그 적용
71+
if [[ "$REF_NAME" == "main" ]]; then
72+
IMAGE_NAME=4moa/moa-be
73+
TAG=${{ steps.version.outputs.version }}
74+
fi
75+
76+
echo "image_name=$IMAGE_NAME"
77+
echo "tag=$TAG"
78+
79+
echo "image_name=$IMAGE_NAME" >> $GITHUB_OUTPUT
80+
echo "tag=$TAG" >> $GITHUB_OUTPUT
81+
82+
- name: Docker Build & Push
83+
run: |
84+
DOCKERFILE=Dockerfile
85+
86+
docker buildx build \
87+
--platform linux/amd64 \
88+
-f $DOCKERFILE \
89+
-t ${{ steps.tagger.outputs.image_name }}:${{ steps.tagger.outputs.tag }} \
90+
--push .
91+
92+
# - name: Checkout EKS GitOps Repo
93+
# uses: actions/checkout@v3
94+
# with:
95+
# repository: 100-hours-a-week/4-bull4zo-eks
96+
# token: ${{ secrets.GH_PAT }}
97+
# path: eks-repo
98+
99+
# - name: Update deployment.yaml image tag
100+
# run: |
101+
# TAG=${{ steps.tagger.outputs.tag }}
102+
# DEPLOY_PATH="eks-repo/backend/deployment.yml"
103+
# sed -i "s|\(image: 4moa/moa-be:\).*|\1$TAG|" "$DEPLOY_PATH"
104+
# echo "✅ Updated image tag to $TAG"
105+
# grep "image:" "$DEPLOY_PATH" || echo "image tag not found"
106+
107+
# - name: Commit and Push updated manifest
108+
# run: |
109+
# cd eks-repo
110+
# git config user.name "GitHub Actions"
111+
# git config user.email "actions@github.com"
112+
# git add .
113+
# git commit -m "test: update backend image tag to ${{ steps.tagger.outputs.tag }}"
114+
# git push
115+
116+
- name: SSH into Bastion and update deployment tag
117+
env:
118+
SSH_KEY: ${{ secrets.GCP_CICD_SSH_KEY }}
119+
BASTION_HOST: ${{ secrets.BASTION_HOST }}
120+
DEPLOY_FILE: /home/ubuntu/4-bull4zo-eks/backend/deployment.yml
121+
IMAGE_TAG: ${{ steps.tagger.outputs.tag }}
122+
run: |
123+
echo "$SSH_KEY" > key.pem
124+
chmod 600 key.pem
125+
126+
# optional: trust host
127+
mkdir -p ~/.ssh
128+
ssh-keyscan -H $BASTION_HOST >> ~/.ssh/known_hosts
129+
130+
ssh -i key.pem ubuntu@$BASTION_HOST << EOF
131+
echo "Connected to Bastion"
132+
133+
cd /home/ubuntu/4-bull4zo-eks/backend
134+
git pull origin main
135+
sed -i "s|\(image: 4moa/moa-be:\).*|\1$IMAGE_TAG|" $DEPLOY_FILE
136+
137+
git config user.name "GitHub Actions"
138+
git config user.email "actions@github.com"
139+
git add $DEPLOY_FILE
140+
git commit -m "cicd: update image tag to $IMAGE_TAG"
141+
git push origin main
142+
EOF
143+
144+
rm key.pem

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ plugins {
88
}
99

1010
group = 'com.moa'
11-
version = 'v3.1.0'
11+
version = 'v3.2.0'
1212

1313
java {
1414
toolchain {

src/main/java/com/moa/moa_server/domain/comment/repository/CommentRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ Integer findFirstAnonymousNumberByVoteIdAndUserId(
1919

2020
@Query("SELECT c.vote.id, COUNT(c) FROM Comment c WHERE c.vote.id IN :voteIds GROUP BY c.vote.id")
2121
List<Object[]> countCommentsByVoteIds(@Param("voteIds") List<Long> voteIds);
22+
23+
int countByVoteId(Long voteId);
2224
}

src/main/java/com/moa/moa_server/domain/comment/service/CommentService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.moa.moa_server.domain.global.cursor.CreatedAtCommentIdCursor;
1616
import com.moa.moa_server.domain.global.util.XssUtil;
1717
import com.moa.moa_server.domain.notification.application.producer.VoteNotificationProducerImpl;
18+
import com.moa.moa_server.domain.ranking.service.RankingRedisService;
1819
import com.moa.moa_server.domain.user.entity.User;
1920
import com.moa.moa_server.domain.vote.entity.Vote;
2021
import com.moa.moa_server.domain.vote.repository.VoteRepository;
@@ -32,6 +33,7 @@ public class CommentService {
3233

3334
private final CommentRepository commentRepository;
3435
private final VoteRepository voteRepository;
36+
private final RankingRedisService rankingRedisService;
3537

3638
private final VoteNotificationProducerImpl voteNotificationProducer;
3739

@@ -69,6 +71,9 @@ public CommentCreateResponse createComment(
6971

7072
voteNotificationProducer.notifyVoteCommented(voteId, userId, comment.getContent());
7173

74+
// 랭킹 갱신을 위해 수정된 투표를 Redis ZSet에 기록
75+
rankingRedisService.trackUpdatedVote(voteId);
76+
7277
return new CommentCreateResponse(
7378
comment.getId(), comment.getContent(), authorNickname, comment.getCreatedAt());
7479
}

src/main/java/com/moa/moa_server/domain/comment/service/context/CommentPermissionContextFactory.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.moa.moa_server.domain.comment.handler.CommentErrorCode;
44
import com.moa.moa_server.domain.comment.handler.CommentException;
5+
import com.moa.moa_server.domain.ranking.util.RankingPermissionValidator;
56
import com.moa.moa_server.domain.user.entity.User;
67
import com.moa.moa_server.domain.user.handler.UserErrorCode;
78
import com.moa.moa_server.domain.user.handler.UserException;
@@ -22,6 +23,7 @@ public class CommentPermissionContextFactory {
2223
private final UserRepository userRepository;
2324
private final VoteRepository voteRepository;
2425
private final VoteResponseRepository voteResponseRepository;
26+
private final RankingPermissionValidator rankingPermissionValidator;
2527

2628
@Transactional(readOnly = true)
2729
public CommentPermissionContext validateAndGetContext(Long userId, Long voteId) {
@@ -38,15 +40,16 @@ public CommentPermissionContext validateAndGetContext(Long userId, Long voteId)
3840
.findById(voteId)
3941
.orElseThrow(() -> new CommentException(CommentErrorCode.VOTE_NOT_FOUND));
4042

41-
// 댓글 작성 권한 확인 (투표 참여자(유효 응답: 1, 2)이거나 투표 등록자)
43+
// 댓글 작성 권한 확인 (투표 참여자(유효 응답: 1, 2)이거나 투표 등록자, top3 투표)
4244
boolean isOwner = vote.getUser().getId().equals(user.getId());
4345
boolean isParticipant =
4446
voteResponseRepository.existsByVoteIdAndUserIdAndOptionNumberIn(
4547
vote.getId(), user.getId(), List.of(1, 2));
46-
if (!(isOwner || isParticipant)) {
48+
boolean isTop3Accessible = rankingPermissionValidator.isAccessibleAsTopRankedVote(user, vote);
49+
50+
if (!(isOwner || isParticipant || isTop3Accessible)) {
4751
throw new CommentException(CommentErrorCode.FORBIDDEN);
4852
}
49-
// TODO: 권한 추가 - top3 투표인 경우 모든 사용자가 댓글 작성 가능
5053

5154
return new CommentPermissionContext(user, vote);
5255
}

src/main/java/com/moa/moa_server/domain/group/repository/GroupMemberRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@ Optional<GroupMember> findByGroupAndUserIncludingDeleted(
3939
boolean existsByGroupAndUser(Group group, User user);
4040

4141
int countByGroup(Group group);
42+
43+
@Query("SELECT gm.group.id FROM GroupMember gm WHERE gm.user = :user")
44+
List<Long> findGroupIdsByUser(@Param("user") User user);
4245
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.moa.moa_server.domain.ranking.controller;
2+
3+
import com.moa.moa_server.domain.global.dto.ApiResponse;
4+
import com.moa.moa_server.domain.ranking.dto.TopVoteResponse;
5+
import com.moa.moa_server.domain.ranking.service.RankingService;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.annotation.Nullable;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.*;
13+
14+
@Tag(name = "TopVote", description = "랭킹 투표 도메인 API")
15+
@RestController
16+
@RequiredArgsConstructor
17+
@RequestMapping("/api/v1/votes/top")
18+
public class RankingController {
19+
20+
private final RankingService rankingService;
21+
22+
@Operation(summary = "Top3 투표 목록 조회", description = "그룹 내 하루 동안의 Top3 투표 목록을 조회합니다.")
23+
@GetMapping
24+
public ResponseEntity<ApiResponse<TopVoteResponse>> getTopVotes(
25+
@AuthenticationPrincipal Long userId, @RequestParam @Nullable Long groupId) {
26+
TopVoteResponse response = rankingService.getTopVotes(userId, groupId);
27+
return ResponseEntity.ok(new ApiResponse<>("SUCCESS", response));
28+
}
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.moa.moa_server.domain.ranking.dto;
2+
3+
import com.moa.moa_server.domain.vote.dto.response.result.VoteOptionResult;
4+
import com.moa.moa_server.domain.vote.entity.Vote;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import java.time.LocalDateTime;
7+
import java.util.List;
8+
import lombok.Builder;
9+
10+
@Builder
11+
@Schema(description = "Top3 투표 정보")
12+
public record TopVoteItem(
13+
@Schema(description = "투표 ID", example = "123") Long voteId,
14+
@Schema(description = "투표가 속한 그룹 ID", example = "1") Long groupId,
15+
@Schema(description = "투표 본문 내용", example = "에어컨 추우신 분?") String content,
16+
@Schema(description = "투표 시작 시각", example = "2025-07-20T12:00:00") LocalDateTime createdAt,
17+
@Schema(description = "투표 종료 시각", example = "2025-07-21T12:00:00") LocalDateTime closedAt,
18+
@Schema(description = "항목별 결과 리스트") List<VoteOptionResult> results) {
19+
public static TopVoteItem from(Vote vote, List<VoteOptionResult> results) {
20+
return TopVoteItem.builder()
21+
.voteId(vote.getId())
22+
.groupId(vote.getGroup().getId())
23+
.content(vote.getContent())
24+
.createdAt(vote.getOpenAt() != null ? vote.getOpenAt() : vote.getCreatedAt())
25+
.closedAt(vote.getClosedAt())
26+
.results(results)
27+
.build();
28+
}
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.moa.moa_server.domain.ranking.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import java.time.LocalDateTime;
5+
import java.util.List;
6+
import lombok.Builder;
7+
8+
@Builder
9+
@Schema(description = "Top3 투표 조회 응답 DTO")
10+
public record TopVoteResponse(
11+
@Schema(description = "그룹 ID", example = "1") Long groupId,
12+
@Schema(description = "랭킹 기준 시작 시간", example = "2025-07-21T00:00:00") LocalDateTime rankedFrom,
13+
@Schema(description = "랭킹 기준 종료 시간", example = "2025-07-21T01:00:00") LocalDateTime rankedTo,
14+
@Schema(description = "Top3 투표 목록") List<TopVoteItem> topVotes) {
15+
16+
public static TopVoteResponse of(
17+
Long groupId, LocalDateTime from, LocalDateTime to, List<TopVoteItem> votes) {
18+
return TopVoteResponse.builder()
19+
.groupId(groupId)
20+
.rankedFrom(from)
21+
.rankedTo(to)
22+
.topVotes(votes)
23+
.build();
24+
}
25+
}

0 commit comments

Comments
 (0)