Skip to content

Commit f598835

Browse files
authored
20260226 #236 관리자 페이지 통계 대시보드 실시간 활동 로그 추가 (#249)
* [BE] [FEAT] activityLog 추가 및 이벤트 관련 코드 추가 회원가입, 로그인, 베팅 참여, 게시물 작성, 출석 체크인에 이벤트 추가 완료 백테스팅, 댓글, 좋아요 이벤트 추가 고려 QrStreamService에 SseService 코드 사용 고려 * [BE] [FIX] deploy 관련 설정 변경 * [BE] [CHORE] db 설정 파일 주석 수정 * [BE] [FEAT] 댓글, 좋아요시 이벤트 발행 추가 백테스팅, 퀀트봇에 이벤트 발생 고려 QrStreamService에 SseService 코드 사용 고려 yml 수정에 따른 securityConfig 수정 * [BE] [FIX] securityConfig에 세션 설정 stateless 로 변경 oauth2를 더이상 쓰지 않으므로 변경 * [BE] [FIX] 토끼 리뷰 반영 targetId String -> UUID로 변경 회원가입 시 출석 type 사용 오류 수정
1 parent 5bacf07 commit f598835

22 files changed

Lines changed: 329 additions & 63 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
branches: ["main"]
77
paths:
88
- "backend/**"
9-
- "frontend/**" # 프론트 변경에도 트리거
9+
- "frontend/**" # 프론트 변경에도 트리거
1010
- "docker-compose.yml"
1111
- ".github/workflows/deploy.yml"
1212

@@ -17,12 +17,7 @@ concurrency:
1717
env:
1818
REGISTRY: ghcr.io
1919
IMAGE_BACK: ghcr.io/sisc-it/sisc-web-back
20-
IMAGE_FRONT: ghcr.io/sisc-it/sisc-web-front # ⬅ 프론트 이미지 추가
21-
SSH_HOST: ${{ secrets.SSH_HOST }}
22-
SSH_USER: ${{ secrets.SSH_USER }}
23-
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
24-
SSH_PORT: ${{ secrets.SSH_PORT }}
25-
20+
IMAGE_FRONT: ghcr.io/sisc-it/sisc-web-front # 프론트 이미지 추가
2621

2722
jobs:
2823
# 1. 변경 감지 (변동 없음)
@@ -63,7 +58,7 @@ jobs:
6358
- uses: actions/checkout@v4
6459
- uses: docker/login-action@v3
6560
with:
66-
registry: ghcr.io
61+
registry: ${{ env.REGISTRY }}
6762
username: ${{ github.actor }} # 실행 중인 유저 이름 자동 할당
6863
password: ${{ secrets.GITHUB_TOKEN }} # 별도 설정 없이 바로 사용 가능
6964
- uses: docker/setup-buildx-action@v3
@@ -93,7 +88,7 @@ jobs:
9388
needs: [changes] # deploy는 job 들이 병렬 처리가 되므로, 리스트 항목이 모두 끝나야 시작된다는 조건 추가
9489
#if: ${{ needs.changes.outputs.front == 'true' || github.event_name == 'workflow_dispatch' }}
9590
runs-on: ubuntu-latest
96-
environment: production # production 이라는 environment에 있는 secrets 들을 쓰기 위함 (DEV_API_URL)
91+
environment: development # development 이라는 environment에 있는 secrets 들을 쓰기 위함 (secrets.FRONTEND_URL)
9792
permissions: { contents: read, packages: write }
9893
#defaults: # context: ./frontend
9994
# run:
@@ -102,7 +97,7 @@ jobs:
10297
- uses: actions/checkout@v4
10398
- uses: docker/login-action@v3
10499
with:
105-
registry: ghcr.io
100+
registry: ${{ env.REGISTRY }}
106101
username: ${{ github.actor }} # 실행 중인 유저 이름 자동 할당
107102
password: ${{ secrets.GITHUB_TOKEN }} # 별도 설정 없이 바로 사용 가능
108103

@@ -126,7 +121,7 @@ jobs:
126121
#tags: ${{ steps.meta-front.outputs.tags }}
127122
#labels: ${{ steps.meta-front.outputs.labels }}
128123
build-args: |
129-
VITE_API_URL=${{ secrets.DEV_API_URL }}
124+
VITE_API_URL=${{ secrets.FRONTEND_URL }}
130125
cache-from: type=gha
131126
cache-to: type=gha,mode=max
132127

