Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
795e6e4
fix: 공지사항 페이지 당 5개로 수정
brothergiven Nov 10, 2025
fc912ba
feat: 사용자 제재 엔터티 클래스 작성
brothergiven Nov 10, 2025
f32d8c1
feat: 사용자 제재 DTO, Repository 작성
brothergiven Nov 10, 2025
1415a3f
feat: UserReport 조회 로직 작성
brothergiven Nov 10, 2025
dc4a2ef
feat: 유저 정지 상태를 관리하기 위한 status 필드 추가
brothergiven Nov 11, 2025
73657d5
feat: 유저 정지 처리 로직 추가(User 엔터티)
brothergiven Nov 11, 2025
ec124be
feat: 유저 제재 및 기록 저장 로직 작성
brothergiven Nov 11, 2025
c7b97ab
fix: 직접 작성한 관리자 인증 코드 PreAuthorize 애노테이션으로 대체
brothergiven Nov 11, 2025
12a893c
feat: 유저 제재 관련 컨트롤러 계층 작성
brothergiven Nov 11, 2025
37a4bd2
docs: swagger 작성
brothergiven Nov 11, 2025
beba1f5
docs: Swagger 수정
brothergiven Nov 12, 2025
4e0439e
feat: 제재 기록에 Read 필드 추가
brothergiven Nov 13, 2025
3fc76c5
feat: Read 필드 기준으로 조회하는 로직 작성
brothergiven Nov 13, 2025
90296d8
feat: details 필드 추가된 UserAccountException 예외 작성(정지, 경고 시 세부 정보 함께)
brothergiven Nov 13, 2025
c4add8c
fix: ExceptionRes DTO 수정( <String, String> -> <String, Object> )
brothergiven Nov 13, 2025
3b356ff
fix: TokenDto 필드 추가(경고/정지 메시지)
brothergiven Nov 13, 2025
d9c2cc9
feat: 로그인 시 경고/정지 처리 로직 작성
brothergiven Nov 13, 2025
ede73c8
docs: Swagger 수정
brothergiven Nov 13, 2025
4dd5e81
docs: 오타 수정
brothergiven Nov 13, 2025
8c456c1
fix: Optional 예외처리 수정
Uralauah Nov 16, 2025
06e557a
fix: 불필요 메서드 삭제
Uralauah Nov 16, 2025
f76e298
fix: warningCount 기본값 설정
Uralauah Nov 16, 2025
0d744da
fix: 로그인 순서 수정
Uralauah Nov 16, 2025
c640f7f
fix: 함수 오버로딩
Uralauah Nov 16, 2025
528dc55
fix: 책임 분리
Uralauah Nov 16, 2025
43b7b99
fix: 제재 기간 필드 수정
Uralauah Nov 16, 2025
e212092
fix: 제재 기간 필드 수정
Uralauah Nov 16, 2025
2a072ba
feat: 신고 목록 사용자 uuid 추가
Uralauah Nov 16, 2025
a6c925b
docs: SanctionReq 필드 설명 수정
Uralauah Nov 16, 2025
61e758a
fix: QnA 조회 페이지 크기 5로 수정
brothergiven Nov 16, 2025
4faa916
fix: 로그인 책임 분리
Uralauah Nov 16, 2025
cbcffc3
Merge remote-tracking branch 'origin/feat/admin' into feat/admin
Uralauah Nov 16, 2025
d643adb
Merge branch 'develop' into feat/admin
Uralauah Nov 16, 2025
e4ebcb3
docs : 스웨거 설명 추가
jung-min-ju Nov 17, 2025
fe99db9
style : 변수명 userUuid로 변경
jung-min-ju Nov 17, 2025
695deb3
add : 정지 취소 기능 추가
jung-min-ju Nov 17, 2025
148e326
docs : 정지 취소 기능 스웨거 설명 추가
jung-min-ju Nov 17, 2025
dc95a89
add : 정지 기간 끝났을 시, 유저 활성화
jung-min-ju Nov 17, 2025
f693e4b
feat : 근거 사유 서버에서 주입되도록 수정
jung-min-ju Nov 17, 2025
e77c265
docs : 스웨거 사용되지 않는 에러 설명 제거
jung-min-ju Nov 17, 2025
1c1a74d
fix: isSuspensionExpired() 직렬화 방지하여 Redis 캐시 에러 해결
jung-min-ju Nov 17, 2025
8f11cb2
Merge branch 'develop' into feat/admin
jung-min-ju Nov 18, 2025
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
12 changes: 9 additions & 3 deletions gotcha-auth/src/main/java/gotcha_auth/dto/TokenDto.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 2 additions & 2 deletions gotcha-auth/src/main/java/gotcha_auth/jwt/JwtHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public record ExceptionRes(
String code,
HttpStatus status,
String message,
Map<String, String> fields
Map<String, Object> fields
) {
public static ExceptionRes from(ExceptionCode error){
return ExceptionRes.builder()
Expand All @@ -23,7 +23,7 @@ public static ExceptionRes from(ExceptionCode error){
.build();
}

public static ExceptionRes from(ExceptionCode error, Map<String, String> fields) {
public static ExceptionRes from(ExceptionCode error, Map<String, Object> fields) {
return ExceptionRes.builder()
.status(error.getStatus())
.code(error.getCode())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -58,4 +57,5 @@ public UserReport(UserReportType userReportType, String detail, List<ChatMessage
public static UserReport of(UserReportType userReportType, String detail, List<ChatMessage> chatLog, User user) {
return new UserReport(userReportType, detail, chatLog, user);
}

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}


}
40 changes: 38 additions & 2 deletions gotcha-domain/src/main/java/gotcha_domain/user/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Comment on lines +80 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 제재 기간이 끝나면, 해당 유저의 상태를 다시 ACTIVE로 돌려주는 기능이 없는 것 같습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞네요 "임시 정지된 유저의 경우" 매 로그인 마다 시간이 지났는지 아닌지를 체크 해주는 로직으로 생각해두고 있었는데 추가한다는걸 깜빡했습니다 (__)

@Enumerated(EnumType.STRING)
@Column(name = "private_chat_option", nullable = false)
private PrivateChatOption privateChatOption = PrivateChatOption.ALLOW;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package gotcha_domain.user;

public enum UserStatus {
ACTIVE, SUSPENDED, BANNED
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand All @@ -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)
Expand Down
45 changes: 43 additions & 2 deletions gotcha/src/main/java/Gotcha/domain/auth/api/AuthApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "유효성검사 실패",
Expand Down
Loading
Loading