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
3 changes: 3 additions & 0 deletions docs/api-specs/oauth-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@
- `users`, `user_social_accounts`, `auth_refresh_tokens` 연관 데이터를 함께 처리합니다.
- 사용자 도메인 상세 정리는 `user` 정책에 따라 함께 처리합니다.
- 탈퇴 사유는 별도 이력 테이블에 저장합니다.
- 운영 중인 클래스는 소프트 삭제해 새 참여·운영을 막고, 참여 중 클래스 멤버십은 `LEFT`로 바꿉니다.
- 투표 등 활동 이력이 있는 멤버의 좌석은 유지하고, 활동이 없으면 좌석을 반환합니다.
- 작성한 의견·댓글은 공개 화면에서 숨기며, 리포트·이력 참조를 위해 행은 보존합니다.

요청 헤더:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand All @@ -16,6 +17,9 @@ public interface ClassMemberRepository extends JpaRepository<ClassMember, Long>
/** 참여용: 상태와 무관하게 이 유저의 멤버 행 (UK(class_id, user_id) 라 최대 1건) */
Optional<ClassMember> findByClassroomIdAndUserId(Long classroomId, Long userId);

@EntityGraph(attributePaths = "classroom")
List<ClassMember> findByUserIdAndStatus(Long userId, ClassMemberStatus status);

Optional<ClassMember> findByIdAndClassroomId(Long id, Long classroomId);

