Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.example.solidconnection.admin.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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 com.example.solidconnection.admin.dto.UserBanRequest;
import com.example.solidconnection.admin.service.AdminUserBanService;
import com.example.solidconnection.common.resolver.AuthorizedUser;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@RequestMapping("/admin/users")
@RestController
public class AdminUserBanController {
private final AdminUserBanService adminUserBanService;

@PostMapping("/{user-id}/ban")
public ResponseEntity<Void> banUser(
@AuthorizedUser long adminId,
@PathVariable(name = "user-id") long userId,
@Valid @RequestBody UserBanRequest request
) {
adminUserBanService.banUser(userId, adminId, request);
return ResponseEntity.ok().build();
}

@PatchMapping("/{user-id}/unban")
public ResponseEntity<Void> unbanUser(
@AuthorizedUser long adminId,
@PathVariable(name = "user-id") long userId
) {
adminUserBanService.unbanUser(userId, adminId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.admin.dto;

import com.example.solidconnection.siteuser.domain.UserBanDuration;

import jakarta.validation.constraints.NotNull;

public record UserBanRequest(
@NotNull(message = "차단 기간을 입력해주세요.")
UserBanDuration duration
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.example.solidconnection.admin.service;

import static java.time.ZoneOffset.UTC;

import com.example.solidconnection.admin.dto.UserBanRequest;
import com.example.solidconnection.chat.repository.ChatMessageRepository;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.exception.ErrorCode;
import com.example.solidconnection.community.post.repository.PostRepository;
import com.example.solidconnection.report.repository.ReportRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.domain.UserBan;
import com.example.solidconnection.siteuser.domain.UserStatus;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.siteuser.repository.UserBanRepository;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
Copy link
Member

Choose a reason for hiding this comment

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

마찬가지입니다 !

@RequiredArgsConstructor
@Service
public class AdminUserBanService {

private final UserBanRepository userBanRepository;
private final ReportRepository reportRepository;
private final SiteUserRepository siteUserRepository;
private final PostRepository postRepository;
private final ChatMessageRepository chatMessageRepository;

@Transactional
public void banUser(long userId, long adminId, UserBanRequest request) {
SiteUser user = siteUserRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
validateNotAlreadyBanned(userId);
validateReportExists(userId);

user.updateUserStatus(UserStatus.BANNED);
updateReportedContentIsDeleted(userId, true);
createUserBan(userId, adminId, request);
}
Comment on lines +35 to +45
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

차단 처리 순서/원자성(동시성) 보강이 필요해요.

  1. (동시성) validateNotAlreadyBanned()save() 사이 TOCTOU
    • 동시에 두 번 호출되면 “둘 다 미차단”으로 통과하고 ban 레코드가 중복 생성될 수 있어요.
  2. (정합성) SiteUser를 먼저 로드하는데, 이미 차단 상태면 불필요 조회
    • validateNotAlreadyBanned(userId)를 먼저 두면 DB 호출 1회 줄일 수 있어요(성능/명확성).


private void validateNotAlreadyBanned(long userId) {
if (userBanRepository.existsByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC))) {
throw new CustomException(ErrorCode.ALREADY_BANNED_USER);
}
}
Comment on lines +47 to +51
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "AdminUserBanService.java" -type f

Repository: solid-connection/solid-connect-server

Length of output: 163


🏁 Script executed:

cat -n ./src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java

Repository: solid-connection/solid-connect-server

Length of output: 5608


🏁 Script executed:

find . -name "UserBan.java" -type f | head -5

Repository: solid-connection/solid-connect-server

Length of output: 153


🏁 Script executed:

cat -n ./src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java

Repository: solid-connection/solid-connect-server

Length of output: 2262


🏁 Script executed:

find . -name "UserBanRepository.java" -type f

Repository: solid-connection/solid-connect-server

Length of output: 167


🏁 Script executed:

cat -n ./src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java

Repository: solid-connection/solid-connect-server

Length of output: 1365


시간 기준 만료와 배치 실행 사이의 시간차로 활성 차단 검증이 누락될 수 있습니다.

현재 로직의 동작 방식:

  1. validateNotAlreadyBanned() (47-51줄)와 findActiveBan() (82-86줄)은 모두 isExpired=false AND expiredAt > now 조건으로 활성 차단 여부를 판단합니다.
  2. 배치 작업은 매일 자정(0 0 0 * * *)에만 isExpired를 true로 업데이트합니다.

발생 가능한 문제:

  1. 중복 차단 생성: expiredAt ≤ now인데 배치가 아직 실행되지 않으면, 쿼리는 그 차단을 찾지 못하므로 banUser()는 새로운 차단을 또 생성할 수 있습니다.
  2. 수동 해제 불가: 같은 시간 구간에 unbanUser() 호출 시 findActiveBan()이 레코드를 찾지 못해 NOT_BANNED_USER 예외를 던지므로 관리자의 수동 해제가 막힙니다.

개선 방향:

  • 차단 검증/조회 경로에서 시간 기준으로 만료된 차단을 즉시 마킹하거나,
  • DB 레벨에서 사용자당 활성 차단 1개 제약 조건과 원자적 업데이트를 결합하여 중복을 구조적으로 방지하는 것이 안전합니다.


private void validateReportExists(long userId) {
if (!reportRepository.existsByReportedId(userId)) {
throw new CustomException(ErrorCode.REPORT_NOT_FOUND);
}
}

private void updateReportedContentIsDeleted(long userId, boolean isDeleted) {
postRepository.updateReportedPostsIsDeleted(userId, isDeleted);
chatMessageRepository.updateReportedChatMessagesIsDeleted(userId, isDeleted);
}
Comment on lines +59 to +62
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# AdminUserBanService 파일 내용 확인
wc -l src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java

Repository: solid-connection/solid-connect-server

Length of output: 218


🏁 Script executed:

# 업데이트 쿼리 메서드 위치 찾기
rg -n "updateReportedPostsIsDeleted|updateReportedChatMessagesIsDeleted" --type java

Repository: solid-connection/solid-connect-server

Length of output: 824


🏁 Script executed:

# Post 및 ChatMessage 저장소의 실제 구현 확인
find src -name "*Repository.java" -type f | grep -E "(Post|ChatMessage)" | head -5

Repository: solid-connection/solid-connect-server

Length of output: 438


🏁 Script executed:

# MyBatis mapper XML 파일 찾기
find src -name "*.xml" -type f | head -20

Repository: solid-connection/solid-connect-server

Length of output: 118


🏁 Script executed:

# PostRepository 및 ChatMessageRepository의 SQL 매퍼 찾기
find src/main/resources -name "*Mapper.xml" -o -name "*mapper.xml" | grep -i -E "(post|chat)"

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# 전체 resources 디렉토리 구조 확인
find src/main/resources -type f | head -30

Repository: solid-connection/solid-connect-server

Length of output: 2117


🏁 Script executed:

# PostRepository 전체 내용 확인
cat -n src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java

Repository: solid-connection/solid-connect-server

Length of output: 3956


🏁 Script executed:

# ChatMessageRepository 전체 내용 확인
cat -n src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java

Repository: solid-connection/solid-connect-server

Length of output: 3701


🏁 Script executed:

# Post 엔티티 확인 (deletion reason 관련 필드 확인)
find src -name "Post.java" -type f | grep -v Test

Repository: solid-connection/solid-connect-server

Length of output: 154


🏁 Script executed:

# Post 엔티티 확인 - 삭제 이유 필드 존재 여부
cat -n src/main/java/com/example/solidconnection/community/post/domain/Post.java

Repository: solid-connection/solid-connect-server

Length of output: 3461


🏁 Script executed:

# ChatMessage 엔티티 확인
find src -name "ChatMessage.java" -path "*/domain/*" | grep -v Test

Repository: solid-connection/solid-connect-server

Length of output: 151


🏁 Script executed:

# Report 테이블/엔티티 확인
find src -name "Report.java" -path "*/domain/*" | grep -v Test

Repository: solid-connection/solid-connect-server

Length of output: 148


🏁 Script executed:

# AdminUserBanService의 ban/unban 로직 확인
cat -n src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java | head -150

Repository: solid-connection/solid-connect-server

Length of output: 5608


🏁 Script executed:

# Report 엔티티 확인
cat -n src/main/java/com/example/solidconnection/report/domain/Report.java

Repository: solid-connection/solid-connect-server

Length of output: 2162


🏁 Script executed:

# ChatMessage 엔티티 확인
cat -n src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java

Repository: solid-connection/solid-connect-server

Length of output: 2262


🏁 Script executed:

# 사용자가 자신의 콘텐츠를 삭제하는 다른 경로 확인
rg -n "isDeleted.*true" src/main/java/com/example/solidconnection/community/post/ --type java -A2

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# 사용자가 자신의 채팅을 삭제하는 다른 경로 확인
rg -n "isDeleted.*true" src/main/java/com/example/solidconnection/chat/ --type java -A2

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# Post 삭제 메서드 찾기
rg -n "setDeleted|delete\(" src/main/java/com/example/solidconnection/community/post/ --type java -A3

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# ChatMessage 삭제 메서드 찾기
rg -n "setDeleted|delete\(" src/main/java/com/example/solidconnection/chat/ --type java -A3

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# Post를 삭제하는 모든 서비스 로직 확인
find src/main/java/com/example/solidconnection -name "*Service.java" -exec grep -l "PostRepository\|updateReportedPostsIsDeleted" {} \;

Repository: solid-connection/solid-connect-server

Length of output: 680


🏁 Script executed:

# 삭제 관련 메서드와 호출 흐름 확인
rg -n "updateReportedPostsIsDeleted|updateReportedChatMessagesIsDeleted|bulkUpdateReportedPostsIsDeleted|bulkUpdateReportedChatMessagesIsDeleted" src/main/java --type java -B5 -A5

Repository: solid-connection/solid-connect-server

Length of output: 7857


신고 콘텐츠의 삭제 이유를 추적하지 않아, 차단 해제 시 다른 사유로 삭제된 콘텐츠까지 복구될 위험이 있습니다.

현재 코드 분석 결과:

  1. 엔티티 레벨에서의 문제

    • Post, ChatMessage 엔티티가 isDeleted 불린 플래그만 보유
    • 삭제 사유(차단, 사용자 삭제, 정책 삭제 등)를 구분하는 필드 없음
  2. 리스크 시나리오

    • 사용자가 게시한 콘텐츠가 신고 당함 → Report 생성
    • 관리자가 해당 사용자 차단 → updateReportedPostsIsDeleted(userId, true) 실행
    • 나중에 차단 해제 → updateReportedPostsIsDeleted(userId, false) 실행
    • 만약 해당 콘텐츠가 차단 전에 정책 위반으로 관리자가 삭제했다면, 차단 해제 시 정책 삭제된 콘텐츠까지 복구됨
  3. 현재 쿼리의 한계

    • 저장소 메서드는 p.id IN (SELECT r.target_id FROM report WHERE target_type='POST') 조건으로 필터링
    • Report 존재 여부는 확인하나, 삭제된 "이유"는 확인하지 않음
  4. 개선 방향

    • 최소한 deletedReason, deletedByBan 같은 필드 추가하여 차단으로 삭제된 콘텐츠만 복구
    • 또는 업데이트 쿼리에 WHERE is_deleted = true AND deleted_reason = 'BAN' 같은 조건 추가
    • Report 테이블에 ban 상태 추적하는 방식도 검토 가능
🤖 Prompt for AI Agents
In
@src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java
around lines 59 - 62, The current updateReportedContentIsDeleted method (calling
postRepository.updateReportedPostsIsDeleted and
chatMessageRepository.updateReportedChatMessagesIsDeleted) toggles isDeleted for
all reported content without tracking why it was deleted, risking restoring
items deleted for other reasons; modify the domain by adding a deletion-reason
field (e.g., deletedReason or deletedByBan) to Post and ChatMessage (or track
ban deletions in Report), and change the repository update queries to only
clear/delete state when deletedByBan/ deletedReason == 'BAN' (or set
deletedByBan flag on ban) so updateReportedContentIsDeleted only affects items
marked deleted due to the ban.


private void createUserBan(long userId, long adminId, UserBanRequest request) {
ZonedDateTime now = ZonedDateTime.now(UTC);
ZonedDateTime expiredAt = now.plusDays(request.duration().getDays());
UserBan userBan = new UserBan(userId, adminId, request.duration(), expiredAt);
userBanRepository.save(userBan);
}

@Transactional
public void unbanUser(long userId, long adminId) {
SiteUser user = siteUserRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
UserBan userBan = findActiveBan(userId);
userBan.manuallyUnban(adminId);

user.updateUserStatus(UserStatus.REPORTED);
updateReportedContentIsDeleted(userId, false);
}

private UserBan findActiveBan(long userId) {
return userBanRepository
.findByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC))
.orElseThrow(() -> new CustomException(ErrorCode.NOT_BANNED_USER));
}

@Transactional
@Scheduled(cron = "0 0 0 * * *")
public void expireUserBans() {
try {
ZonedDateTime now = ZonedDateTime.now(UTC);
List<Long> expiredUserIds = userBanRepository.findExpiredBannedUserIds(now);

if (expiredUserIds.isEmpty()) {
return;
}

userBanRepository.bulkExpireUserBans(now);
siteUserRepository.bulkUpdateUserStatus(expiredUserIds, UserStatus.REPORTED);
bulkUpdateReportedContentIsDeleted(expiredUserIds);
log.info("Finished processing expired blocks:: userIds={}", expiredUserIds);
} catch (Exception e) {
log.error("Failed to process expired blocks", e);
}
}
Comment on lines +88 to +106
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

@Transactional 메서드에서 예외를 삼키면 ‘부분 커밋’이 나올 수 있어요.

