diff --git a/build.gradle b/build.gradle index 99f0a89..53b96c1 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' //Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' @@ -69,6 +70,9 @@ dependencies { // OpenSearch implementation 'org.opensearch.client:opensearch-rest-high-level-client:2.15.0' + // Email + implementation 'org.springframework.boot:spring-boot-starter-mail' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/teamficial/teamficial_be/domain/admin/controller/AdminReportController.java b/src/main/java/teamficial/teamficial_be/domain/admin/controller/AdminReportController.java new file mode 100644 index 0000000..bf90e3a --- /dev/null +++ b/src/main/java/teamficial/teamficial_be/domain/admin/controller/AdminReportController.java @@ -0,0 +1,50 @@ +package teamficial.teamficial_be.domain.admin.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import teamficial.teamficial_be.domain.admin.dto.ReportResponseDto; +import teamficial.teamficial_be.domain.admin.service.AdminReportService; +import teamficial.teamficial_be.global.apiPayload.ApiResponse; +import teamficial.teamficial_be.global.security.AuthDetails; +import teamficial.teamficial_be.global.util.ScrollResponse; + +@Tag(name = "어드민 신고 관리 관련 API") +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminReportController { + + private final AdminReportService adminReportService; + + @GetMapping("/report/{reportId}") + @Operation(summary = "신고된 키워드 코멘트 조회하기", description = "어드민이 신고된 키워드를 조회할 때 사용하는 API입니다.") + public ApiResponse getReport(@AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long reportId){ + + ReportResponseDto responseDto = adminReportService.getReport(authDetails.user(), reportId); + + return ApiResponse.onSuccess(responseDto); + } + + @GetMapping("/report") + @Operation(summary = "신고된 키워드 코멘트 리스트 조회하기", description = "어드민이 신고된 키워드를 조회할 때 사용하는 API입니다.") + public ApiResponse> getReportList(@AuthenticationPrincipal AuthDetails authDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size){ + + ScrollResponse responseDtos = adminReportService.getReportList(authDetails.user(), page, size); + + return ApiResponse.onSuccess(responseDtos); + } + + @PostMapping("/report/{reportId}") + @Operation(summary = "신고된 키워드 코멘트 삭제하기", description = "어드민이 신고된 키워드를 삭제할 때 사용하는 API입니다.") + public ApiResponse acceptReport(@AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long reportId){ + + adminReportService.acceptReport(reportId); + + return ApiResponse.onSuccess("신고가 반영되었습니다."); + } +} diff --git a/src/main/java/teamficial/teamficial_be/domain/admin/dto/ReportResponseDto.java b/src/main/java/teamficial/teamficial_be/domain/admin/dto/ReportResponseDto.java new file mode 100644 index 0000000..df6a9f4 --- /dev/null +++ b/src/main/java/teamficial/teamficial_be/domain/admin/dto/ReportResponseDto.java @@ -0,0 +1,31 @@ +package teamficial.teamficial_be.domain.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import teamficial.teamficial_be.domain.report.entity.Report; + +@Getter +@Builder +public class ReportResponseDto { + @Schema(name= "신고 id") + Long reportId; + @Schema(name = "신고된 키워드 코멘트 id") + Long commentId; + @Schema(example = "신고 유형") + String reportType; + @Schema(example = "신고 유형 (기타)") + String reportTypeEtc; + @Schema(example = "신고 내용") + String reportContent; + + public static ReportResponseDto of(Report report) { + return ReportResponseDto.builder() + .reportId(report.getId()) + .commentId(report.getReportedCommentId()) + .reportType(report.getReportType().getDescription()) + .reportTypeEtc(report.getReportEtc()) + .reportContent(report.getContent()) + .build(); + } +} diff --git a/src/main/java/teamficial/teamficial_be/domain/admin/service/AdminReportService.java b/src/main/java/teamficial/teamficial_be/domain/admin/service/AdminReportService.java new file mode 100644 index 0000000..aed1887 --- /dev/null +++ b/src/main/java/teamficial/teamficial_be/domain/admin/service/AdminReportService.java @@ -0,0 +1,47 @@ +package teamficial.teamficial_be.domain.admin.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import teamficial.teamficial_be.domain.admin.dto.ReportResponseDto; +import teamficial.teamficial_be.domain.report.entity.Report; +import teamficial.teamficial_be.domain.report.service.ReportService; +import teamficial.teamficial_be.domain.user.entity.User; +import teamficial.teamficial_be.global.util.ScrollResponse; + +@Service +@RequiredArgsConstructor +public class AdminReportService { + + private final ReportService reportService; + + @Transactional(readOnly = true) + public ReportResponseDto getReport(User user, Long reportId) { + Report report = reportService.getById(reportId); + + return ReportResponseDto.of(report); + } + + + @Transactional(readOnly = true) + public ScrollResponse getReportList(User user, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.Direction.DESC, "createdAt"); + + Slice reportList = reportService.getAllReport(pageable); + + Slice slice = reportList.map(ReportResponseDto::of); + + return ScrollResponse.of(slice); + } + + @Transactional + public void acceptReport(Long reportId) { + Report report = reportService.getById(reportId); + + reportService.acceptReport(report); + } +} diff --git a/src/main/java/teamficial/teamficial_be/domain/keyword/entity/Keyword.java b/src/main/java/teamficial/teamficial_be/domain/keyword/entity/Keyword.java index f3349e2..b952ed6 100644 --- a/src/main/java/teamficial/teamficial_be/domain/keyword/entity/Keyword.java +++ b/src/main/java/teamficial/teamficial_be/domain/keyword/entity/Keyword.java @@ -38,4 +38,10 @@ public void updateHead(boolean is_head) { public void increaseCount() { this.count++; } + + public void decreaseCount() { + if (this.count > 0) { + this.count--; + } + } } diff --git a/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordCommentService.java b/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordCommentService.java index 6d2053b..c296cb8 100644 --- a/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordCommentService.java +++ b/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordCommentService.java @@ -5,7 +5,6 @@ import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import teamficial.teamficial_be.domain.keyword.entity.HeadKeyword; import teamficial.teamficial_be.domain.keyword.entity.Keyword; import teamficial.teamficial_be.domain.keyword.entity.KeywordComment; import teamficial.teamficial_be.domain.keyword.repository.KeywordCommentRepository; @@ -31,4 +30,8 @@ public KeywordComment getById(Long keywordCommentId) { return keywordCommentRepository.findById(keywordCommentId) .orElseThrow(()-> new GeneralException(ErrorStatus.NOT_FOUND_KEYWORD_COMMENT)); } + + public void delete(KeywordComment comment) { + keywordCommentRepository.delete(comment); + } } diff --git a/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordService.java b/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordService.java index a640ccb..b1294cb 100644 --- a/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordService.java +++ b/src/main/java/teamficial/teamficial_be/domain/keyword/service/KeywordService.java @@ -83,4 +83,8 @@ public Keyword getKeywordByUserAndKeywordName(User user,String keywordName) { return keywordRepository.findByUserAndKeywordName(user,keywordName) .orElseThrow(()-> new NotFoundHandler(ErrorStatus.NOT_FOUND_KEYWORD)); } + + public void delete(Keyword keyword) { + keywordRepository.delete(keyword); + } } diff --git a/src/main/java/teamficial/teamficial_be/domain/report/controller/ReportController.java b/src/main/java/teamficial/teamficial_be/domain/report/controller/ReportController.java index 2b2c4c8..8b14a81 100644 --- a/src/main/java/teamficial/teamficial_be/domain/report/controller/ReportController.java +++ b/src/main/java/teamficial/teamficial_be/domain/report/controller/ReportController.java @@ -1,6 +1,7 @@ package teamficial.teamficial_be.domain.report.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -13,6 +14,7 @@ import teamficial.teamficial_be.global.apiPayload.ApiResponse; import teamficial.teamficial_be.global.security.AuthDetails; +@Tag(name = "신고 관련 API") @RestController @RequiredArgsConstructor public class ReportController { diff --git a/src/main/java/teamficial/teamficial_be/domain/report/entity/Report.java b/src/main/java/teamficial/teamficial_be/domain/report/entity/Report.java index 6c1ffec..f4dd3d2 100644 --- a/src/main/java/teamficial/teamficial_be/domain/report/entity/Report.java +++ b/src/main/java/teamficial/teamficial_be/domain/report/entity/Report.java @@ -31,5 +31,10 @@ public class Report extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; + @Column(nullable = false) + private Long reportedCommentId; + public void reportAccept(){ + this.isApplied = true; + } } diff --git a/src/main/java/teamficial/teamficial_be/domain/report/repository/ReportRepository.java b/src/main/java/teamficial/teamficial_be/domain/report/repository/ReportRepository.java index 24e9332..f257659 100644 --- a/src/main/java/teamficial/teamficial_be/domain/report/repository/ReportRepository.java +++ b/src/main/java/teamficial/teamficial_be/domain/report/repository/ReportRepository.java @@ -1,7 +1,15 @@ package teamficial.teamficial_be.domain.report.repository; +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.Query; import teamficial.teamficial_be.domain.report.entity.Report; public interface ReportRepository extends JpaRepository { + boolean existsByReportedCommentId(Long keywordCommentId); + + @Query("SELECT r FROM Report r " + + "WHERE r.isApplied = false ") + Slice findAllByIsApplied(Pageable pageable); } diff --git a/src/main/java/teamficial/teamficial_be/domain/report/service/MailService.java b/src/main/java/teamficial/teamficial_be/domain/report/service/MailService.java new file mode 100644 index 0000000..bebf79f --- /dev/null +++ b/src/main/java/teamficial/teamficial_be/domain/report/service/MailService.java @@ -0,0 +1,46 @@ +package teamficial.teamficial_be.domain.report.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender mailSender; + private final TemplateEngine templateEngine; + + public void sendReportEmail(String toEmail) { + Context context = new Context(); + + String body = templateEngine.process("report", context); + + sendHtmlEmail(toEmail, "[팀피셜] 신고가 접수되었습니다", body); + } + + private void sendHtmlEmail(String to, String subject, String htmlBody) { + MimeMessage message = mailSender.createMimeMessage(); + + try { + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlBody, true); + + ClassPathResource imageResource = new ClassPathResource("/static/report_email.jpeg"); + helper.addInline("report_email", imageResource); + + mailSender.send(message); + } catch (MessagingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java b/src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java index 0d1c134..68ef977 100644 --- a/src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java +++ b/src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java @@ -1,10 +1,16 @@ package teamficial.teamficial_be.domain.report.service; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import teamficial.teamficial_be.domain.keyword.entity.HeadKeyword; +import teamficial.teamficial_be.domain.keyword.entity.Keyword; import teamficial.teamficial_be.domain.keyword.entity.KeywordComment; +import teamficial.teamficial_be.domain.keyword.service.HeadKeywordService; import teamficial.teamficial_be.domain.keyword.service.KeywordCommentService; +import teamficial.teamficial_be.domain.keyword.service.KeywordService; import teamficial.teamficial_be.domain.report.dto.ReportRequestDto; import teamficial.teamficial_be.domain.report.entity.Report; import teamficial.teamficial_be.domain.report.entity.ReportType; @@ -19,11 +25,20 @@ public class ReportService { private final ReportRepository reportRepository; private final KeywordCommentService keywordCommentService; + private final MailService mailService; + private final KeywordService keywordService; + private final HeadKeywordService headKeywordService; @Transactional public void reportTeamficialLog(User user, Long keywordCommentId, ReportRequestDto reportRequestDto) { KeywordComment comment = keywordCommentService.getById(keywordCommentId); + //중복 신고 방지 + if (alreadyExistReport(keywordCommentId)){ + throw new GeneralException(ErrorStatus.REPORT_DUPLICATE); + } + + //권한 확인 if (!comment.getKeyword().getUser().getId().equals(user.getId())) { throw new GeneralException(ErrorStatus._FORBIDDEN); } @@ -32,6 +47,7 @@ public void reportTeamficialLog(User user, Long keywordCommentId, ReportRequestD .reportType(reportRequestDto.getReportType()) .reportEtc(reportRequestDto.getReportType()== ReportType.OTHER? reportRequestDto.getReportEtc() : null) .content(reportRequestDto.getContent()) + .reportedCommentId(keywordCommentId) .isApplied(false) .user(user) .build(); @@ -39,5 +55,46 @@ public void reportTeamficialLog(User user, Long keywordCommentId, ReportRequestD reportRepository.save(report); //이메일 전송 로직 + mailService.sendReportEmail(user.getEmail()); + } + + @Transactional + public void acceptReport(Report report) { + + if (report.isApplied()) { + throw new GeneralException(ErrorStatus.REPORT_ALREADY_APPLIED); + } + + //코멘트 삭제 + KeywordComment comment = keywordCommentService.getById(report.getReportedCommentId()); + + keywordCommentService.delete(comment); + + //신고된 코멘트로 인해 추출된 키워드의 코멘트가 신고된 코멘트밖에 없는 경우 + Keyword keyword = comment.getKeyword(); + + keyword.decreaseCount(); + if (keyword.getCount() ==0){ + keywordService.delete(keyword); + } + + report.reportAccept(); + + reportRepository.save(report); + } + + @Transactional(readOnly = true) + public Report getById(Long reportId) { + return reportRepository.findById(reportId) + .orElseThrow(()-> new GeneralException(ErrorStatus.NOT_FOUND_REPORT)); + } + + @Transactional(readOnly = true) + public Slice getAllReport(Pageable pageable) { + return reportRepository.findAllByIsApplied(pageable); + } + + private boolean alreadyExistReport(Long keywordCommentId) { + return reportRepository.existsByReportedCommentId(keywordCommentId); } } diff --git a/src/main/java/teamficial/teamficial_be/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/teamficial/teamficial_be/global/apiPayload/code/status/ErrorStatus.java index 6a1cc35..83956b2 100644 --- a/src/main/java/teamficial/teamficial_be/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/teamficial/teamficial_be/global/apiPayload/code/status/ErrorStatus.java @@ -52,9 +52,14 @@ public enum ErrorStatus implements BaseErrorCode { HEAD_KEYWORD_DUPLICATE(HttpStatus.BAD_REQUEST,"HEADKEYWORD4005","이미 등록된 대표키워드입니다."), CAN_NOT_WRITE_TEAMFICIAL_LOG_OVER_1(HttpStatus.FORBIDDEN,"KEYWORD_COMMENT5001","해당 유저에게 쓴 팀피셜록이 이미 존재합니다."), - //나의 팀 관련 응답 TEAM_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_FORBIDDEN403", "팀 멤버를 조회할 수 있는 권한이 없습니다."), + + //신고 관련 응답 + REPORT_DUPLICATE(HttpStatus.CONFLICT,"REPORT4001","이미 신고된 코멘트입니다."), + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, "REPORT404", "해당 신고를 찾을 수 없습니다."), + REPORT_ALREADY_APPLIED(HttpStatus.CONFLICT,"REPORT4002","이미 반영된 신고입니다.") + ; private final HttpStatus httpStatus; diff --git a/src/main/java/teamficial/teamficial_be/global/config/SecurityConfig.java b/src/main/java/teamficial/teamficial_be/global/config/SecurityConfig.java index 2e763cf..ff77609 100644 --- a/src/main/java/teamficial/teamficial_be/global/config/SecurityConfig.java +++ b/src/main/java/teamficial/teamficial_be/global/config/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import teamficial.teamficial_be.domain.user.entity.UserRole; import teamficial.teamficial_be.global.apiPayload.exception.handler.OAuth2AuthenticationSuccessHandler; import teamficial.teamficial_be.global.security.CustomOauth2UserService; import teamficial.teamficial_be.global.security.jwt.CookieUtil; @@ -63,6 +64,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/swagger-ui/**", "/profile/{profileId}" ).permitAll() + .requestMatchers("/admin/**").hasAuthority("ADMIN") .requestMatchers("/preSigned-url","/profile/**").authenticated() .anyRequest().permitAll() ); diff --git a/src/main/java/teamficial/teamficial_be/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/teamficial/teamficial_be/global/security/jwt/JwtAuthenticationFilter.java index f5847a3..910f24d 100644 --- a/src/main/java/teamficial/teamficial_be/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/teamficial/teamficial_be/global/security/jwt/JwtAuthenticationFilter.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -15,6 +16,7 @@ import java.io.IOException; +@Slf4j @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -45,19 +47,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } - -// if (token != null && tokenProvider.validateToken(token)) { -// try{ -// Authentication authentication = tokenProvider.getAuthentication(token); -// -// if (authentication != null) { -// SecurityContextHolder.getContext().setAuthentication(authentication); -// } -// }catch (IllegalArgumentException e){ -// throw new GeneralException(ErrorStatus.TOKEN_INVALID); -// } -// } - filterChain.doFilter(request, response); } diff --git a/src/main/resources/static/report_email.jpeg b/src/main/resources/static/report_email.jpeg new file mode 100644 index 0000000..f9a3360 Binary files /dev/null and b/src/main/resources/static/report_email.jpeg differ diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html new file mode 100644 index 0000000..73e9b0a --- /dev/null +++ b/src/main/resources/templates/report.html @@ -0,0 +1,32 @@ + + + + + 팀피셜 이메일 + + + + +
+ +
+

+ 안녕하세요 팀피셜입니다. +

+
+ +
+ 신고 이메일용 사진 +
+ +
+ + \ No newline at end of file