Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 19 additions & 25 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
branches: ["main"]
paths:
- "backend/**"
- "frontend/**" # ⬅ 프론트 변경에도 트리거
- "frontend/**" # 프론트 변경에도 트리거
- "docker-compose.yml"
- ".github/workflows/deploy.yml"

Expand All @@ -17,12 +17,7 @@ concurrency:
env:
REGISTRY: ghcr.io
IMAGE_BACK: ghcr.io/sisc-it/sisc-web-back
IMAGE_FRONT: ghcr.io/sisc-it/sisc-web-front # ⬅ 프론트 이미지 추가
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_PORT: ${{ secrets.SSH_PORT }}

IMAGE_FRONT: ghcr.io/sisc-it/sisc-web-front # 프론트 이미지 추가

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

Expand All @@ -126,7 +121,7 @@ jobs:
#tags: ${{ steps.meta-front.outputs.tags }}
#labels: ${{ steps.meta-front.outputs.labels }}
build-args: |
VITE_API_URL=${{ secrets.DEV_API_URL }}
VITE_API_URL=${{ secrets.FRONTEND_URL }}
cache-from: type=gha
cache-to: type=gha,mode=max

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

Expand All @@ -151,8 +146,7 @@ jobs:
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env

# 3. 기타 Secrets
echo "DEV_FRONTEND_URL=${{ secrets.DEV_FRONTEND_URL }}" >> .env
echo "PROD_FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}" >> .env
echo "FRONTEND_URL=${{ secrets.FRONTEND_URL }}" >> .env
echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
echo "MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}" >> .env
echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env
Expand All @@ -166,24 +160,24 @@ jobs:
- name: 파일 전송 (docker-compose, .env to EC2)
uses: appleboy/scp-action@master
with:
host: ${{ env.SSH_HOST }}
username: ${{ env.SSH_USER }}
key: ${{ env.SSH_PRIVATE_KEY }}
port: ${{ env.SSH_PORT }}
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
source: "docker-compose.yml, .env"
target: "/home/ubuntu/apps/sisc-web/"
target: "/home/${{ secrets.SSH_USER }}/app/sisc-web/"

- name: SSH deploy
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ env.SSH_HOST }}
username: ${{ env.SSH_USER }}
key: ${{ env.SSH_PRIVATE_KEY }}
port: ${{ env.SSH_PORT }}
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
cd ~/apps/sisc-web
cd ~/app/sisc-web

