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
27 changes: 21 additions & 6 deletions docs/erd/user-ops.puml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<PK>>
--
user_id : BIGINT <<FK>>
device_token : string
platform : string
last_seen_at : timestamp
fcm_token : VARCHAR(255) <<UNIQUE>>
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 <<PK>>
--
user_id : BIGINT <<FK>>
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 {
Expand All @@ -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
15 changes: 15 additions & 0 deletions src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,13 +17,15 @@
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.LocalDate;
import java.util.List;

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;
private final UserDailyActivityRepository userDailyActivityRepository;
private final ObjectMapper objectMapper = new ObjectMapper();

// 1. 화이트리스트 상수 관리
Expand Down Expand Up @@ -89,6 +92,7 @@ protected void doFilterInternal(HttpServletRequest request,
);

SecurityContextHolder.getContext().setAuthentication(authentication);
recordActivity(userId);

} else {
// shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<UserDailyActivity, Long> {

@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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@
public class SecurityConfig {

private final JwtProvider jwtProvider;
private final UserDailyActivityRepository userDailyActivityRepository;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
);
67 changes: 67 additions & 0 deletions src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -73,7 +75,7 @@ void setUp() {
kakaoOAuthClient, googleOAuthClient, appleOAuthClient, userRepository,
socialAccountRepository, localAccountRepository, refreshTokenRepository,
userProfileRepository, userSettingsRepository, userTendencyScoreRepository,
userWithdrawalRepository,
userWithdrawalRepository, userDailyActivityRepository,
jwtProvider, creditService, passwordEncoder
);
}
Expand Down Expand Up @@ -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
Expand Down
Loading