@@ -136,7 +131,7 @@ jobs:
136131
# - manual 실행(workflow_dispatch)이면 무조건 실행
137132
# - push일 때는 둘 중 하나라도 성공한 경우에만 실행 (둘 다 failure가 아닌 이상 OK)
138133
runs-on: ubuntu-latest
139-
environment: production # production 환경의 시크릿 사용
134+
environment: development # development 환경의 repository secrets 사용
140135
steps:
141136
- uses: actions/checkout@v4
142137

@@ -151,8 +146,7 @@ jobs:
151146
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env
152147
153148
# 3. 기타 Secrets
154-
echo "DEV_FRONTEND_URL=${{ secrets.DEV_FRONTEND_URL }}" >> .env
155-
echo "PROD_FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}" >> .env
149+
echo "FRONTEND_URL=${{ secrets.FRONTEND_URL }}" >> .env
156150
echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
157151
echo "MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}" >> .env
158152
echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env
@@ -166,20 +160,20 @@ jobs:
166160
- name: 파일 전송 (docker-compose, .env to EC2)
167161
uses: appleboy/scp-action@master
168162
with:
169-
host: ${{ env.SSH_HOST }}
170-
username: ${{ env.SSH_USER }}
171-
key: ${{ env.SSH_PRIVATE_KEY }}
172-
port: ${{ env.SSH_PORT }}
163+
host: ${{ secrets.SSH_HOST }}
164+
username: ${{ secrets.SSH_USER }}
165+
key: ${{ secrets.SSH_PRIVATE_KEY }}
166+
port: ${{ secrets.SSH_PORT }}
173167
source: "docker-compose.yml, .env"
174168
target: "/home/ubuntu/apps/sisc-web/"
175169