# GHCR 로그인 (백/프론트 둘 다 같은 레지스트리 사용)
echo "${{ secrets.GHCR_READ_TOKEN }}" | docker login ghcr.io -u ${{ secrets.GHCR_READ_USER }} --password-stdin
Expand All @@ -198,7 +192,7 @@ jobs:
for i in {1..30}; do
status=$(docker inspect --format='{{json .State.Health.Status}}' api 2>/dev/null || echo '"none"')
if echo "$status" | grep -q healthy; then
echo "✅ api healthy"; break
echo "=== 서버 정상 작동 확인 ==="; break
fi
sleep 2
done
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.sejongisc.backend.activity.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "activity_log", indexes = {
@Index(name = "idx_activity_user_id", columnList = "userId"),
@Index(name = "idx_activity_created_at", columnList = "createdAt")
})
public class ActivityLog {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private UUID userId;

@Column(nullable = false)
private String username; // 조회 시 조인 부하를 줄이기 위해 이름 스냅샷 저장

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ActivityType type; // ATTENDANCE, BOARD, BETTING 등

@Column(nullable = false, length = 30)
private String message; // "자유게시판에 글을 게시했어요"

private UUID targetId; // 관련 게시글 ID 등 (상세보기용)

private String boardName; // 관리자 게시판별 통계용

private LocalDateTime createdAt;

@Builder
public ActivityLog(UUID userId, String username, ActivityType type, String message, UUID targetId, String boardName) {
this.userId = userId;
this.username = username;
this.type = type;
this.message = message;
this.targetId = targetId;
this.boardName = boardName;
this.createdAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.sejongisc.backend.activity.entity;

public enum ActivityType {
ATTENDANCE, // 출석체크
BOARD_POST, // 게시글 작성
BOARD_COMMENT, // 댓글 작성
BOARD_LIKE, // 좋아요
BETTING_JOIN, // 베팅 참여
AUTH_LOGIN, // 로그인 (방문자 통계용)
SIGNUP // 일반 회원가입
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.sejongisc.backend.activity.event;

import org.sejongisc.backend.activity.entity.ActivityType;

import java.util.UUID;

public record ActivityEvent(
UUID userId,
String username,
ActivityType type,
String message,
UUID targetId,
String boardName
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.sejongisc.backend.activity.listener;

import lombok.RequiredArgsConstructor;
import org.sejongisc.backend.activity.entity.ActivityLog;
import org.sejongisc.backend.activity.event.ActivityEvent;
import org.sejongisc.backend.activity.repository.ActivityLogRepository;
import org.sejongisc.backend.common.sse.SseService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class ActivityEventListener {

private final ActivityLogRepository activityLogRepository;
private final SseService sseService; // 실시간 전송용 서비스

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleActivityEvent(ActivityEvent event) {
// DB 저장 (마이페이지 및 관리자 통계용)
ActivityLog log = activityLogRepository.save(ActivityLog.builder()
.userId(event.userId())
.username(event.username())
.type(event.type())
.message(event.message())
.targetId(event.targetId())
.boardName(event.boardName())
.build());

// 관리자 채널에 실시간 SSE 전송 (메인 대시보드 피드용)
sseService.send("ADMIN_DASHBOARD", "newLog", log);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.sejongisc.backend.activity.repository;

import org.sejongisc.backend.activity.entity.ActivityLog;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

public interface ActivityLogRepository extends JpaRepository<ActivityLog, Long> {

// 이슈 1: 메인 대시보드 실시간 로그 (최신순 20개)
List<ActivityLog> findTop20ByOrderByCreatedAtDesc();

// 이슈 2: 마이페이지 내 활동 조회
Slice<ActivityLog> findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable);

// 이슈 3-1: 일일 방문자 수 통계
@Query("SELECT COUNT(DISTINCT a.userId) FROM ActivityLog a " +
"WHERE a.type = 'AUTH_LOGIN' AND a.createdAt BETWEEN :start AND :end")
long countDailyUniqueVisitors(LocalDateTime start, LocalDateTime end);

// 이슈 3-2: 게시판별 활동량 집계 (게시글+댓글+좋아요)
@Query("SELECT a.boardName, COUNT(a) FROM ActivityLog a " +
"WHERE a.type IN ('BOARD_POST', 'BOARD_COMMENT', 'BOARD_LIKE') " +
"AND a.createdAt BETWEEN :start AND :end " +
"GROUP BY a.boardName")
List<Object[]> countActivityByBoard(LocalDateTime start, LocalDateTime end);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public ResponseEntity<Void> checkIn(
@RequestBody AttendanceRoundQrTokenRequest request
) {
UUID userId = requireUserId(userDetails);
attendanceService.checkIn(userId, request);
attendanceService.checkIn(userId, userDetails.getName(), request);
return ResponseEntity.ok().build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sejongisc.backend.activity.entity.ActivityType;
import org.sejongisc.backend.activity.event.ActivityEvent;
import org.sejongisc.backend.attendance.dto.AttendanceResponse;
import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenRequest;
import org.sejongisc.backend.attendance.entity.Attendance;
Expand All @@ -17,6 +19,7 @@
import org.sejongisc.backend.common.exception.ErrorCode;
import org.sejongisc.backend.user.repository.UserRepository;
import org.sejongisc.backend.user.entity.User;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -33,11 +36,12 @@ public class AttendanceService {
private final UserRepository userRepository;
private final AttendanceAuthorizationService authorizationService;
private final AttendanceRoundService attendanceRoundService;
private final ApplicationEventPublisher eventPublisher;

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

// 토큰 검증 + ACTIVE 라운드 조회
AttendanceRound round = attendanceRoundService.verifyQrTokenAndGetRound(request.qrToken());
Expand Down Expand Up @@ -67,6 +71,14 @@ public void checkIn(UUID userId, AttendanceRoundQrTokenRequest request) {
} catch (DataIntegrityViolationException e) {
throw new CustomException(ErrorCode.ALREADY_CHECKED_IN);
}
eventPublisher.publishEvent(new ActivityEvent(
userId,
username,
ActivityType.ATTENDANCE,
username + "님이 " + round.getAttendanceSession().getTitle() + " 세션에 출석했습니다.",
att.getAttendanceId(),
null
));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,8 @@
@Slf4j
public class QrTokenStreamService {

@Value("${app.prod-frontend-url}")
private String prodFrontendUrl;

@Value("${app.dev-frontend-url}")
private String devFrontendUrl;

private final Environment environment;
@Value("${app.frontend-url}")
private String frontendUrl;

private static final String ATTENDANCE_PATH = "/attendance";

Expand Down Expand Up @@ -109,16 +104,7 @@ public SseEmitter subscribe(UUID roundId, UUID userId) {
}

public String createQrUrl(UUID roundId, String token) {
String baseUrl = devFrontendUrl;

/* TODO : 배포서버 설치, application-dev.yml 생성, devFrontendUrl 이관 후 주석 해제
for (String profile : environment.getActiveProfiles()) {
if ("prod".equalsIgnoreCase(profile)) {
baseUrl = prodFrontendUrl;
break;
}
}*/

String baseUrl = frontendUrl;
return String.format("%s%s?roundId=%s&token=%s", baseUrl, ATTENDANCE_PATH, roundId, token);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public ResponseEntity<UserBetResponse> postUserBet(
@AuthenticationPrincipal CustomUserDetails principal,
@Valid @RequestBody UserBetRequest userBetRequest) {

UserBetResponse userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest);
UserBetResponse userBet = bettingService.postUserBet(principal.getUserId(), principal.getName(), userBetRequest);
return ResponseEntity.ok(userBet);
}

Expand Down
Loading