diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml index db748bcc..f722a71b 100644 --- a/docs/erd/user-ops.puml +++ b/docs/erd/user-ops.puml @@ -33,14 +33,27 @@ entity "USER_AGREEMENTS\n사용자 동의 이력" as user_agreements { agreed_at : timestamp } -entity "USER_DEVICES\n사용자 디바이스" as user_devices { +entity "USER_DEVICES\n사용자 디바이스(FCM/APNs 토큰)" as user_devices { * id : BIGINT <> -- user_id : BIGINT <> - device_token : string - platform : string - last_seen_at : timestamp + fcm_token : VARCHAR(255) <> + platform : ENUM('ANDROID', 'IOS') + last_used_at : timestamp created_at : timestamp + updated_at : timestamp +} + +entity "USER_DAILY_ACTIVITIES\n어드민 대시보드 DAU/MAU 집계용 유저-날짜별 활동" as user_daily_activities { + * id : BIGINT <> + -- + user_id : BIGINT <> + activity_date : DATE + logged_in : boolean + active : boolean + created_at : timestamp + updated_at : timestamp + UNIQUE(user_id, activity_date) } entity "USER_BLOCKS\n사용자 차단" as user_blocks { @@ -54,18 +67,20 @@ entity "USER_BLOCKS\n사용자 차단" as user_blocks { users -[hidden]down- user_settings user_settings -[hidden]down- user_agreements user_agreements -[hidden]down- user_devices -user_devices -[hidden]down- user_blocks +user_devices -[hidden]down- user_daily_activities +user_daily_activities -[hidden]down- user_blocks users ||--|| user_settings users ||--o{ user_agreements users ||--o{ user_devices +users ||--o{ user_daily_activities users ||--o{ user_blocks : blocker users ||--o{ user_blocks : blocked note bottom of user_blocks 공통 컬럼 정책 - BaseEntity: created_at, updated_at - - agreed_at, last_seen_at 은 도메인별 개별 컬럼 + - agreed_at, last_used_at 은 도메인별 개별 컬럼 end note @enduml diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index 42c11fc2..474f9158 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.oauth.jwt; import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.common.response.ApiResponse; import jakarta.servlet.FilterChain; @@ -16,6 +17,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -23,6 +25,7 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + private final UserDailyActivityRepository userDailyActivityRepository; private final ObjectMapper objectMapper = new ObjectMapper(); // 1. 화이트리스트 상수 관리 @@ -89,6 +92,7 @@ protected void doFilterInternal(HttpServletRequest request, ); SecurityContextHolder.getContext().setAuthentication(authentication); + recordActivity(userId); } else { // shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단 @@ -105,6 +109,17 @@ protected void doFilterInternal(HttpServletRequest request, } } + /** + * DAU/MAU 집계를 위한 활동 기록. 대시보드 집계용 부가 기능이라 실패해도 인증 흐름을 막지 않는다. + */ + private void recordActivity(Long userId) { + try { + userDailyActivityRepository.markActive(userId, LocalDate.now()); + } catch (Exception e) { + log.warn("[JwtFilter] Failed to record daily activity for userId={}: {}", userId, e.getMessage()); + } + } + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(errorCode.getHttpStatus().value()); diff --git a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java index 75d68024..2a0839e5 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java +++ b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java @@ -26,6 +26,7 @@ import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.entity.UserTendencyScore; import com.swyp.picke.domain.user.entity.UserWithdrawal; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserProfileRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -43,6 +44,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Base64; import java.util.List; @@ -94,6 +96,7 @@ public class AuthService { private final UserSettingsRepository userSettingsRepository; private final UserTendencyScoreRepository userTendencyScoreRepository; private final UserWithdrawalRepository userWithdrawalRepository; + private final UserDailyActivityRepository userDailyActivityRepository; private final JwtProvider jwtProvider; private final CreditService creditService; private final PasswordEncoder passwordEncoder; @@ -201,6 +204,8 @@ private LoginResponse issueTokensAndBuildResponse(User user, boolean isNewUser) throw new CustomException(ErrorCode.USER_SUSPENDED); } + userDailyActivityRepository.markLoggedIn(user.getId(), LocalDate.now()); + // 기존 refresh token 삭제 후 새로 발급 refreshTokenRepository.deleteByUser(user); diff --git a/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java b/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java new file mode 100644 index 00000000..414c8e81 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.user.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 어드민 대시보드 DAU/MAU 집계를 위한 유저-날짜별 활동 기록. + * {@code active}/{@code logged_in}은 JwtFilter/AuthService에서 UPSERT로 기록되며, + * 이 엔티티는 조회 전용으로만 사용한다. + */ +@Getter +@Entity +@Table( + name = "user_daily_activities", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "activity_date"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDailyActivity extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "activity_date", nullable = false) + private LocalDate activityDate; + + @Column(name = "logged_in", nullable = false) + private boolean loggedIn; + + @Column(name = "active", nullable = false) + private boolean active; +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java new file mode 100644 index 00000000..f61b32f8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java @@ -0,0 +1,32 @@ +package com.swyp.picke.domain.user.repository; + +import com.swyp.picke.domain.user.entity.UserDailyActivity; +import java.time.LocalDate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface UserDailyActivityRepository extends JpaRepository { + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO user_daily_activities (user_id, activity_date, logged_in, active, created_at, updated_at) + VALUES (:userId, :date, false, true, now(), now()) + ON CONFLICT (user_id, activity_date) + DO UPDATE SET active = true, updated_at = now() + """, nativeQuery = true) + void markActive(@Param("userId") Long userId, @Param("date") LocalDate date); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO user_daily_activities (user_id, activity_date, logged_in, active, created_at, updated_at) + VALUES (:userId, :date, true, false, now(), now()) + ON CONFLICT (user_id, activity_date) + DO UPDATE SET logged_in = true, updated_at = now() + """, nativeQuery = true) + void markLoggedIn(@Param("userId") Long userId, @Param("date") LocalDate date); +} diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 73ca62b7..d0037617 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.oauth.jwt.JwtFilter; import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class SecurityConfig { private final JwtProvider jwtProvider; + private final UserDailyActivityRepository userDailyActivityRepository; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -70,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) - .addFilterBefore(new JwtFilter(jwtProvider), + .addFilterBefore(new JwtFilter(jwtProvider, userDailyActivityRepository), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql b/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql new file mode 100644 index 00000000..daa6c787 --- /dev/null +++ b/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql @@ -0,0 +1,11 @@ +-- 어드민 대시보드 DAU/MAU 집계를 위한 유저-날짜별 활동 기록 테이블 +CREATE TABLE IF NOT EXISTS user_daily_activities ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + activity_date DATE NOT NULL, + logged_in BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_user_daily_activities_user_date UNIQUE (user_id, activity_date) +); diff --git a/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java b/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java new file mode 100644 index 00000000..563781c4 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java @@ -0,0 +1,67 @@ +package com.swyp.picke.domain.oauth.jwt; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import jakarta.servlet.FilterChain; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class JwtFilterTest { + + @Mock + private JwtProvider jwtProvider; + + @Mock + private UserDailyActivityRepository userDailyActivityRepository; + + @Mock + private FilterChain filterChain; + + @Test + void doFilterInternal_유효한_토큰이면_오늘자_활동을_기록한다() throws Exception { + JwtFilter jwtFilter = new JwtFilter(jwtProvider, userDailyActivityRepository); + + when(jwtProvider.validateToken("valid-token")).thenReturn(true); + when(jwtProvider.getUserId("valid-token")).thenReturn(42L); + when(jwtProvider.getRole("valid-token")).thenReturn("USER"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/me/mypage"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(userDailyActivityRepository).markActive(eq(42L), any(LocalDate.class)); + verify(filterChain).doFilter(request, response); + } + + @Test + void doFilterInternal_활동_기록이_실패해도_요청_처리는_계속된다() throws Exception { + JwtFilter jwtFilter = new JwtFilter(jwtProvider, userDailyActivityRepository); + + when(jwtProvider.validateToken("valid-token")).thenReturn(true); + when(jwtProvider.getUserId("valid-token")).thenReturn(42L); + when(jwtProvider.getRole("valid-token")).thenReturn("USER"); + doThrow(new RuntimeException("DB down")) + .when(userDailyActivityRepository).markActive(any(), any()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/me/mypage"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } +} diff --git a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java index eba3aaa6..81dce108 100644 --- a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java @@ -24,6 +24,7 @@ import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.enums.WithdrawalReason; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserProfileRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -60,6 +61,7 @@ class OAuthServiceTest { @Mock private UserSettingsRepository userSettingsRepository; @Mock private UserTendencyScoreRepository userTendencyScoreRepository; @Mock private UserWithdrawalRepository userWithdrawalRepository; + @Mock private UserDailyActivityRepository userDailyActivityRepository; @Mock private JwtProvider jwtProvider; @Mock private CreditService creditService; @Mock private PasswordEncoder passwordEncoder; @@ -73,7 +75,7 @@ void setUp() { kakaoOAuthClient, googleOAuthClient, appleOAuthClient, userRepository, socialAccountRepository, localAccountRepository, refreshTokenRepository, userProfileRepository, userSettingsRepository, userTendencyScoreRepository, - userWithdrawalRepository, + userWithdrawalRepository, userDailyActivityRepository, jwtProvider, creditService, passwordEncoder ); } @@ -112,6 +114,7 @@ void setUp() { assertThat(response.getAccessToken()).isEqualTo("jwt-access"); assertThat(response.isNewUser()).isFalse(); verify(refreshTokenRepository).save(any()); + verify(userDailyActivityRepository).markLoggedIn(eq(user.getId()), any(java.time.LocalDate.class)); } @Test