From be80605fd5613e5b3149aecf5beb994f68b65c7e Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 30 Aug 2026 23:21:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20DAU/MAU=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=EC=9A=A9=20UserDailyActivity=20=EC=97=94=ED=8B=B0=ED=8B=B0/?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/entity/UserDailyActivity.java | 38 +++++++++++++++++++ .../UserDailyActivityRepository.java | 32 ++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java create mode 100644 src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java 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 0000000..414c8e8 --- /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 0000000..f61b32f --- /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); +} From ca2324d1c83ebf2e86851d2eef7391aed8ea2d60 Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 30 Aug 2026 23:22:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20JwtFilter=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=9A=94=EC=B2=AD=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9D=BC=EB=B3=84=20=ED=99=9C=EB=8F=99(active)=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../picke/domain/oauth/jwt/JwtFilter.java | 15 +++++ .../picke/global/config/SecurityConfig.java | 4 +- .../picke/domain/oauth/jwt/JwtFilterTest.java | 67 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java 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 42c11fc..474f915 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/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 73ca62b..d003761 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/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 0000000..563781c --- /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); + } +} From b42ed46c11b747d1fa9699c3ef7607e3f30056ba Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 30 Aug 2026 23:22:05 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EC=8B=9C=EC=A0=90=EC=97=90=20=EC=9D=BC?= =?UTF-8?q?=EB=B3=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8(logged=5Fin)=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/picke/domain/oauth/service/AuthService.java | 5 +++++ .../swyp/picke/domain/oauth/service/OAuthServiceTest.java | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 75d6802..2a0839e 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/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java index eba3aaa..81dce10 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 From d16a3afc5eb9d610d994974e4a1102fa00de2f1d Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 30 Aug 2026 23:24:57 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20user=5Fdaily=5Factivities=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20SQL=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V20260830_01__create_user_daily_activities.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql 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 0000000..daa6c78 --- /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) +); From 9b2a72c4a534bf46402f1e4b1b5ecd3ddf26b78e Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 30 Aug 2026 23:29:12 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20UserDevice=20ERD=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EB=AA=85=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20UserDaily?= =?UTF-8?q?Activity=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/erd/user-ops.puml | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml index db748bc..f722a71 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