  1. 현재 구조
    • @Transactional + try/catch(Exception)로 전체를 감싸고 로깅만 함
  2. 문제
    • 중간에 예외가 나도 catch로 먹어버리면 트랜잭션이 롤백되지 않고 “어디까지는 반영, 어디까지는 미반영”이 될 수 있어요(특히 bulk update 조합에서 치명적).
  3. 추천
    • 예외는 밖으로 던져서 스프링이 롤백하게 두는 편이 안전해요(스케줄러는 다음 주기에 다시 돕니다).
    • 로깅이 필요하면 catch 후 throw 하거나, rollback-only 설정을 명시하세요.
변경 예시(예외 삼키지 않기)
     @Transactional
     @Scheduled(cron = "0 0 0 * * *")
     public void expireUserBans() {
-        try {
-            ZonedDateTime now = ZonedDateTime.now(UTC);
-            List<Long> expiredUserIds = userBanRepository.findExpiredBannedUserIds(now);
+        ZonedDateTime now = ZonedDateTime.now(UTC);
+        List<Long> expiredUserIds = userBanRepository.findExpiredBannedUserIds(now);

-            if (expiredUserIds.isEmpty()) {
-                return;
-            }
+        if (expiredUserIds.isEmpty()) {
+            return;
+        }

-            userBanRepository.bulkExpireUserBans(now);
-            siteUserRepository.bulkUpdateUserStatus(expiredUserIds, UserStatus.REPORTED);
-            bulkUpdateReportedContentIsDeleted(expiredUserIds);
-            log.info("Finished processing expired blocks:: userIds={}", expiredUserIds);
-        } catch (Exception e) {
-            log.error("Failed to process expired blocks", e);
-        }
+        userBanRepository.bulkExpireUserBans(now);
+        siteUserRepository.bulkUpdateUserStatus(expiredUserIds, UserStatus.REPORTED);
+        bulkUpdateReportedContentIsDeleted(expiredUserIds);
+        log.info("Finished processing expired bans:: userIds={}", expiredUserIds);
     }
🤖 Prompt for AI Agents
In
@src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java
around lines 88 - 106, The expireUserBans() method is swallowing all exceptions
inside a try/catch which can prevent the @Transactional rollback and cause
partial commits; remove the broad try/catch or rethrow the exception after
logging so Spring can trigger transaction rollback. Specifically, update
expireUserBans() to let exceptions propagate (or if you must log, catch
Exception e, call log.error(..., e) then throw e) so that
userBanRepository.bulkExpireUserBans(...),
siteUserRepository.bulkUpdateUserStatus(...), and
bulkUpdateReportedContentIsDeleted(...) run inside a transactional boundary that
will roll back on error.


private void bulkUpdateReportedContentIsDeleted(List<Long> expiredUserIds) {
postRepository.bulkUpdateReportedPostsIsDeleted(expiredUserIds, false);
chatMessageRepository.bulkUpdateReportedChatMessagesIsDeleted(expiredUserIds, false);
}

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package com.example.solidconnection.auth.dto;

import com.example.solidconnection.siteuser.domain.AuthType;
import com.example.solidconnection.siteuser.domain.ExchangeStatus;
import com.example.solidconnection.siteuser.domain.Role;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
Expand All @@ -20,27 +17,4 @@ public record SignUpRequest(

@NotBlank(message = "닉네임을 입력해주세요.")
String nickname) {

public SiteUser toOAuthSiteUser(String email, AuthType authType) {
return new SiteUser(
email,
this.nickname,
this.profileImageUrl,
this.exchangeStatus,
Role.MENTEE,
authType
);
}

public SiteUser toEmailSiteUser(String email, String encodedPassword) {
return new SiteUser(
email,
this.nickname,
this.profileImageUrl,
this.exchangeStatus,
Role.MENTEE,
AuthType.EMAIL,
encodedPassword
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.example.solidconnection.siteuser.domain.AuthType;
import com.example.solidconnection.siteuser.domain.Role;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.domain.UserStatus;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -56,7 +57,8 @@ public SignInResponse signUp(SignUpRequest signUpRequest) {
signUpRequest.exchangeStatus(),
Role.MENTEE,
authType,
password
password,
UserStatus.ACTIVE
));

// 관심 지역, 국가 저장
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Where;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "is_deleted = false")
Copy link
Member

Choose a reason for hiding this comment

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

이후 ChatMessageRepository에서 사용되는 네이티브 쿼리는 @Where 어노테이션을 우회하기에, 해당 쿼리에는 명시적으로 조건을 다는 게 좋을 거 같습니다

Copy link
Contributor

Choose a reason for hiding this comment

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

이런 기능은 처음 알았네요!
is_deleted를 놓치지 않기 위해 사용하신 거 같은데 성혁님이 말씀해주신 건 반영하면 좋겠네요!

Copy link
Author

Choose a reason for hiding this comment

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

@Query(value = "...", nativeQuery = true)
처럼 nativeQuery = true 옵션을 추가하지 않을 경우에는 @Where 어노테이션이 적용됩니다. 또 추가해야할 부분 있을까요?

public class ChatMessage extends BaseEntity {

@Id
Expand All @@ -33,6 +35,9 @@ public class ChatMessage extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
private ChatRoom chatRoom;

@Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false)
private boolean isDeleted = false;

@OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true)
private final List<ChatAttachment> chatAttachments = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand Down Expand Up @@ -48,4 +49,20 @@ SELECT MAX(cm2.id)
GROUP BY cm.chatRoom.id
""")
List<UnreadCountDto> countUnreadMessagesBatch(@Param("chatRoomIds") List<Long> chatRoomIds, @Param("userId") long userId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
UPDATE chat_message cm SET cm.is_deleted = :isDeleted
WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT')
AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id = :siteUserId)
""", nativeQuery = true)
void updateReportedChatMessagesIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted);
Comment on lines +53 to +59
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# 마이그레이션 파일 디렉토리 위치 확인
find . -type d -name "migration" -o -name "db" | head -20

