Skip to content

Commit a22026d

Browse files
authored
Merge pull request #274 from SEMOSAN/feat/#269-admin-community
[Feat] 관리자 커뮤니티 관리 기능 추가
2 parents fecbeeb + a1498c3 commit a22026d

12 files changed

Lines changed: 277 additions & 2 deletions

File tree

src/main/java/com/semosan/api/common/jwt/JwtFilter.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.semosan.api.common.exception.GeneralException;
66
import com.semosan.api.common.response.ApiResponse;
77
import com.semosan.api.common.status.ErrorStatus;
8+
import com.semosan.api.domain.user.entity.User;
9+
import com.semosan.api.domain.user.repository.UserRepository;
810
import io.jsonwebtoken.Claims;
911
import jakarta.servlet.FilterChain;
1012
import jakarta.servlet.ServletException;
@@ -34,6 +36,7 @@ public class JwtFilter extends OncePerRequestFilter {
3436

3537
private final JwtService jwtService;
3638
private final ObjectMapper objectMapper;
39+
private final UserRepository userRepository;
3740

3841
// SecurityConfig의 permitAll 경로와 동일하게 맞춰줍니다.
3942
// WebSocket 핸드셰이크(/ws/tracking)는 HTTP JWT 필터 우회 — STOMP CONNECT 프레임의
@@ -78,6 +81,11 @@ protected void doFilterInternal(
7881
String tokenType = claims.get("tokenType", String.class);
7982
if ("ADMIN".equals(tokenType)) {
8083
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
84+
} else {
85+
User user = userRepository.findById(userId).orElse(null);
86+
if (user != null && user.isSuspended()) {
87+
throw new GeneralException(ErrorStatus.USER_SUSPENDED);
88+
}
8189
}
8290

8391
UsernamePasswordAuthenticationToken authentication =

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public enum ErrorStatus implements BaseStatus {
6060
EXERCISE_DETAIL_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_9", "운동 빈도와 운동 시간을 입력해야 합니다."),
6161
EXERCISE_DETAIL_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "USER_400_10", "운동 안함 선택 시 운동 빈도와 운동 시간을 입력할 수 없습니다."),
6262
ONBOARDING_NOT_COMPLETED(HttpStatus.FORBIDDEN, "USER_403_1", "온보딩을 완료해야 이용할 수 있습니다."),
63+
USER_SUSPENDED(HttpStatus.FORBIDDEN, "USER_403_2", "정지된 사용자입니다."),
6364
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."),
6465
NOTIFICATION_SETTING_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_2", "알림 설정을 찾을 수 없습니다."),
6566
ONBOARDING_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_3", "온보딩 정보를 찾을 수 없습니다."),

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ public enum SuccessStatus implements BaseStatus {
146146
ADMIN_RESTAURANT_UPDATE_SUCCESS(HttpStatus.OK, "ADM_200_6", "맛집 수정에 성공했습니다."),
147147
ADMIN_RESTAURANT_DELETE_SUCCESS(HttpStatus.OK, "ADM_200_7", "맛집 삭제에 성공했습니다."),
148148
ADMIN_RESTAURANT_SECTION_CREATE_SUCCESS(HttpStatus.CREATED, "ADM_201_1", "맛집 섹션 생성에 성공했습니다."),
149-
ADMIN_RESTAURANT_CREATE_SUCCESS(HttpStatus.CREATED, "ADM_201_2", "맛집 생성에 성공했습니다.");
149+
ADMIN_RESTAURANT_CREATE_SUCCESS(HttpStatus.CREATED, "ADM_201_2", "맛집 생성에 성공했습니다."),
150+
ADMIN_REPORTED_POST_LIST_SUCCESS(HttpStatus.OK, "ADM_200_8", "신고된 게시글 목록 조회에 성공했습니다."),
151+
ADMIN_POST_DELETE_SUCCESS(HttpStatus.OK, "ADM_200_9", "게시글 강제 삭제에 성공했습니다."),
152+
ADMIN_COMMENT_DELETE_SUCCESS(HttpStatus.OK, "ADM_200_10", "댓글 강제 삭제에 성공했습니다."),
153+
ADMIN_USER_SUSPEND_SUCCESS(HttpStatus.OK, "ADM_200_11", "사용자 정지 처리에 성공했습니다."),
154+
ADMIN_USER_UNSUSPEND_SUCCESS(HttpStatus.OK, "ADM_200_12", "사용자 정지 해제에 성공했습니다.");
150155

151156
private final HttpStatus httpStatus;
152157
private final String code;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.semosan.api.domain.admin.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.admin.controller.docs.AdminCommunityControllerDocs;
6+
import com.semosan.api.domain.admin.dto.request.AdminUserSuspendRequest;
7+
import com.semosan.api.domain.admin.dto.response.AdminReportedPostResponse;
8+
import com.semosan.api.domain.admin.service.AdminCommunityService;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.data.domain.Page;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
@RestController
18+
@RequestMapping("/api/admin")
19+
@RequiredArgsConstructor
20+
public class AdminCommunityController implements AdminCommunityControllerDocs {
21+
22+
private final AdminCommunityService adminCommunityService;
23+
24+
@GetMapping("/community/reported-posts")
25+
@Override
26+
public ResponseEntity<ApiResponse<Page<AdminReportedPostResponse>>> getReportedPosts(
27+
@PageableDefault(size = 20) Pageable pageable
28+
) {
29+
Page<AdminReportedPostResponse> result = adminCommunityService.getReportedPosts(pageable);
30+
return ApiResponse.success(SuccessStatus.ADMIN_REPORTED_POST_LIST_SUCCESS, result);
31+
}
32+
33+
@DeleteMapping("/community/posts/{postId}")
34+
@Override
35+
public ResponseEntity<ApiResponse<Void>> deletePost(@PathVariable Long postId) {
36+
adminCommunityService.deletePost(postId);
37+
return ApiResponse.success(SuccessStatus.ADMIN_POST_DELETE_SUCCESS);
38+
}
39+
40+
@DeleteMapping("/community/comments/{commentId}")
41+
@Override
42+
public ResponseEntity<ApiResponse<Void>> deleteComment(@PathVariable Long commentId) {
43+
adminCommunityService.deleteComment(commentId);
44+
return ApiResponse.success(SuccessStatus.ADMIN_COMMENT_DELETE_SUCCESS);
45+
}
46+
47+
@PostMapping("/users/{userId}/suspend")
48+
@Override
49+
public ResponseEntity<ApiResponse<Void>> suspendUser(
50+
@PathVariable Long userId,
51+
@Valid @RequestBody AdminUserSuspendRequest request
52+
) {
53+
adminCommunityService.suspendUser(userId, request);
54+
return ApiResponse.success(SuccessStatus.ADMIN_USER_SUSPEND_SUCCESS);
55+
}
56+
57+
@DeleteMapping("/users/{userId}/suspend")
58+
@Override
59+
public ResponseEntity<ApiResponse<Void>> unsuspendUser(@PathVariable Long userId) {
60+
adminCommunityService.unsuspendUser(userId);
61+
return ApiResponse.success(SuccessStatus.ADMIN_USER_UNSUSPEND_SUCCESS);
62+
}
63+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.semosan.api.domain.admin.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.domain.admin.dto.request.AdminUserSuspendRequest;
5+
import com.semosan.api.domain.admin.dto.response.AdminReportedPostResponse;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import org.springframework.data.domain.Page;
11+
import org.springframework.data.domain.Pageable;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.PathVariable;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
16+
@Tag(name = "Admin Community", description = "관리자 커뮤니티 관리 API")
17+
public interface AdminCommunityControllerDocs {
18+
19+
@Operation(summary = "신고된 게시글 목록 조회", description = "신고 횟수가 많은 순으로 게시글 목록을 조회합니다.")
20+
@ApiResponses({
21+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공")
22+
})
23+
ResponseEntity<ApiResponse<Page<AdminReportedPostResponse>>> getReportedPosts(Pageable pageable);
24+
25+
@Operation(summary = "게시글 강제 삭제", description = "게시글을 강제로 삭제(숨김) 처리합니다.")
26+
@ApiResponses({
27+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"),
28+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글을 찾을 수 없음")
29+
})
30+
ResponseEntity<ApiResponse<Void>> deletePost(@PathVariable Long postId);
31+
32+
@Operation(summary = "댓글 강제 삭제", description = "댓글을 강제로 삭제 처리합니다.")
33+
@ApiResponses({
34+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"),
35+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음")
36+
})
37+
ResponseEntity<ApiResponse<Void>> deleteComment(@PathVariable Long commentId);
38+
39+
@Operation(summary = "사용자 정지", description = "사용자를 지정된 기간까지 정지합니다.")
40+
@ApiResponses({
41+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "정지 성공"),
42+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음")
43+
})
44+
ResponseEntity<ApiResponse<Void>> suspendUser(
45+
@PathVariable Long userId,
46+
@Valid @RequestBody AdminUserSuspendRequest request
47+
);
48+
49+
@Operation(summary = "사용자 정지 해제", description = "사용자의 정지를 해제합니다.")
50+
@ApiResponses({
51+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "정지 해제 성공"),
52+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음")
53+
})
54+
ResponseEntity<ApiResponse<Void>> unsuspendUser(@PathVariable Long userId);
55+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.semosan.api.domain.admin.dto.request;
2+
3+
import jakarta.validation.constraints.Future;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
import java.time.LocalDateTime;
7+
8+
public record AdminUserSuspendRequest(
9+
@NotNull @Future LocalDateTime suspendedUntil
10+
) {
11+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.semosan.api.domain.admin.dto.response;
2+
3+
import java.time.LocalDateTime;
4+
5+
public record AdminReportedPostResponse(
6+
Long postId,
7+
String title,
8+
String content,
9+
Long authorId,
10+
String authorNickname,
11+
long reportCount,
12+
boolean deleted,
13+
LocalDateTime createdAt
14+
) {
15+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.semosan.api.domain.admin.service;
2+
3+
import com.semosan.api.common.exception.GeneralException;
4+
import com.semosan.api.common.status.ErrorStatus;
5+
import com.semosan.api.domain.admin.dto.request.AdminUserSuspendRequest;
6+
import com.semosan.api.domain.admin.dto.response.AdminReportedPostResponse;
7+
import com.semosan.api.domain.community.comment.entity.Comment;
8+
import com.semosan.api.domain.community.comment.repository.CommentRepository;
9+
import com.semosan.api.domain.community.post.entity.Post;
10+
import com.semosan.api.domain.community.post.repository.FreePostReportRepository;
11+
import com.semosan.api.domain.community.post.repository.PostRepository;
12+
import com.semosan.api.domain.community.post.repository.ReportedPostProjection;
13+
import com.semosan.api.domain.user.entity.User;
14+
import com.semosan.api.domain.user.repository.UserRepository;
15+
import lombok.RequiredArgsConstructor;
16+
import org.springframework.data.domain.Page;
17+
import org.springframework.data.domain.Pageable;
18+
import org.springframework.stereotype.Service;
19+
import org.springframework.transaction.annotation.Transactional;
20+
21+
@Service
22+
@RequiredArgsConstructor
23+
public class AdminCommunityService {
24+
25+
private final PostRepository postRepository;
26+
private final CommentRepository commentRepository;
27+
private final FreePostReportRepository freePostReportRepository;
28+
private final UserRepository userRepository;
29+
30+
@Transactional(readOnly = true)
31+
public Page<AdminReportedPostResponse> getReportedPosts(Pageable pageable) {
32+
return freePostReportRepository.findReportedPosts(pageable)
33+
.map(p -> new AdminReportedPostResponse(
34+
p.getPostId(),
35+
p.getTitle(),
36+
p.getContent(),
37+
p.getAuthorId(),
38+
p.getAuthorNickname(),
39+
p.getReportCount(),
40+
p.getDeleted(),
41+
p.getCreatedAt()
42+
));
43+
}
44+
45+
@Transactional
46+
public void deletePost(Long postId) {
47+
Post post = postRepository.findById(postId)
48+
.orElseThrow(() -> new GeneralException(ErrorStatus.POST_NOT_FOUND));
49+
post.softDelete();
50+
}
51+
52+
@Transactional
53+
public void deleteComment(Long commentId) {
54+
Comment comment = commentRepository.findById(commentId)
55+
.orElseThrow(() -> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND));
56+
comment.softDelete();
57+
}
58+
59+
@Transactional
60+
public void suspendUser(Long userId, AdminUserSuspendRequest request) {
61+
User user = userRepository.findById(userId)
62+
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
63+
user.suspend(request.suspendedUntil());
64+
}
65+
66+
@Transactional
67+
public void unsuspendUser(Long userId) {
68+
User user = userRepository.findById(userId)
69+
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
70+
user.unsuspend();
71+
}
72+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
package com.semosan.api.domain.community.post.repository;
22

33
import com.semosan.api.domain.community.post.entity.FreePostReport;
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.Pageable;
46
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
58

69
public interface FreePostReportRepository extends JpaRepository<FreePostReport, Long> {
710

811
boolean existsByReporter_IdAndPost_Id(Long reporterId, Long postId);
12+
13+
@Query("""
14+
SELECT r.post.id AS postId, r.post.title AS title, r.post.content AS content,
15+
r.post.author.id AS authorId, r.post.author.nickname AS authorNickname,
16+
COUNT(r) AS reportCount, r.post.deleted AS deleted, r.post.createdAt AS createdAt
17+
FROM FreePostReport r
18+
GROUP BY r.post.id, r.post.title, r.post.content,
19+
r.post.author.id, r.post.author.nickname,
20+
r.post.deleted, r.post.createdAt
21+
ORDER BY COUNT(r) DESC, r.post.createdAt DESC
22+
""")
23+
Page<ReportedPostProjection> findReportedPosts(Pageable pageable);
924
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.semosan.api.domain.community.post.repository;
2+
3+
import java.time.LocalDateTime;
4+
5+
public interface ReportedPostProjection {
6+
Long getPostId();
7+
String getTitle();
8+
String getContent();
9+
Long getAuthorId();
10+
String getAuthorNickname();
11+
long getReportCount();
12+
boolean getDeleted();
13+
LocalDateTime getCreatedAt();
14+
}

0 commit comments

Comments
 (0)