diff --git a/gotcha-auth/src/main/java/gotcha_auth/dto/TokenDto.java b/gotcha-auth/src/main/java/gotcha_auth/dto/TokenDto.java index 603ce077..c839428f 100644 --- a/gotcha-auth/src/main/java/gotcha_auth/dto/TokenDto.java +++ b/gotcha-auth/src/main/java/gotcha_auth/dto/TokenDto.java @@ -1,17 +1,23 @@ package gotcha_auth.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import java.time.LocalDateTime; +@JsonInclude(JsonInclude.Include.NON_NULL) public record TokenDto( String accessToken, String refreshToken, LocalDateTime accessTokenExpiredAt, - boolean autoSignIn + boolean autoSignIn, + Object warningDetails ) { public static TokenDto of(String accessToken, String refreshToken, LocalDateTime accessTokenExpiredAt) { - return new TokenDto(accessToken, refreshToken, accessTokenExpiredAt, false); + return new TokenDto(accessToken, refreshToken, accessTokenExpiredAt, false, null); } public static TokenDto of(String accessToken, String refreshToken, LocalDateTime accessTokenExpiredAt, boolean autoSignIn) { - return new TokenDto(accessToken, refreshToken, accessTokenExpiredAt, autoSignIn); + return new TokenDto(accessToken, refreshToken, accessTokenExpiredAt, autoSignIn, null); + } + public static TokenDto of(String accessToken, String refreshToken, LocalDateTime accessTokenExpiredAt, boolean autoSignIn, Object warningDetails) { + return new TokenDto(accessToken, refreshToken, accessTokenExpiredAt, autoSignIn, warningDetails); } } \ No newline at end of file diff --git a/gotcha-auth/src/main/java/gotcha_auth/jwt/JwtHelper.java b/gotcha-auth/src/main/java/gotcha_auth/jwt/JwtHelper.java index d9a263ab..29ed3ba3 100644 --- a/gotcha-auth/src/main/java/gotcha_auth/jwt/JwtHelper.java +++ b/gotcha-auth/src/main/java/gotcha_auth/jwt/JwtHelper.java @@ -46,7 +46,7 @@ private TokenDto getTokenDto(User user, String uuid, String username, boolean au userService.updateLastLogout(user, accessTokenExpiredAt); refreshTokenService.saveRefreshToken(uuid, refreshToken); - return new TokenDto(accessToken, refreshToken, accessTokenExpiredAt, autoSignIn); + return TokenDto.of(accessToken, refreshToken, accessTokenExpiredAt, autoSignIn); } public TokenDto reissueToken(String refreshToken) { @@ -75,7 +75,7 @@ public TokenDto reissueToken(String refreshToken) { refreshTokenService.deleteRefreshToken(refreshToken); refreshTokenService.saveRefreshToken(uuid, newRefreshToken); - return new TokenDto(newAccessToken, newRefreshToken, newAccessTokenExpiredAt, autoSignIn); + return TokenDto.of(newAccessToken, newRefreshToken, newAccessTokenExpiredAt, autoSignIn); } public void removeToken(String accessToken, String refreshToken, HttpServletResponse response) { diff --git a/gotcha-common/src/main/java/gotcha_common/exception/ExceptionRes.java b/gotcha-common/src/main/java/gotcha_common/exception/ExceptionRes.java index 05eaac6b..065619cd 100644 --- a/gotcha-common/src/main/java/gotcha_common/exception/ExceptionRes.java +++ b/gotcha-common/src/main/java/gotcha_common/exception/ExceptionRes.java @@ -13,7 +13,7 @@ public record ExceptionRes( String code, HttpStatus status, String message, - Map fields + Map fields ) { public static ExceptionRes from(ExceptionCode error){ return ExceptionRes.builder() @@ -23,7 +23,7 @@ public static ExceptionRes from(ExceptionCode error){ .build(); } - public static ExceptionRes from(ExceptionCode error, Map fields) { + public static ExceptionRes from(ExceptionCode error, Map fields) { return ExceptionRes.builder() .status(error.getStatus()) .code(error.getCode()) diff --git a/gotcha-common/src/main/java/gotcha_common/exception/GlobalExceptionHandler.java b/gotcha-common/src/main/java/gotcha_common/exception/GlobalExceptionHandler.java index 349aab15..63bd1ac6 100644 --- a/gotcha-common/src/main/java/gotcha_common/exception/GlobalExceptionHandler.java +++ b/gotcha-common/src/main/java/gotcha_common/exception/GlobalExceptionHandler.java @@ -94,7 +94,7 @@ public ResponseEntity handleFieldValidationException(FieldValidationException log.warn("[Field Validation Exception] {}: {}", field, message) ); ExceptionCode error = GlobalExceptionCode.FIELD_VALIDATION_ERROR; - return ResponseEntity.status(error.getStatus()).body(ExceptionRes.from(error, e.getFieldErrors())); + return ResponseEntity.status(error.getStatus()).body(ExceptionRes.from(error, new HashMap<>(e.getFieldErrors()))); } } \ No newline at end of file diff --git a/gotcha-domain/src/main/java/gotcha_domain/report/UserReport.java b/gotcha-domain/src/main/java/gotcha_domain/report/UserReport.java index 91a19042..8b398c88 100644 --- a/gotcha-domain/src/main/java/gotcha_domain/report/UserReport.java +++ b/gotcha-domain/src/main/java/gotcha_domain/report/UserReport.java @@ -33,12 +33,11 @@ public class UserReport extends BaseTimeEntity { @NotNull @Enumerated(EnumType.STRING) - private UserReportType userReportType; - - @NotNull + public UserReportType userReportType; + @Column(columnDefinition = "TEXT") private String detail; - + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; @@ -58,4 +57,5 @@ public UserReport(UserReportType userReportType, String detail, List chatLog, User user) { return new UserReport(userReportType, detail, chatLog, user); } + } diff --git a/gotcha-domain/src/main/java/gotcha_domain/report/UserReportType.java b/gotcha-domain/src/main/java/gotcha_domain/report/UserReportType.java index e02815c2..5fe07a43 100644 --- a/gotcha-domain/src/main/java/gotcha_domain/report/UserReportType.java +++ b/gotcha-domain/src/main/java/gotcha_domain/report/UserReportType.java @@ -1,9 +1,18 @@ package gotcha_domain.report; +import lombok.AllArgsConstructor; + +@AllArgsConstructor public enum UserReportType { - BAD_LANGUAGE, // 욕설 - HATE_SPEECH, // 혐오 발언 - INAPPROPRIATE_NAME, // 불쾌감을 주거나 부적절한 이름 - SPAM, // 도배 - OTHER // 기타 + BAD_LANGUAGE("욕설"), + HATE_SPEECH("혐오 발언"), + INAPPROPRIATE_NAME("불쾌감을 주거나 부적절한 이름"), + SPAM("도배"), + OTHER("기타. 신고 기록을 참조하세요."); + + String reason; + + public String getReason() { + return reason; + } } diff --git a/gotcha-domain/src/main/java/gotcha_domain/sanction/SanctionType.java b/gotcha-domain/src/main/java/gotcha_domain/sanction/SanctionType.java new file mode 100644 index 00000000..569eaf96 --- /dev/null +++ b/gotcha-domain/src/main/java/gotcha_domain/sanction/SanctionType.java @@ -0,0 +1,32 @@ +package gotcha_domain.sanction; + +import gotcha_domain.user.User; + +public enum SanctionType { + WARNING { + @Override + public void apply(User user, Long days) { + user.incrementWarningCount(); + } + }, + TEMP_BAN { + @Override + public void apply(User user, Long days) { + user.suspendUser(days); + } + }, + TEMP_BAN_CANCEL { + @Override + public void apply(User user, Long days) { + user.unsuspendUser(); // 정지 취소(즉시 해제) + } + }, + PERM_BAN { + @Override + public void apply(User user, Long days) { + user.banUser(); + } + }; + + public abstract void apply(User user, Long days); +} diff --git a/gotcha-domain/src/main/java/gotcha_domain/sanction/UserSanction.java b/gotcha-domain/src/main/java/gotcha_domain/sanction/UserSanction.java new file mode 100644 index 00000000..3bf4c2c0 --- /dev/null +++ b/gotcha-domain/src/main/java/gotcha_domain/sanction/UserSanction.java @@ -0,0 +1,74 @@ +package gotcha_domain.sanction; + +import gotcha_common.entity.BaseTimeEntity; +import gotcha_domain.report.UserReport; +import gotcha_domain.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSanction extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 제재를 받은 유저 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 조치를 내린 관리자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private User admin; + + @Enumerated(EnumType.STRING) + @Column(name = "sanction_type", nullable = false) + private SanctionType sanctionType; + + // 제재 사유 + @Column(nullable = false, columnDefinition = "TEXT") + private String reason; + + // 제재 만료 일시 + @Column(name = "expire_duration") + private Long expireDuration; + + // 근거가 된 신고(nullable) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_report_id") + private UserReport userReport; + + @Column(name = "is_read", nullable = false) + private boolean isRead = false; + + @Builder + public UserSanction(User user, User admin, SanctionType sanctionType, String reason, Long expireDuration, UserReport userReport) { + this.user = user; + this.admin = admin; + this.sanctionType = sanctionType; + this.reason = reason; + this.expireDuration = expireDuration; + this.userReport = userReport; + } + + public void markAsRead() { + this.isRead = true; + } + + +} diff --git a/gotcha-domain/src/main/java/gotcha_domain/user/User.java b/gotcha-domain/src/main/java/gotcha_domain/user/User.java index fb2d61f1..08fb85d7 100644 --- a/gotcha-domain/src/main/java/gotcha_domain/user/User.java +++ b/gotcha-domain/src/main/java/gotcha_domain/user/User.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import gotcha_common.entity.BaseTimeEntity; +import gotcha_common.exception.CustomException; import gotcha_domain.achivement.UserAchievement; import gotcha_domain.friend.Friend; import gotcha_domain.friend.FriendRequest; @@ -52,12 +53,12 @@ public class User extends BaseTimeEntity { @Enumerated(EnumType.STRING) private Role role; - private Integer warningCount; + private Integer warningCount = 0; @Setter private LocalDateTime lastLogout; - private Boolean isLocked; +// private Boolean isLocked; // UserStatus로 대체 @Setter private int level; @@ -72,6 +73,13 @@ public class User extends BaseTimeEntity { @Column(name = "chat_option", nullable = false) private ChatOption chatOption = ChatOption.ALLOW_ALL; + @Enumerated(EnumType.STRING) + @Column(name = "user_status", nullable = false) + private UserStatus userStatus = UserStatus.ACTIVE; + + @Column(name = "suspension_end_date") + private LocalDateTime suspensionEndDate; + @Enumerated(EnumType.STRING) @Column(name = "private_chat_option", nullable = false) private PrivateChatOption privateChatOption = PrivateChatOption.ALLOW; @@ -139,7 +147,35 @@ public void updateChatSettings(ChatOption chatOption, PrivateChatOption privateC this.privateChatOption = privateChatOption; } + public void incrementWarningCount() { + this.warningCount++; + } + + public void suspendUser(long days) { + this.userStatus = UserStatus.SUSPENDED; + this.suspensionEndDate = LocalDateTime.now().plusDays(days); + } + + public void unsuspendUser() { + this.userStatus = UserStatus.ACTIVE; + this.suspensionEndDate = null; + } + public void banUser(){ + this.userStatus = UserStatus.BANNED; + this.suspensionEndDate = null; + } + + @JsonIgnore + public boolean isSuspensionExpired() { + return suspensionEndDate != null && suspensionEndDate.isBefore(LocalDateTime.now()); + } + + public void checkSuspensionAndUnsuspend() { + if (isSuspensionExpired()) { + unsuspendUser(); + } + } @Override public boolean equals(Object o) { if (this == o) { diff --git a/gotcha-domain/src/main/java/gotcha_domain/user/UserStatus.java b/gotcha-domain/src/main/java/gotcha_domain/user/UserStatus.java new file mode 100644 index 00000000..ce54051c --- /dev/null +++ b/gotcha-domain/src/main/java/gotcha_domain/user/UserStatus.java @@ -0,0 +1,5 @@ +package gotcha_domain.user; + +public enum UserStatus { + ACTIVE, SUSPENDED, BANNED +} diff --git a/gotcha-socket/src/main/java/socket_server/common/exception/SocketGlobalExceptionHandler.java b/gotcha-socket/src/main/java/socket_server/common/exception/SocketGlobalExceptionHandler.java index 893d6e74..0642bcba 100644 --- a/gotcha-socket/src/main/java/socket_server/common/exception/SocketGlobalExceptionHandler.java +++ b/gotcha-socket/src/main/java/socket_server/common/exception/SocketGlobalExceptionHandler.java @@ -12,6 +12,8 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.ControllerAdvice; +import java.util.HashMap; + import static gotcha_common.exception.exceptionCode.GlobalExceptionCode.USER_NOT_FOUND; import static socket_server.common.constants.WebSocketConstants.*; @@ -27,7 +29,7 @@ public void handleSocketFieldValidationException( SimpMessageHeaderAccessor accessor ) { log.warn("[WebSocket FieldValidationException] {} - {}", e.getSource(), e.getFieldErrors()); - sendErrorToUser(e.getSource(), accessor, ExceptionRes.from(GlobalExceptionCode.FIELD_VALIDATION_ERROR, e.getFieldErrors())); + sendErrorToUser(e.getSource(), accessor, ExceptionRes.from(GlobalExceptionCode.FIELD_VALIDATION_ERROR, new HashMap<>(e.getFieldErrors()))); } @MessageExceptionHandler(SocketCustomException.class) diff --git a/gotcha/src/main/java/Gotcha/domain/auth/api/AuthApi.java b/gotcha/src/main/java/Gotcha/domain/auth/api/AuthApi.java index 6a1cf207..1046d8fc 100644 --- a/gotcha/src/main/java/Gotcha/domain/auth/api/AuthApi.java +++ b/gotcha/src/main/java/Gotcha/domain/auth/api/AuthApi.java @@ -130,13 +130,54 @@ ResponseEntity guestSignUp(@Valid @RequestBody SignUpReq signUpReq, @Operation(summary = "로그인", description = "로그인 API") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공", + @ApiResponse(responseCode = "200", description = "로그인 성공. 읽지 않은 경고가 있을 경우 warningDetails가 함께 반환됩니다.", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(value = """ + @ExampleObject(name = "일반 로그인 성공", value = """ { "expiredAt": "2025-04-10T06:57:45", "accessToken": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGdtYWlsLmNvbSIsInJvbGUiOiJVU0VSIiwidXNlcklkIjo1LCJpc3MiOiJnb3RjaGEhIiwiaWF0IjoxNzQ0MjY2NDY1LCJleHAiOjE3NDQyNjgyNjV9.u8RTE1VFsxZjQNB_dsc3ibSKqoHQGbC9-ppbOQUvzVY" } + """), + @ExampleObject(name = "경고 메시지가 있는 로그인 성공", value = """ + { + "accessToken": "Bearer eyJhbGciOiJIxzI1NiJ9...", + "refreshToken": "Bearer eyJhbGciOiJIUzI1NiJ9...", + "accessTokenExpiredAt": "2025-11-13T15:00:00", + "autoSignIn": false, + "warningDetails": { + "id": 1, + "targetUserName": "nickname123", + "adminUserName": "admin", + "sanctionType": "WARNING", + "reason": "부적절한 언어 사용", + "expiresAt": null, + "createdAt": "2025-11-13T14:00:00" + } + } + """) + })), + @ApiResponse(responseCode = "403", description = "계정이 정지됨. details에 상세 정보가 포함됩니다(일시 정지일 경우 만료 기간 포함).", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일시 정지", value = """ + { + "code": "AUTH-403-002", + "status": "FORBIDDEN", + "message": "계정이 일시 정지되었습니다.", + "details": { + "reason": "스팸 메시지 발송", + "expiresAt": "2025-12-25T00:00:00" + } + } + """), + @ExampleObject(name = "영구 정지", value = """ + { + "code": "AUTH-403-003", + "status": "FORBIDDEN", + "message": "계정이 영구 정지되었습니다.", + "details": { + "reason": "스팸 메시지 발송" + } + } """) })), @ApiResponse(responseCode = "422", description = "유효성검사 실패", diff --git a/gotcha/src/main/java/Gotcha/domain/auth/controller/AuthController.java b/gotcha/src/main/java/Gotcha/domain/auth/controller/AuthController.java index 443b3da3..50f52ba0 100644 --- a/gotcha/src/main/java/Gotcha/domain/auth/controller/AuthController.java +++ b/gotcha/src/main/java/Gotcha/domain/auth/controller/AuthController.java @@ -5,6 +5,7 @@ import Gotcha.domain.auth.dto.SignInReq; import Gotcha.domain.auth.dto.SignUpReq; import Gotcha.domain.auth.service.AuthService; +import Gotcha.domain.auth.service.SignInUseCase; import gotcha_domain.auth.SecurityUserDetails; import gotcha_auth.dto.TokenDto; import gotcha_auth.exception.JwtExceptionCode; @@ -39,6 +40,7 @@ @RequestMapping("/api/v1/auth") public class AuthController implements AuthApi { private final AuthService authService; + private final SignInUseCase signInUseCase; private final CookieUtil cookieUtil; private final MailCodeService mailCodeService; private final UserService userService; @@ -62,7 +64,7 @@ public ResponseEntity guestSignUp(@Valid @RequestBody SignUpReq signUpReq, @PostMapping("/sign-in") public ResponseEntity signIn(@Valid @RequestBody SignInReq signInReq) { - TokenDto tokenDto = authService.signIn(signInReq); + TokenDto tokenDto = signInUseCase.execute(signInReq); return createTokenRes(tokenDto, signInReq.autoSignIn()); } @@ -121,6 +123,10 @@ private ResponseEntity createTokenRes(TokenDto tokenDto, boolean autoSignIn) responseData.put("accessToken", tokenDto.accessToken()); responseData.put("expiredAt", tokenDto.accessTokenExpiredAt()); + if (tokenDto.warningDetails() != null) { + responseData.put("warningDetails", tokenDto.warningDetails()); + } + return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookieUtil.createCookie(REFRESH_COOKIE_VALUE, diff --git a/gotcha/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java b/gotcha/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java index 55096bc4..8fd4ffe5 100644 --- a/gotcha/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java +++ b/gotcha/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java @@ -9,7 +9,10 @@ public enum AuthExceptionCode implements ExceptionCode { INVALID_USERNAME_AND_PASSWORD(HttpStatus.NOT_FOUND, "AUTH-404-001", "아이디 또는 비밀번호가 유효하지 않습니다."), INVALID_GUEST(HttpStatus.BAD_REQUEST, "AUTH-400-001", "게스트가 아닙니다."), - INVALID_USERID(HttpStatus.NOT_FOUND, "AUTH-404-002", "존재하지 않는 사용자입니다."); + INVALID_USERID(HttpStatus.NOT_FOUND, "AUTH-404-002", "존재하지 않는 사용자입니다."), + ACCOUNT_SUSPENDED(HttpStatus.FORBIDDEN, "AUTH-403-002", "계정이 일시 정지되었습니다."), + ACCOUNT_BANNED(HttpStatus.FORBIDDEN, "AUTH-403-003", "계정이 영구 정지되었습니다."), + SANCTION_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH-404-003", "제재 내역이 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/gotcha/src/main/java/Gotcha/domain/auth/exception/UserAccountStatusException.java b/gotcha/src/main/java/Gotcha/domain/auth/exception/UserAccountStatusException.java new file mode 100644 index 00000000..5bd723f2 --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/auth/exception/UserAccountStatusException.java @@ -0,0 +1,20 @@ +package Gotcha.domain.auth.exception; + +import gotcha_common.exception.CustomException; +import gotcha_common.exception.exceptionCode.ExceptionCode; +import lombok.Getter; + +import java.util.Map; + +@Getter +public class UserAccountStatusException extends CustomException { + + private final Map details; + + public UserAccountStatusException(ExceptionCode exceptionCode, Map details) { + super(exceptionCode); + this.details = details; + } + +} + diff --git a/gotcha/src/main/java/Gotcha/domain/auth/exception/UserAccountStatusExceptionHandler.java b/gotcha/src/main/java/Gotcha/domain/auth/exception/UserAccountStatusExceptionHandler.java new file mode 100644 index 00000000..f9482bed --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/auth/exception/UserAccountStatusExceptionHandler.java @@ -0,0 +1,21 @@ +package Gotcha.domain.auth.exception; + +import gotcha_common.exception.ExceptionRes; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class UserAccountStatusExceptionHandler { + + @ExceptionHandler(UserAccountStatusException.class) + public ResponseEntity handleAccountStatusException(UserAccountStatusException e) { + log.error("[Account Status Exception] {}: {}", e.getExceptionCode().getMessage(), e.getDetails()); + return ResponseEntity + .status(e.getExceptionCode().getStatus()) + .body(ExceptionRes.from(e.getExceptionCode(), e.getDetails())); + } + +} diff --git a/gotcha/src/main/java/Gotcha/domain/auth/service/AuthService.java b/gotcha/src/main/java/Gotcha/domain/auth/service/AuthService.java index 47f4bba8..73bda03e 100644 --- a/gotcha/src/main/java/Gotcha/domain/auth/service/AuthService.java +++ b/gotcha/src/main/java/Gotcha/domain/auth/service/AuthService.java @@ -74,15 +74,14 @@ public TokenDto signUp(SignUpReq signUpReq) { } @Transactional - public TokenDto signIn(SignInReq signInReq){ + public User authenticate(SignInReq signInReq){ User user = userRepository.findByEmail(signInReq.email()) .orElseThrow(() -> new CustomException(AuthExceptionCode.INVALID_USERNAME_AND_PASSWORD)); if(!passwordEncoder.matches(signInReq.password(), user.getPassword())){ throw new CustomException(AuthExceptionCode.INVALID_USERNAME_AND_PASSWORD); } - - return jwtHelper.createToken(user, signInReq.autoSignIn()); + return user; } public void signOut(String HeaderAccessToken, String refreshToken, HttpServletResponse response) { diff --git a/gotcha/src/main/java/Gotcha/domain/auth/service/SignInUseCase.java b/gotcha/src/main/java/Gotcha/domain/auth/service/SignInUseCase.java new file mode 100644 index 00000000..bf54dc35 --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/auth/service/SignInUseCase.java @@ -0,0 +1,44 @@ +package Gotcha.domain.auth.service; + +import Gotcha.domain.auth.dto.SignInReq; +import Gotcha.domain.sanction.dto.SanctionRes; +import Gotcha.domain.sanction.service.SanctionService; +import gotcha_auth.dto.TokenDto; +import gotcha_auth.jwt.JwtHelper; +import gotcha_domain.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class SignInUseCase { + private final AuthService authService; + private final SanctionService sanctionService; + private final JwtHelper jwtHelper; + + @Transactional + public TokenDto execute(SignInReq signInReq) { + // 1. 사용자 인증 + User user = authService.authenticate(signInReq); + + // 2. 정지기간 만료되었는지 확인 + sanctionService.validateSuspendedEndDate(user); + + // 3. 제재/차단 상태 확인 + sanctionService.validateLoginAccess(user); + + // 4. 경고 확인 + Optional warningOpt = sanctionService.findAndMarkUnreadWarning(user); + + // 5. 토큰 생성 + TokenDto tokenDto = jwtHelper.createToken(user, signInReq.autoSignIn()); + + // 6. 경고 메시지가 있으면 토큰에 추가하여 반환 + return warningOpt + .map(warning -> TokenDto.of(tokenDto.accessToken(), tokenDto.refreshToken(), tokenDto.accessTokenExpiredAt(), tokenDto.autoSignIn(), warning)) + .orElse(tokenDto); + } +} diff --git a/gotcha/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java b/gotcha/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java index 2e439072..ca84c6d1 100644 --- a/gotcha/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java +++ b/gotcha/src/main/java/Gotcha/domain/inquiry/service/InquiryService.java @@ -25,7 +25,7 @@ public class InquiryService { private final InquiryRepository inquiryRepository; - private static final Integer INQUIRIES_PER_PAGE = 10; + private static final Integer INQUIRIES_PER_PAGE = 5; @Transactional(readOnly = true) public Page getInquiries(String keyword, Integer page, InquirySortType sort, Boolean isSolved) { diff --git a/gotcha/src/main/java/Gotcha/domain/notification/controller/AdminNotificationController.java b/gotcha/src/main/java/Gotcha/domain/notification/controller/AdminNotificationController.java index 9c06f563..73241172 100644 --- a/gotcha/src/main/java/Gotcha/domain/notification/controller/AdminNotificationController.java +++ b/gotcha/src/main/java/Gotcha/domain/notification/controller/AdminNotificationController.java @@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -27,6 +28,7 @@ public class AdminNotificationController implements AdminNotificationApi { @Override @PostMapping + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createNotification( @Valid @RequestBody NotificationReq notificationReq, @AuthenticationPrincipal SecurityUserDetails userDetails){ @@ -36,20 +38,22 @@ public ResponseEntity createNotification( @Override @PutMapping("/{notificationId}") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity updateNotification( @PathVariable(value = "notificationId") Long notificationId, @Valid @RequestBody NotificationReq notificationReq, @AuthenticationPrincipal SecurityUserDetails userDetails){ - adminNotificationService.updateNotification(notificationReq, notificationId, userDetails.getId()); + adminNotificationService.updateNotification(notificationReq, notificationId); return ResponseEntity.ok(SuccessRes.from("공지사항 수정에 성공했습니다.")); } @Override @DeleteMapping("/{notificationId}") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteNotification( @PathVariable(value = "notificationId") Long notificationId, @AuthenticationPrincipal SecurityUserDetails userDetails){ - adminNotificationService.deleteNotification(notificationId, userDetails.getId()); + adminNotificationService.deleteNotification(notificationId); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } } \ No newline at end of file diff --git a/gotcha/src/main/java/Gotcha/domain/notification/service/AdminNotificationService.java b/gotcha/src/main/java/Gotcha/domain/notification/service/AdminNotificationService.java index 1d4aae04..3d121614 100644 --- a/gotcha/src/main/java/Gotcha/domain/notification/service/AdminNotificationService.java +++ b/gotcha/src/main/java/Gotcha/domain/notification/service/AdminNotificationService.java @@ -5,11 +5,11 @@ import gotcha_common.exception.CustomException; import gotcha_domain.notification.Notification; import gotcha_domain.notification.NotificationReq; -import gotcha_domain.user.Role; import gotcha_domain.user.User; import gotcha_user.exceptionCode.UserExceptionCode; import gotcha_user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,7 +23,8 @@ public class AdminNotificationService { @Transactional public void createNotification(NotificationReq notificationReq, Long writerId){ - User writer = validateAdmin(writerId); + User writer = userRepository.findById(writerId) + .orElseThrow(() -> new CustomException(UserExceptionCode.INVALID_USERID)); Notification notification = notificationReq.toEntity(writer); @@ -32,19 +33,15 @@ public void createNotification(NotificationReq notificationReq, Long writerId){ @Transactional - public void updateNotification(NotificationReq notificationReq, Long notificationId, Long userId){ - validateAdmin(userId); - - Notification notification = validateUserNotification(notificationId, userId); + public void updateNotification(NotificationReq notificationReq, Long notificationId){ + Notification notification = validateNotification(notificationId); notification.update(notificationReq); } @Transactional - public void deleteNotification(Long notificationId, Long userId){ - validateAdmin(userId); - - Notification notification = validateUserNotification(notificationId, userId); + public void deleteNotification(Long notificationId){ + Notification notification = validateNotification(notificationId); notificationRepository.delete(notification); } @@ -58,14 +55,6 @@ private Notification validateUserNotification(Long notificationId, Long userId) return notification; } - private User validateAdmin(Long userId){ - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(UserExceptionCode.INVALID_USERID)); - if(!user.getRole().equals(Role.ADMIN)) - throw new CustomException(NotificationExceptionCode.UNAUTHORIZED_ACTION); - return user; - } - private Notification validateNotification(Long notificationId){ return notificationRepository.findById(notificationId) .orElseThrow(() -> new CustomException(NotificationExceptionCode.NOT_FOUND)); diff --git a/gotcha/src/main/java/Gotcha/domain/notification/service/NotificationService.java b/gotcha/src/main/java/Gotcha/domain/notification/service/NotificationService.java index 4566b62b..f2127a3f 100644 --- a/gotcha/src/main/java/Gotcha/domain/notification/service/NotificationService.java +++ b/gotcha/src/main/java/Gotcha/domain/notification/service/NotificationService.java @@ -21,7 +21,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; - private final Integer NOTIS_PER_PAGE = 10; + private final Integer NOTIS_PER_PAGE = 5; @Transactional(readOnly = true) diff --git a/gotcha/src/main/java/Gotcha/domain/report/dto/UserReportRes.java b/gotcha/src/main/java/Gotcha/domain/report/dto/UserReportRes.java index 308c876e..e537a9ed 100644 --- a/gotcha/src/main/java/Gotcha/domain/report/dto/UserReportRes.java +++ b/gotcha/src/main/java/Gotcha/domain/report/dto/UserReportRes.java @@ -9,6 +9,7 @@ public record UserReportRes ( Long userReportId, + String reportedUserUuid, LocalDateTime reportedAt, String nickname, UserReportType reportType, @@ -18,6 +19,7 @@ public record UserReportRes ( public static UserReportRes from(UserReport userReport) { return new UserReportRes( userReport.getId(), + userReport.getUser().getUuid(), userReport.getCreatedAt(), userReport.getUser().getNickname(), userReport.getUserReportType(), diff --git a/gotcha/src/main/java/Gotcha/domain/report/exception/ReportExceptionCode.java b/gotcha/src/main/java/Gotcha/domain/report/exception/ReportExceptionCode.java index 1e8ea27f..0630b82b 100644 --- a/gotcha/src/main/java/Gotcha/domain/report/exception/ReportExceptionCode.java +++ b/gotcha/src/main/java/Gotcha/domain/report/exception/ReportExceptionCode.java @@ -6,7 +6,8 @@ @AllArgsConstructor public enum ReportExceptionCode implements ExceptionCode { - CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "REPORT-400-001", "자기 자신을 신고할 수 없습니다."); + CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "REPORT-400-001", "자기 자신을 신고할 수 없습니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "REPORT-404-001", "신고 내역을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/gotcha/src/main/java/Gotcha/domain/report/service/UserReportService.java b/gotcha/src/main/java/Gotcha/domain/report/service/UserReportService.java index 213ffac8..9c689c66 100644 --- a/gotcha/src/main/java/Gotcha/domain/report/service/UserReportService.java +++ b/gotcha/src/main/java/Gotcha/domain/report/service/UserReportService.java @@ -40,4 +40,9 @@ public void reportUser(UserReportReq reportReq, SecurityUserDetails userDetails) userReportRepository.save(userReport); } + public UserReport findUserReportById(Long reportId){ + return userReportRepository.findById(reportId) + .orElseThrow(() -> new CustomException(ReportExceptionCode.REPORT_NOT_FOUND)); + } + } diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/api/SanctionApi.java b/gotcha/src/main/java/Gotcha/domain/sanction/api/SanctionApi.java new file mode 100644 index 00000000..962f1fdf --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/api/SanctionApi.java @@ -0,0 +1,98 @@ +package Gotcha.domain.sanction.api; + +import Gotcha.domain.sanction.dto.SanctionReq; +import Gotcha.domain.sanction.dto.SanctionRes; +import gotcha_domain.auth.SecurityUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + + + +@Tag(name = "[관리자 제재 API]", description = "관리자용 사용자 제재 관련 API") +public interface SanctionApi { + + @Operation(summary = "사용자 제재 적용 API", description = "특정 사용자에게 제재(경고, 임시 정지, 영구 정지)를 부여합니다.\n" + + "\n" + + "- targetUserUuid: 제재 대상 사용자의 UUID입니다.(필수)\n" + + "- sanctionType: 제재 유형을 나타내는 Enum입니다.(필수)\n" + + " * WARNING : 경고 1회를 부여합니다.\n" + + " * TEMP_BAN : 일정 기간 동안 계정을 정지시킵니다. durationDays가 필요합니다.\n" + + " * TEMP_BAN_CANCEL : 일정 기간 동안 정지된 계정을 다시 활성화 시킵니다. 정지 상태가 아니었다면 에러가 반환됩니다.\n" + + " * PERM_BAN : 계정을 영구 정지(영구 차단)합니다.\n" + + "\n" + + "- sourceReportId: 제재의 근거가 되는 신고 ID입니다.(필수)\n" + + "- durationDays: TEMP_BAN 제재 시 적용되는 정지 기간(일 단위)입니다. WARNING, PERM_BAN에서는 사용되지 않습니다.\n") + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "제재 조치 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "id": 1, + "targetUserName": "제재받은유저", + "adminUserName": "관리자", + "sanctionType": "TEMP_BAN", + "reason": "부적절한 언어 사용", + "expiresAt": "2025-11-18T10:00:00", + "createdAt": "2025-11-11T10:00:00" + } + """) + }) + ), + @ApiResponse(responseCode = "403", description = "관리자 권한 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "code": "AUTH-403-001", + "status": "FORBIDDEN", + "message": "접근 권한이 없습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "code": "USER-404-001", + "status": "NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "404", description = "존재하지 않는 신고 건", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "code": "REPORT-404-001", + "status": "NOT_FOUND", + "message": "신고 내역을 찾을 수 없습니다." + } + """) + }) + ), + @ApiResponse(responseCode = "400", description = "정지 상태가 아닌 유저에게 정지 취소 시", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "code": "SANCTION-400-001", + "status": "Bad Request", + "message": "해당 유저는 정지 상태가 아닙니다." + } + """) + }) + ) + }) + ResponseEntity applySanction( + @Valid @RequestBody SanctionReq sanctionReq, + SecurityUserDetails userDetails); +} diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/controller/SanctionController.java b/gotcha/src/main/java/Gotcha/domain/sanction/controller/SanctionController.java new file mode 100644 index 00000000..3c2ef519 --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/controller/SanctionController.java @@ -0,0 +1,41 @@ +package Gotcha.domain.sanction.controller; + +import Gotcha.domain.sanction.api.SanctionApi; +import Gotcha.domain.sanction.dto.SanctionReq; +import Gotcha.domain.sanction.dto.SanctionRes; +import Gotcha.domain.sanction.service.SanctionService; +import gotcha_domain.auth.SecurityUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.security.Principal; + +@RestController +@RequestMapping("/api/v1/admin/sanctions") +@RequiredArgsConstructor +public class SanctionController implements SanctionApi { + + private final SanctionService sanctionService; + + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity applySanction( + @Valid @RequestBody SanctionReq sanctionReq, + @AuthenticationPrincipal SecurityUserDetails userDetails) { + + String adminId = userDetails.getUuid(); + + SanctionRes response = sanctionService.sanctionUser(sanctionReq, adminId); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/dto/SanctionReq.java b/gotcha/src/main/java/Gotcha/domain/sanction/dto/SanctionReq.java new file mode 100644 index 00000000..a987af1f --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/dto/SanctionReq.java @@ -0,0 +1,24 @@ +package Gotcha.domain.sanction.dto; + +import gotcha_domain.sanction.SanctionType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SanctionReq { + @NotBlank(message = "제재 대상의 UUID는 필수입니다.") + private String targetUserUuId; + + // adminUserId 필드는 보안을 위해 제거하고, Controller에서 직접 인증 정보를 사용합니다. + + @NotNull(message = "제재 유형은 필수입니다.") + private SanctionType sanctionType; + + @NotNull(message = "제재의 근거가 되는 유저 신고 id는 필수입니다.") + private Long sourceReportId; // 제재의 근거가 되는 신고 ID + + private Long durationDays; // 제재 기간 (일 단위), TEMP_BAN일 때 필요 +} diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/dto/SanctionRes.java b/gotcha/src/main/java/Gotcha/domain/sanction/dto/SanctionRes.java new file mode 100644 index 00000000..86d66d37 --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/dto/SanctionRes.java @@ -0,0 +1,32 @@ +package Gotcha.domain.sanction.dto; + +import gotcha_domain.sanction.SanctionType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class SanctionRes { + private Long id; + private String targetUserName; + private String adminUserName; + private SanctionType sanctionType; + private String reason; + private Long expireDuration; + private LocalDateTime createdAt; + + public static SanctionRes fromEntity(gotcha_domain.sanction.UserSanction sanction) { + return SanctionRes.builder() + .id(sanction.getId()) + .targetUserName(sanction.getUser().getNickname()) + .adminUserName(sanction.getAdmin().getNickname()) + .sanctionType(sanction.getSanctionType()) + .reason(sanction.getReason()) + .expireDuration(sanction.getExpireDuration()) + .createdAt(sanction.getCreatedAt()) + .build(); + } + +} diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/exception/SanctionExceptionCode.java b/gotcha/src/main/java/Gotcha/domain/sanction/exception/SanctionExceptionCode.java new file mode 100644 index 00000000..d1764dac --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/exception/SanctionExceptionCode.java @@ -0,0 +1,30 @@ +package Gotcha.domain.sanction.exception; + +import gotcha_common.exception.exceptionCode.ExceptionCode; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum SanctionExceptionCode implements ExceptionCode { + NOT_SUSPENDED_USER(HttpStatus.BAD_REQUEST, "SANCTION-400-001", "해당 유저는 정지 상태가 아닙니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + @Override + public HttpStatus getStatus() { + return status; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + +} diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/repository/SanctionRepository.java b/gotcha/src/main/java/Gotcha/domain/sanction/repository/SanctionRepository.java new file mode 100644 index 00000000..a456f7f1 --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/repository/SanctionRepository.java @@ -0,0 +1,14 @@ +package Gotcha.domain.sanction.repository; + +import gotcha_domain.sanction.SanctionType; +import gotcha_domain.sanction.UserSanction; +import gotcha_domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + + +public interface SanctionRepository extends JpaRepository { + Optional findTopByUserAndIsReadIsFalseOrderByCreatedAtDesc(User user); + Optional findTopByUserAndSanctionTypeAndIsReadIsFalseOrderByCreatedAtDesc(User user, SanctionType type); +} diff --git a/gotcha/src/main/java/Gotcha/domain/sanction/service/SanctionService.java b/gotcha/src/main/java/Gotcha/domain/sanction/service/SanctionService.java new file mode 100644 index 00000000..4dd9d48a --- /dev/null +++ b/gotcha/src/main/java/Gotcha/domain/sanction/service/SanctionService.java @@ -0,0 +1,113 @@ +package Gotcha.domain.sanction.service; + +import Gotcha.domain.auth.exception.AuthExceptionCode; +import Gotcha.domain.auth.exception.UserAccountStatusException; +import Gotcha.domain.report.service.UserReportService; +import Gotcha.domain.sanction.dto.SanctionReq; +import Gotcha.domain.sanction.dto.SanctionRes; +import Gotcha.domain.sanction.exception.SanctionExceptionCode; +import Gotcha.domain.sanction.repository.SanctionRepository; +import gotcha_common.exception.CustomException; +import gotcha_domain.report.UserReport; +import gotcha_domain.sanction.SanctionType; +import gotcha_domain.sanction.UserSanction; +import gotcha_domain.user.User; +import gotcha_domain.user.UserStatus; +import gotcha_user.service.UserService; +import jakarta.persistence.TableGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class SanctionService { + + private final SanctionRepository sanctionRepository; + private final UserService userService; + private final UserReportService userReportService; + + @Transactional + public SanctionRes sanctionUser(SanctionReq sanctionReq, String adminId) { + // admin User 조회 + User adminUser = userService.findUserByUuid(adminId); + + //1. target User 조회 + User targetUser = userService.findUserByUuid(sanctionReq.getTargetUserUuId()); + + // 2. source Report 조회 + UserReport sourceReport = userReportService.findUserReportById(sanctionReq.getSourceReportId()); + String reason = sourceReport.getUserReportType().getReason(); + + // 3. 제재 유형에 따른 로직 처리 + SanctionType sanctionType = sanctionReq.getSanctionType(); + if(sanctionType == SanctionType.TEMP_BAN_CANCEL) { + if(targetUser.getUserStatus() != UserStatus.SUSPENDED) { + throw new CustomException(SanctionExceptionCode.NOT_SUSPENDED_USER); + } + } + sanctionType.apply(targetUser, sanctionReq.getDurationDays()); + + // 4. 제재 기록 저장 + UserSanction userSanction = UserSanction.builder() + .user(targetUser) + .admin(adminUser) + .sanctionType(sanctionType) + .reason(reason) + .expireDuration(sanctionReq.getDurationDays()) + .userReport(sourceReport) + .build(); + + sanctionRepository.save(userSanction); + + return SanctionRes.fromEntity(userSanction); + } + + @Transactional + public void validateLoginAccess(User user) { + if (user.getUserStatus() == UserStatus.SUSPENDED || user.getUserStatus() == UserStatus.BANNED) { + AuthExceptionCode code = user.getUserStatus() == UserStatus.SUSPENDED ? + AuthExceptionCode.ACCOUNT_SUSPENDED : AuthExceptionCode.ACCOUNT_BANNED; + + UserSanction sanction = findLatestUnread(user) + .orElseThrow(()->new CustomException(AuthExceptionCode.SANCTION_NOT_FOUND)); + sanction.markAsRead(); + + Map details = new HashMap<>(); + details.put("reason", sanction.getReason()); + if (sanction.getExpireDuration() != null) { + details.put("expireDuration", sanction.getExpireDuration()); + } + throw new UserAccountStatusException(code, details); + } + } + + @Transactional + public void validateSuspendedEndDate(User user) { + if(user.getUserStatus()==UserStatus.SUSPENDED) { + user.checkSuspensionAndUnsuspend(); + } + } + + @Transactional + public Optional findAndMarkUnreadWarning(User user) { + return findLatestUnread(user, SanctionType.WARNING) + .map(warning -> { + warning.markAsRead(); + return SanctionRes.fromEntity(warning); + }); + } + + public Optional findLatestUnread(User user) { + return sanctionRepository.findTopByUserAndIsReadIsFalseOrderByCreatedAtDesc(user); + } + + public Optional findLatestUnread(User user, SanctionType type) { + return sanctionRepository.findTopByUserAndSanctionTypeAndIsReadIsFalseOrderByCreatedAtDesc(user, type); + } + +}