Repository: solid-connection/solid-connect-server

Length of output: 138


🏁 Script executed:

# 마이그레이션 파일 목록 확인
ls -la ./src/main/resources/db/migration/

echo -e "\n=== 전체 파일 목록 ==="
find ./src/main/resources/db/migration -type f -name "*.sql" | sort

Repository: solid-connection/solid-connect-server

Length of output: 6853


🏁 Script executed:

# chat 및 report 관련 테이블 생성 파일 확인
echo "=== V24__add_chat_related_tables.sql ==="
cat ./src/main/resources/db/migration/V24__add_chat_related_tables.sql

echo -e "\n=== V25__create_report_table.sql ==="
cat ./src/main/resources/db/migration/V25__create_report_table.sql

echo -e "\n=== V41__add_is_deleted_to_post_and_chat_message.sql ==="
cat ./src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql

Repository: solid-connection/solid-connect-server

Length of output: 3030


🏁 Script executed:

# report 테이블의 target_type ENUM 변경 이력 확인
echo "=== Checking for report table modifications ==="
rg -n "ALTER TABLE report" ./src/main/resources/db/migration/ -A 5

echo -e "\n=== Checking for CHAT in target_type ==="
rg -n "CHAT" ./src/main/resources/db/migration/ -B 2 -A 2