176170
- name: SSH deploy
177171
uses: appleboy/ssh-action@v1.2.0
178172
with:
179-
host: ${{ env.SSH_HOST }}
180-
username: ${{ env.SSH_USER }}
181-
key: ${{ env.SSH_PRIVATE_KEY }}
182-
port: ${{ env.SSH_PORT }}
173+
host: ${{ secrets.SSH_HOST }}
174+
username: ${{ secrets.SSH_USER }}
175+
key: ${{ secrets.SSH_PRIVATE_KEY }}
176+
port: ${{ secrets.SSH_PORT }}
183177
script_stop: true
184178
script: |
185179
set -euo pipefail
@@ -198,7 +192,7 @@ jobs:
198192
for i in {1..30}; do
199193
status=$(docker inspect --format='{{json .State.Health.Status}}' api 2>/dev/null || echo '"none"')
200194
if echo "$status" | grep -q healthy; then
201-
echo "✅ api healthy"; break
195+
echo "=== 서버 정상 작동 확인 ==="; break
202196
fi
203197
sleep 2
204198
done
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.sejongisc.backend.activity.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AccessLevel;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
import java.time.LocalDateTime;
10+
import java.util.UUID;
11+
12+
@Entity
13+
@Getter
14+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
15+
@Table(name = "activity_log", indexes = {
16+
@Index(name = "idx_activity_user_id", columnList = "userId"),
17+
@Index(name = "idx_activity_created_at", columnList = "createdAt")
18+
})
19+
public class ActivityLog {
20+
21+
@Id
22+
@GeneratedValue(strategy = GenerationType.IDENTITY)
23+
private Long id;
24+
25+
@Column(nullable = false)
26+
private UUID userId;
27+
28+
@Column(nullable = false)
29+
private String username; // 조회 시 조인 부하를 줄이기 위해 이름 스냅샷 저장
30+
31+
@Enumerated(EnumType.STRING)
32+
@Column(nullable = false)
33+
private ActivityType type; // ATTENDANCE, BOARD, BETTING 등
34+
35+
@Column(nullable = false, length = 30)
36+
private String message; // "자유게시판에 글을 게시했어요"
37+
38+
private UUID targetId; // 관련 게시글 ID 등 (상세보기용)
39+
40+
private String boardName; // 관리자 게시판별 통계용
41+
42+
private LocalDateTime createdAt;
43+
44+
@Builder
45+
public ActivityLog(UUID userId, String username, ActivityType type, String message, UUID targetId, String boardName) {
46+
this.userId = userId;
47+
this.username = username;
48+
this.type = type;
49+
this.message = message;
50+
this.targetId = targetId;
51+
this.boardName = boardName;
52+
this.createdAt = LocalDateTime.now();
53+
}
54+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.sejongisc.backend.activity.entity;
2+
3+
public enum ActivityType {
4+
ATTENDANCE, // 출석체크
5+
BOARD_POST, // 게시글 작성
6+
BOARD_COMMENT, // 댓글 작성
7+
BOARD_LIKE, // 좋아요
8+
BETTING_JOIN, // 베팅 참여
9+
AUTH_LOGIN, // 로그인 (방문자 통계용)
10+
SIGNUP // 일반 회원가입
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.sejongisc.backend.activity.event;
2+
3+
import org.sejongisc.backend.activity.entity.ActivityType;
4+
5+
import java.util.UUID;
6+
7+
public record ActivityEvent(
8+
UUID userId,
9+
String username,
10+
ActivityType type,
11+
String message,
12+
UUID targetId,
13+
String boardName
14+
) {}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package org.sejongisc.backend.activity.listener;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.sejongisc.backend.activity.entity.ActivityLog;
5+
import org.sejongisc.backend.activity.event.ActivityEvent;
6+
import org.sejongisc.backend.activity.repository.ActivityLogRepository;
7+
import org.sejongisc.backend.common.sse.SseService;
8+
import org.springframework.scheduling.annotation.Async;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.event.TransactionPhase;
11+
import org.springframework.transaction.event.TransactionalEventListener;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class ActivityEventListener {
16+
17+
private final ActivityLogRepository activityLogRepository;
18+
private final SseService sseService; // 실시간 전송용 서비스
19+
20+
@Async
21+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
22+
public void handleActivityEvent(ActivityEvent event) {
23+
// DB 저장 (마이페이지 및 관리자 통계용)
24+
ActivityLog log = activityLogRepository.save(ActivityLog.builder()
25+
.userId(event.userId())
26+
.username(event.username())
27+
.type(event.type())
28+
.message(event.message())
29+
.targetId(event.targetId())
30+
.boardName(event.boardName())
31+
.build());
32+
33+
// 관리자 채널에 실시간 SSE 전송 (메인 대시보드 피드용)
34+
sseService.send("ADMIN_DASHBOARD", "newLog", log);
35+
}
36+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.sejongisc.backend.activity.repository;
2+
3+
import org.sejongisc.backend.activity.entity.ActivityLog;
4+
import org.springframework.data.domain.Pageable;
5+
import org.springframework.data.domain.Slice;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
9+
import java.time.LocalDateTime;
10+
import java.util.List;
11+
import java.util.UUID;
12+
13+
public interface ActivityLogRepository extends JpaRepository<ActivityLog, Long> {
14+
15+
// 이슈 1: 메인 대시보드 실시간 로그 (최신순 20개)
16+
List<ActivityLog> findTop20ByOrderByCreatedAtDesc();
17+
18+
// 이슈 2: 마이페이지 내 활동 조회
19+
Slice<ActivityLog> findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable);
20+
21+
// 이슈 3-1: 일일 방문자 수 통계
22+
@Query("SELECT COUNT(DISTINCT a.userId) FROM ActivityLog a " +
23+
"WHERE a.type = 'AUTH_LOGIN' AND a.createdAt BETWEEN :start AND :end")
24+
long countDailyUniqueVisitors(LocalDateTime start, LocalDateTime end);
25+
26+
// 이슈 3-2: 게시판별 활동량 집계 (게시글+댓글+좋아요)
27+
@Query("SELECT a.boardName, COUNT(a) FROM ActivityLog a " +
28+
"WHERE a.type IN ('BOARD_POST', 'BOARD_COMMENT', 'BOARD_LIKE') " +
29+
"AND a.createdAt BETWEEN :start AND :end " +
30+
"GROUP BY a.boardName")
31+
List<Object[]> countActivityByBoard(LocalDateTime start, LocalDateTime end);
32+
}

backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public ResponseEntity<Void> checkIn(
3737
@RequestBody AttendanceRoundQrTokenRequest request
3838
) {
3939
UUID userId = requireUserId(userDetails);
40-
attendanceService.checkIn(userId, request);
40+
attendanceService.checkIn(userId, userDetails.getName(), request);
4141
return ResponseEntity.ok().build();
4242
}
4343

backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import java.util.stream.Collectors;
77
import lombok.RequiredArgsConstructor;
88
import lombok.extern.slf4j.Slf4j;
9+
import org.sejongisc.backend.activity.entity.ActivityType;
10+
import org.sejongisc.backend.activity.event.ActivityEvent;
911
import org.sejongisc.backend.attendance.dto.AttendanceResponse;
1012
import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenRequest;
1113
import org.sejongisc.backend.attendance.entity.Attendance;
@@ -17,6 +19,7 @@
1719
import org.sejongisc.backend.common.exception.ErrorCode;
1820
import org.sejongisc.backend.user.repository.UserRepository;
1921
import org.sejongisc.backend.user.entity.User;
22+
import org.springframework.context.ApplicationEventPublisher;
2023
import org.springframework.dao.DataIntegrityViolationException;
2124
import org.springframework.stereotype.Service;
2225
import org.springframework.transaction.annotation.Transactional;
@@ -33,11 +36,12 @@ public class AttendanceService {
3336
private final UserRepository userRepository;
3437
private final AttendanceAuthorizationService authorizationService;
3538
private final AttendanceRoundService attendanceRoundService;
39+
private final ApplicationEventPublisher eventPublisher;
3640

3741
/**
3842
* QR 토큰 기반 출석 체크인 처리(세션 멤버용) - qrToken으로 라운드 검증/조회 (HMAC + 만료 + ACTIVE) - 세션 멤버십 및 중복 출석 방지 - 지각 판별 및 출석 상태 결정
3943
*/
40-
public void checkIn(UUID userId, AttendanceRoundQrTokenRequest request) {
44+
public void checkIn(UUID userId, String username, AttendanceRoundQrTokenRequest request) {
4145

4246
// 토큰 검증 + ACTIVE 라운드 조회
4347
AttendanceRound round = attendanceRoundService.verifyQrTokenAndGetRound(request.qrToken());
@@ -67,6 +71,14 @@ public void checkIn(UUID userId, AttendanceRoundQrTokenRequest request) {
6771
} catch (DataIntegrityViolationException e) {
6872
throw new CustomException(ErrorCode.ALREADY_CHECKED_IN);
6973
}
74+
eventPublisher.publishEvent(new ActivityEvent(
75+
userId,
76+
username,
77+
ActivityType.ATTENDANCE,
78+
username + "님이 " + round.getAttendanceSession().getTitle() + " 세션에 출석했습니다.",
79+
att.getAttendanceId(),
80+
null
81+
));
7082
}
7183

7284
/**

backend/src/main/java/org/sejongisc/backend/attendance/service/QrTokenStreamService.java

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,8 @@
4040
@Slf4j
4141
public class QrTokenStreamService {
4242

43-
@Value("${app.prod-frontend-url}")
44-
private String prodFrontendUrl;
45-
46-
@Value("${app.dev-frontend-url}")
47-
private String devFrontendUrl;
48-
49-
private final Environment environment;
43+
@Value("${app.frontend-url}")
44+
private String frontendUrl;
5045

5146
private static final String ATTENDANCE_PATH = "/attendance";
5247

@@ -109,16 +104,7 @@ public SseEmitter subscribe(UUID roundId, UUID userId) {
109104
}
110105

111106
public String createQrUrl(UUID roundId, String token) {
112-
String baseUrl = devFrontendUrl;
113-
114-
/* TODO : 배포서버 설치, application-dev.yml 생성, devFrontendUrl 이관 후 주석 해제
115-
for (String profile : environment.getActiveProfiles()) {
116-
if ("prod".equalsIgnoreCase(profile)) {
117-
baseUrl = prodFrontendUrl;
118-
break;
119-
}
120-
}*/
121-
107+
String baseUrl = frontendUrl;
122108
return String.format("%s%s?roundId=%s&token=%s", baseUrl, ATTENDANCE_PATH, roundId, token);
123109
}
124110

backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public ResponseEntity<UserBetResponse> postUserBet(
9595
@AuthenticationPrincipal CustomUserDetails principal,
9696
@Valid @RequestBody UserBetRequest userBetRequest) {
9797

98-
UserBetResponse userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest);
98+
UserBetResponse userBet = bettingService.postUserBet(principal.getUserId(), principal.getName(), userBetRequest);
9999
return ResponseEntity.ok(userBet);
100100
}
101101

0 commit comments

Comments
 (0)