/** 의견·댓글 작성자 표시 이름 일괄 조회용. 나간·내보낸 멤버의 글은 숨기므로 ACTIVE 만 조회한다 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public interface ClassroomRepository extends JpaRepository<Classroom, Long> {

Optional<Classroom> findByIdAndStatus(Long id, ClassStatus status);

List<Classroom> findByOwnerIdAndStatus(Long ownerId, ClassStatus status);

/** 중복 생성 판정용: 같은 운영자가 같은 요청 키로 만든 클래스 */
Optional<Classroom> findByOwnerIdAndCreateRequestKey(Long ownerId, String createRequestKey);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.swyp.picke.domain.user.entity.User;
import com.swyp.picke.domain.user.repository.UserBattleRepository;
import com.swyp.picke.domain.user.repository.UserRepository;
import com.swyp.picke.domain.user.enums.UserStatus;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
import java.time.LocalDateTime;
Expand Down Expand Up @@ -59,6 +60,9 @@ public ClassDetailResponse join(Long userId, ClassJoinRequest request) {
LocalDateTime now = LocalDateTime.now(KST);
String inviteCode = normalizeInviteCode(request.inviteCode());
String displayName = normalizeDisplayName(request.displayName());
User user = userRepository.findByIdForUpdate(userId)
.filter(candidate -> candidate.getStatus() == UserStatus.ACTIVE)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

Classroom classroom = classroomRepository.findByInviteCodeAndStatusForUpdate(inviteCode, ClassStatus.ACTIVE)
.orElseThrow(() -> codeNotUsable(inviteCode));
Expand All @@ -77,8 +81,6 @@ public ClassDetailResponse join(Long userId, ClassJoinRequest request) {
if (existing.isPresent()) {
existing.get().rejoin(displayName, now);
} else {
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
classMemberRepository.save(ClassMember.builder()
.classroom(classroom)
.user(user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.swyp.picke.domain.ticket.enums.TicketStatus;
import com.swyp.picke.domain.ticket.repository.ClassTicketRepository;
import com.swyp.picke.domain.user.entity.User;
import com.swyp.picke.domain.user.enums.UserStatus;
import com.swyp.picke.domain.user.repository.UserRepository;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
Expand Down Expand Up @@ -77,8 +78,11 @@ public class ClassroomServiceImpl implements ClassroomService {
@Transactional
public ClassDetailResponse create(Long userId, ClassCreateRequest request) {
LocalDateTime now = LocalDateTime.now(KST);
User owner = userRepository.findById(userId)
User owner = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
if (owner.getStatus() != UserStatus.ACTIVE) {
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}
ClassTicket ticket = findUsableTicket(userId, now);
if ((request.battleId() == null) == (request.topicVersionId() == null)) {
throw new CustomException(ErrorCode.CLASS_BATTLE_SOURCE_INVALID);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.swyp.picke.domain.classroom.service;

import com.swyp.picke.domain.classroom.entity.ClassMember;
import com.swyp.picke.domain.classroom.entity.Classroom;
import com.swyp.picke.domain.classroom.enums.ClassMemberStatus;
import com.swyp.picke.domain.classroom.enums.ClassStatus;
import com.swyp.picke.domain.classroom.repository.ClassMemberRepository;
import com.swyp.picke.domain.classroom.repository.ClassroomRepository;
import com.swyp.picke.domain.perspective.enums.PerspectiveStatus;
import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository;
import com.swyp.picke.domain.perspective.repository.PerspectiveRepository;
import com.swyp.picke.domain.user.entity.User;
import com.swyp.picke.domain.user.repository.UserBattleRepository;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/** Applies account-withdrawal policy to live class memberships and authored discussion content. */
@Service
@RequiredArgsConstructor
public class UserClassWithdrawalService {

private final ClassroomRepository classroomRepository;
private final ClassMemberRepository classMemberRepository;
private final UserBattleRepository userBattleRepository;
private final PerspectiveRepository perspectiveRepository;
private final PerspectiveCommentRepository perspectiveCommentRepository;

@Transactional
public void apply(User user, LocalDateTime now) {
Long userId = user.getId();

// A deleted owner cannot continue operating an active class. Keep historical records for reports/audit.
List<Classroom> ownedClasses = classroomRepository.findByOwnerIdAndStatus(userId, ClassStatus.ACTIVE);
ownedClasses.forEach(classroom -> classroom.delete(now));

// Match the normal leave rule: a member who has started the class keeps their seat consumed.
List<ClassMember> activeMemberships = classMemberRepository.findByUserIdAndStatus(userId, ClassMemberStatus.ACTIVE);
Set<Long> activeClassroomIds = activeMemberships.stream()
.map(member -> member.getClassroom().getId()).collect(java.util.stream.Collectors.toSet());
Set<Long> classroomsWithActivity = activeClassroomIds.isEmpty() ? Set.of()
: userBattleRepository.findClassroomIdsWithActivity(userId, activeClassroomIds);
for (ClassMember member : activeMemberships) {
boolean hasActivity = classroomsWithActivity.contains(member.getClassroom().getId());
member.leave(now, hasActivity);
}

// Hide authored text from public and class screens while preserving rows referenced by historical records.
perspectiveRepository.hideAllByUserId(userId, PerspectiveStatus.HIDDEN);
perspectiveCommentRepository.hideAllByUserId(userId);
}
}
37 changes: 34 additions & 3 deletions src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import com.swyp.picke.domain.user.repository.UserDailyActivityRepository;
import com.swyp.picke.domain.user.repository.UserRepository;
import com.swyp.picke.global.common.exception.ErrorCode;
import com.swyp.picke.global.common.response.ApiResponse;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
Expand All @@ -22,13 +22,25 @@
import java.util.Set;

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

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

public JwtFilter(JwtProvider jwtProvider, UserDailyActivityRepository userDailyActivityRepository,
UserRepository userRepository) {
this.jwtProvider = jwtProvider;
this.userDailyActivityRepository = userDailyActivityRepository;
this.userRepository = userRepository;
}

/** Compatibility constructor for isolated filter tests; production wiring always supplies UserRepository. */
public JwtFilter(JwtProvider jwtProvider, UserDailyActivityRepository userDailyActivityRepository) {
this(jwtProvider, userDailyActivityRepository, null);
}

// 1. 화이트리스트 상수 관리
private static final List<String> WHITELIST = List.of(
"/api/v1/admob/reward",
Expand Down Expand Up @@ -96,6 +108,11 @@ protected void doFilterInternal(HttpServletRequest request,
}

Long userId = jwtProvider.getUserId(token);
ErrorCode inactiveAccountError = accountError(userId);
if (inactiveAccountError != null) {
setErrorResponse(response, inactiveAccountError);
return;
}
String role = jwtProvider.getRole(token);
String authorityName = (role != null && role.startsWith("ROLE_")) ? role : "ROLE_" + role;

Expand Down Expand Up @@ -124,6 +141,20 @@ protected void doFilterInternal(HttpServletRequest request,
}
}

private ErrorCode accountError(Long userId) {
if (userRepository == null) {
return null;
}
return userRepository.findById(userId)
.map(user -> switch (user.getStatus()) {
case ACTIVE, PENDING -> null;
case SUSPENDED -> ErrorCode.USER_SUSPENDED;
case BANNED -> ErrorCode.USER_BANNED;
case DELETED -> ErrorCode.AUTH_UNAUTHORIZED;
})
.orElse(ErrorCode.AUTH_UNAUTHORIZED);
}

/**
* DAU/MAU 집계를 위한 활동 기록. 대시보드 집계용 부가 기능이라 실패해도 인증 흐름을 막지 않는다.
*/
Expand Down Expand Up @@ -163,4 +194,4 @@ private boolean isWhitelisted(String uri) {
}
return WHITELIST.stream().anyMatch(white -> uri.equals(white) || uri.startsWith(white));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository;
import com.swyp.picke.domain.user.repository.UserWithdrawalRepository;
import com.swyp.picke.domain.user.service.CreditService;
import com.swyp.picke.domain.classroom.service.UserClassWithdrawalService;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -99,6 +100,7 @@ public class AuthService {
private final UserDailyActivityRepository userDailyActivityRepository;
private final JwtProvider jwtProvider;
private final CreditService creditService;
private final UserClassWithdrawalService userClassWithdrawalService;
private final PasswordEncoder passwordEncoder;

public LoginResponse login(String provider, LoginRequest request) {
Expand Down Expand Up @@ -272,14 +274,16 @@ public void logout(Long userId) {
}

public void withdraw(Long userId, WithdrawRequest request) {
User user = userRepository.findById(userId)
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
refreshTokenRepository.deleteByUser(user);

if (user.getStatus() == UserStatus.DELETED) {
throw new CustomException(ErrorCode.USER_ALREADY_WITHDRAWN);
}

userClassWithdrawalService.apply(user, LocalDateTime.now(java.time.ZoneId.of("Asia/Seoul")));

if (!userWithdrawalRepository.existsByUser_Id(userId)) {
userWithdrawalRepository.save(UserWithdrawal.builder()
.user(user)
Expand Down Expand Up @@ -371,4 +375,4 @@ private String hashToken(String token) {
throw new RuntimeException("토큰 해시 실패", e);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
import com.swyp.picke.domain.user.entity.User;
import com.swyp.picke.domain.user.entity.UserBattle;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
import java.util.Collection;
import java.util.Set;

public interface UserBattleRepository extends JpaRepository<UserBattle, Long> {

Expand All @@ -17,6 +21,10 @@ public interface UserBattleRepository extends JpaRepository<UserBattle, Long> {
// 클래스 내 활동 이력(사전투표 이후) 존재 여부 — 나가기/내보내기 시 좌석 유지 판정(F15)
boolean existsByUserIdAndClassroomId(Long userId, Long classroomId);

@Query("select distinct ub.classroom.id from UserBattle ub where ub.user.id = :userId and ub.classroom.id in :classroomIds")
Set<Long> findClassroomIdsWithActivity(@Param("userId") Long userId,
@Param("classroomIds") Collection<Long> classroomIds);

// 클래스 결과 집계·리포트 대상 선별: 이 클래스 안의 진행 기록 전체 (user 는 LAZY)
List<UserBattle> findByClassroomId(Long classroomId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import jakarta.persistence.LockModeType;
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.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -20,6 +22,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findTopByOrderByIdDesc();
boolean existsByUserTag(String userTag);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);

@Query("select u.credit from User u where u.id = :id")
Integer findCreditById(@Param("id") Long id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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 com.swyp.picke.domain.user.repository.UserRepository;
import java.util.Arrays;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -30,6 +31,7 @@ public class SecurityConfig {

private final JwtProvider jwtProvider;
private final UserDailyActivityRepository userDailyActivityRepository;
private final UserRepository userRepository;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand Down Expand Up @@ -77,7 +79,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(new JwtFilter(jwtProvider, userDailyActivityRepository),
.addFilterBefore(new JwtFilter(jwtProvider, userDailyActivityRepository, userRepository),
UsernamePasswordAuthenticationFilter.class);

return http.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository;
import com.swyp.picke.domain.user.repository.UserWithdrawalRepository;
import com.swyp.picke.domain.user.service.CreditService;
import com.swyp.picke.domain.classroom.service.UserClassWithdrawalService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand Down Expand Up @@ -64,6 +65,7 @@ class OAuthServiceTest {
@Mock private UserDailyActivityRepository userDailyActivityRepository;
@Mock private JwtProvider jwtProvider;
@Mock private CreditService creditService;
@Mock private UserClassWithdrawalService userClassWithdrawalService;
@Mock private PasswordEncoder passwordEncoder;

private AuthService authService;
Expand All @@ -76,7 +78,7 @@ void setUp() {
socialAccountRepository, localAccountRepository, refreshTokenRepository,
userProfileRepository, userSettingsRepository, userTendencyScoreRepository,
userWithdrawalRepository, userDailyActivityRepository,
jwtProvider, creditService, passwordEncoder
jwtProvider, creditService, userClassWithdrawalService, passwordEncoder
);
}

Expand All @@ -98,7 +100,7 @@ void setUp() {
when(kakaoOAuthClient.getAccessToken(anyString(), anyString())).thenReturn("mock-access-token");
when(kakaoOAuthClient.getUserInfo(anyString())).thenReturn(userInfo); // 여기서 null이 안 들어가게 고정

var socialAccount = mock(com.swyp.picke.domain.oauth.entity.UserSocialAccount.class);
var socialAccount = mock(UserSocialAccount.class);
when(socialAccount.getUser()).thenReturn(user);
when(socialAccountRepository.findByProviderAndProviderUserId(anyString(), anyString()))
.thenReturn(Optional.of(socialAccount));
Expand Down Expand Up @@ -395,4 +397,4 @@ void setUp() {
verify(socialAccountRepository, times(1)).delete(socialAccount);
assertThat(user.getStatus()).isEqualTo(UserStatus.DELETED);
}
}
}
Loading