echo -e "\n=== Checking for index creations ==="
rg -n "CREATE.*INDEX|CREATE INDEX" ./src/main/resources/db/migration/ -A 3

Repository: solid-connection/solid-connect-server

Length of output: 14024


🏁 Script executed:

# ENUM 수정 쿼리 찾기
echo "=== Searching for ALTER ENUM or MODIFY for target_type ==="
rg -n "target_type|MODIFY.*enum|ALTER.*enum" ./src/main/resources/db/migration/ -i -B 1 -A 3

# V25 이후의 모든 파일에서 report 관련 수정 확인
echo -e "\n=== All ALTER for report table ==="
rg -n "report" ./src/main/resources/db/migration/ -B 2 -A 2 | head -100

# 'CHAT' 또는 'Chat' 문자열 전체 검색
echo -e "\n=== Searching for Chat keyword in migrations ==="
rg -n "'CHAT'|\"CHAT\"|Chat" ./src/main/resources/db/migration/

Repository: solid-connection/solid-connect-server

Length of output: 8049


🏁 Script executed:

# Java 코드에서 실제 target_type 값 확인
echo "=== Checking Java code for target_type usage ==="
rg -n "target_type.*CHAT|'CHAT'|\"CHAT\"" src/main/java/ -B 2 -A 2 | head -50

# ReportTargetType enum 확인
echo -e "\n=== Searching for ReportTargetType enum ==="
fd -t f "ReportTargetType.java" src/ 

# Report entity 확인
echo -e "\n=== Searching for Report entity ==="
fd -t f "Report.java" src/ | grep -v "Repository"

Repository: solid-connection/solid-connect-server

Length of output: 1730


🏁 Script executed:

# Report entity 상세 확인
echo "=== Report.java content ==="
cat src/main/java/com/example/solidconnection/report/domain/Report.java

# ChatMessageRepository 메서드 전체 확인
echo -e "\n=== ChatMessageRepository class ==="
cat src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java | head -80

Repository: solid-connection/solid-connect-server

Length of output: 4326


🏁 Script executed:

# TargetType enum 검색
echo "=== Searching for TargetType enum ==="
fd -t f "TargetType.java" src/

# 또는 Report.java와 같은 디렉토리에서 찾기
echo -e "\n=== Files in report domain directory ==="
ls -la src/main/java/com/example/solidconnection/report/domain/

# enum 정의 검색
echo -e "\n=== Searching for enum TargetType definition ==="
rg -n "enum TargetType|class TargetType" src/main/java/ -A 5

Repository: solid-connection/solid-connect-server

Length of output: 1082


🏁 Script executed:

# TargetType.java 전체 확인
echo "=== TargetType.java ==="
cat src/main/java/com/example/solidconnection/report/domain/TargetType.java

# 모든 마이그레이션 파일이 있는지 확인
echo -e "\n=== Total migration files ==="
ls -1 ./src/main/resources/db/migration/ | wc -l

# 가장 최신 마이그레이션 파일들 확인
echo -e "\n=== Latest 5 migration files ==="
ls -1 ./src/main/resources/db/migration/ | sort -V | tail -5

# application.properties에서 JPA 설정 확인
echo -e "\n=== Checking application.properties for JPA DDL settings ==="
find ./src/main/resources -name "application*.properties" -o -name "application*.yml" | xargs cat 2>/dev/null | grep -i "jpa\|hibernate\|ddl" -A 2

Repository: solid-connection/solid-connect-server

Length of output: 560


데이터베이스 스키마 수정이 필수적으로 필요합니다.

신고된 채팅 메시지 일괄 처리 메서드의 구현 로직은 정확하나, 데이터베이스와 코드 간 중요한 불일치가 있습니다.

  1. CHAT 타입이 report 테이블 스키마에 없음 (필수 수정)

    • 마이그레이션 V25에서 report 테이블의 target_type ENUM이 ('POST')만 정의되어 있습니다.
    • Java 코드의 TargetType enum은 POST, CHAT을 모두 포함하고 있습니다.
    • 이 쿼리는 현재 데이터베이스 ENUM 제약 위반으로 실행될 수 없습니다.
    • 마이그레이션 파일을 추가하여 target_type ENUM을 ('POST', 'CHAT')로 확장해야 합니다.
  2. 성능 최적화를 위한 인덱스 추가 권장

    • report 테이블의 target_id, target_type 복합 인덱스
    • chat_participant 테이블의 site_user_id 인덱스
    • chat_message 테이블의 sender_id 인덱스

    중첩된 서브쿼리로 인한 성능 저하를 방지하기 위해 위의 인덱스를 추가하시기 바랍니다.

  3. 현재 구현의 긍정적 측면

    • isDeleted 파라미터로 삭제와 복구를 모두 지원합니다.
    • clearAutomaticallyflushAutomatically 옵션으로 벌크 연산 후 영속성 컨텍스트를 적절히 관리했습니다.
🤖 Prompt for AI Agents
In
src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java
around lines 53 to 59, the native UPDATE uses target_type = 'CHAT' but the DB
migration V25 defines the report.target_type ENUM only with 'POST', causing enum
constraint failures; create a new migration that alters the report.target_type
enum to include 'CHAT' (e.g., add a migration V26 that updates the enum values
to ('POST','CHAT') and migrates existing data safely), add the recommended
indexes to improve performance (composite index on report(target_type,
target_id) or (target_id, target_type) depending on query planner, index on
chat_participant(site_user_id), and index on chat_message(sender_id)), apply and
run the migration before deploying this code change, and verify the
updateReportedChatMessagesIsDeleted query executes successfully in integration
tests.


@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
UPDATE chat_message cm SET cm.is_deleted = :isDeleted
WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT')
AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id IN :siteUserIds)
""", nativeQuery = true)
void bulkUpdateReportedChatMessagesIsDeleted(@Param("siteUserIds") List<Long> siteUserIds, @Param("isDeleted") boolean isDeleted);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.example.solidconnection.common.config.web;

import com.example.solidconnection.common.interceptor.BannedUserInterceptor;
import com.example.solidconnection.common.resolver.AuthorizedUserResolver;
import com.example.solidconnection.common.resolver.CustomPageableHandlerMethodArgumentResolver;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
Expand All @@ -14,6 +16,7 @@ public class WebMvcConfig implements WebMvcConfigurer {

private final AuthorizedUserResolver authorizedUserResolver;
private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver;
private final BannedUserInterceptor bannedUserInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
Expand All @@ -22,4 +25,10 @@ public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers)
customPageableHandlerMethodArgumentResolver
));
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(bannedUserInterceptor)
.addPathPatterns("/posts/**", "/comments/**", "/chats/**", "/boards/**");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public enum ErrorCode {
TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학기입니다."),
CURRENT_TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "현재 학기를 찾을 수 없습니다."),
MENTOR_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "멘토 지원서가 존재하지 않습니다."),
REPORT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "신고 내역이 존재하지 않습니다."),

// auth
USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."),
Expand Down Expand Up @@ -149,6 +150,11 @@ public enum ErrorCode {
// chat
INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."),

// ban
ALREADY_BANNED_USER(HttpStatus.CONFLICT.value(), "이미 차단된 사용자입니다."),
NOT_BANNED_USER(HttpStatus.BAD_REQUEST.value(), "차단되지 않은 사용자입니다."),
BANNED_USER_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "차단된 사용자는 커뮤니티 및 채팅을 이용할 수 없습니다."),

// database
DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."),

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.example.solidconnection.common.interceptor;

import static com.example.solidconnection.common.exception.ErrorCode.BANNED_USER_ACCESS_DENIED;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.security.userdetails.SiteUserDetails;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.domain.UserStatus;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class BannedUserInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication != null && authentication.getPrincipal() instanceof SiteUserDetails) {
SiteUserDetails userDetails = (SiteUserDetails) authentication.getPrincipal();
SiteUser siteUser = userDetails.getSiteUser();
Copy link
Member

Choose a reason for hiding this comment

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

JWT 토큰에서 사용자를 조회하고 있어, 차단 시점과의 불일치가 생길 거 같은데, DB 조회와 차단 시 토큰 무효화 중 뭐가 좋을지는 생각해봐야겠네요 ..

불일치가 큰 영향이 있을 거 같진 않다는 생각이 들기도 하고요 ....


if (siteUser.getUserStatus() == UserStatus.BANNED) {
throw new CustomException(BANNED_USER_ACCESS_DENIED);
}
}
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.Where;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Comment 엔티티 파일 찾기
find . -type f -name "Comment.java" | grep -E "community|comment"

Repository: solid-connection/solid-connect-server

Length of output: 162


🏁 Script executed:

# Post 엔티티에서 isDeleted 관련 메서드 확인
rg -n "isDeleted" --type java -A 3 -B 3 | grep -E "(Post\.java|public|private|protected|void|boolean)" | head -50

Repository: solid-connection/solid-connect-server

Length of output: 3229


🏁 Script executed:

# Comment 엔티티의 @Where 어노테이션 확인
rg -n "@Where|@Entity|class Comment" --type java -B 2 -A 10 | grep -E "Comment|@Where"

Repository: solid-connection/solid-connect-server

Length of output: 10260


🏁 Script executed:

# soft delete 관련 메서드명 패턴 검색
rg -n "delete|Delete|soft" --type java src/main/java/com/example/solidconnection/community/ | grep -i "method\|void\|class" | head -30

Repository: solid-connection/solid-connect-server

Length of output: 413


🏁 Script executed:

cat -n src/main/java/com/example/solidconnection/community/comment/domain/Comment.java | head -50

Repository: solid-connection/solid-connect-server

Length of output: 1797


🏁 Script executed:

# Comment.java의 @Where 어노테이션 확인
rg -n "@Where|@Entity" src/main/java/com/example/solidconnection/community/comment/domain/Comment.java

Repository: solid-connection/solid-connect-server

Length of output: 91


🏁 Script executed:

# Comment의 deprecateComment() 메서드 확인
rg -n "deprecateComment" src/main/java/com/example/solidconnection/community/comment/domain/Comment.java -A 5

Repository: solid-connection/solid-connect-server

Length of output: 172


🏁 Script executed:

# CommentRepository에서 soft delete 관련 쿼리 확인
rg -n "isDeleted|deleteComment|deprecate" src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java -B 2 -A 5

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# CommentRepository 파일 찾기 및 내용 확인
find . -type f -name "CommentRepository.java"

Repository: solid-connection/solid-connect-server

Length of output: 176


🏁 Script executed:

# CommentRepository 전체 내용 확인
cat -n src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java | head -80

Repository: solid-connection/solid-connect-server

Length of output: 2647


🏁 Script executed:

# Comment 조회 쿼리들 확인 - isDeleted 필터링 여부
rg -n "SELECT|select|FROM|from" src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java -B 1 -A 8

Repository: solid-connection/solid-connect-server

Length of output: 1831


🏁 Script executed:

# Post 엔티티에서 isDeleted 관련 public 메서드 확인
rg -n "isDeleted|deprecated|deprecate" src/main/java/com/example/solidconnection/community/post/domain/Post.java -B 2 -A 3

Repository: solid-connection/solid-connect-server

Length of output: 343


🏁 Script executed:

# Post.java 전체 메서드 확인
cat -n src/main/java/com/example/solidconnection/community/post/domain/Post.java | grep -E "public|private|void|boolean" | head -30

Repository: solid-connection/solid-connect-server

Length of output: 1079


🏁 Script executed:

# Post.java 전체 내용 확인 - 특히 메서드들
cat -n src/main/java/com/example/solidconnection/community/post/domain/Post.java

Repository: solid-connection/solid-connect-server

Length of output: 3461


🏁 Script executed:

# Comment와 Post의 soft delete 메서드 비교
echo "=== Post의 soft delete 관련 메서드 ===" && \
rg -n "public.*void|public.*delete|public.*deprecate" src/main/java/com/example/solidconnection/community/post/domain/Post.java && \
echo -e "\n=== Comment의 soft delete 관련 메서드 ===" && \
rg -n "public.*void|public.*delete|public.*deprecate" src/main/java/com/example/solidconnection/community/comment/domain/Comment.java

Repository: solid-connection/solid-connect-server

Length of output: 614


🏁 Script executed:

# soft delete 호출되는 곳 확인
rg -n "deprecateComment|\.isDeleted\s*=\s*true" --type java src/main/java/com/example/solidconnection/community/comment/

Repository: solid-connection/solid-connect-server

Length of output: 452


🏁 Script executed:

# CommentService의 findCommentsByPostId 메서드 전체 확인
rg -n "findCommentsByPostId" src/main/java/com/example/solidconnection/community/comment/service/CommentService.java -B 2 -A 15

Repository: solid-connection/solid-connect-server

Length of output: 1041


🏁 Script executed:

# soft delete 처리 관련 전체 흐름 확인 - 어디서 soft delete가 호출되나
rg -n "deprecateComment|updateReportedPostsIsDeleted|updateReportedChatMessagesIsDeleted|updateReportedContentIsDeleted" --type java -B 3 -A 3

Repository: solid-connection/solid-connect-server

Length of output: 9558


🏁 Script executed:

# Verify filterCommentsByDeletionRules implementation
rg -n "filterCommentsByDeletionRules" src/main/java/com/example/solidconnection/community/comment/service/CommentService.java -A 20

Repository: solid-connection/solid-connect-server

Length of output: 1916


Comment 엔티티의 @where 어노테이션 누락과 soft delete 일관성 문제를 해결하세요.

검증 결과, Post와 ChatMessage 엔티티와의 일관성 문제가 확실합니다. 추가로 soft delete 처리에서 더 심각한 문제점도 발견되었습니다:

  1. @where 어노테이션 누락으로 인한 불일치

    • Post (line 27): @Where(clause = "is_deleted = false") 사용
    • ChatMessage (line 23): @Where(clause = "is_deleted = false") 사용
    • Comment: 어노테이션 없음 → 모든 조회가 DB 레벨에서 필터링되지 않음
  2. 필터링 방식이 다름

    • Post/ChatMessage: DB 레벨 자동 필터링 (신뢰성 높음)
    • Comment: 애플리케이션 레벨 필터링 (filterCommentsByDeletionRules) → 모든 쿼리에서 수동으로 처리 필요
  3. 🚨 AdminUserBanService에서 보고된 댓글 미처리 (중대 결함)

    • updateReportedContentIsDeleted() 메서드 (line 60-63)가 Post와 ChatMessage는 soft delete하지만 Comment는 처리하지 않음
    • 결과: 사용자 차단 후에도 보고된 댓글이 계속 조회됨

해결 방법:

  • Comment에 @where 어노테이션 추가
  • Comment soft delete를 위한 bulk update 메서드를 CommentRepository에 추가
  • AdminUserBanService의 updateReportedContentIsDeleted()에서 댓글도 처리하도록 수정
🤖 Prompt for AI Agents
In src/main/java/com/example/solidconnection/community/post/domain/Post.java
around line 21, the review highlights a consistency bug: the Comment entity
lacks @Where(clause = "is_deleted = false") causing DB-level filtering mismatch
and AdminUserBanService.updateReportedContentIsDeleted(...) fails to soft-delete
reported comments. Fix by adding @Where(clause = "is_deleted = false") to the
Comment entity, add a bulk soft-delete method in CommentRepository (e.g., update
isDeleted = true for matched comment IDs or reported flags), and update
AdminUserBanService.updateReportedContentIsDeleted() to call the new repository
method so comments are soft-deleted consistently with Post and ChatMessage.


@Entity
@Getter
@NoArgsConstructor
@EqualsAndHashCode(of = "id")
@Where(clause = "is_deleted = false")
public class Post extends BaseEntity {

@Id
Expand Down Expand Up @@ -50,6 +52,8 @@ public class Post extends BaseEntity {
@Column
private long siteUserId;

@Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false)
private boolean isDeleted = false;

@BatchSize(size = 20)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
Expand Down
Loading
Loading