Skip to content
Merged
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReportResponseDto> 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<ScrollResponse<ReportResponseDto>> getReportList(@AuthenticationPrincipal AuthDetails authDetails,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size){

ScrollResponse<ReportResponseDto> responseDtos = adminReportService.getReportList(authDetails.user(), page, size);

return ApiResponse.onSuccess(responseDtos);
}

@PostMapping("/report/{reportId}")
@Operation(summary = "신고된 키워드 코멘트 삭제하기", description = "어드민이 신고된 키워드를 삭제할 때 사용하는 API입니다.")
public ApiResponse<String> acceptReport(@AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long reportId){

adminReportService.acceptReport(reportId);

return ApiResponse.onSuccess("신고가 반영되었습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<ReportResponseDto> getReportList(User user, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.Direction.DESC, "createdAt");

Slice<Report> reportList = reportService.getAllReport(pageable);

Slice<ReportResponseDto> slice = reportList.map(ReportResponseDto::of);

return ScrollResponse.of(slice);
}

@Transactional
public void acceptReport(Long reportId) {
Report report = reportService.getById(reportId);

reportService.acceptReport(report);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ public void updateHead(boolean is_head) {
public void increaseCount() {
this.count++;
}

public void decreaseCount() {
if (this.count > 0) {
this.count--;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Report, Long> {
boolean existsByReportedCommentId(Long keywordCommentId);

@Query("SELECT r FROM Report r " +
"WHERE r.isApplied = false ")
Slice<Report> findAllByIsApplied(Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
dldusgh318 marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
Expand All @@ -32,12 +47,54 @@ 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();

reportRepository.save(report);

//이메일 전송 로직
mailService.sendReportEmail(user.getEmail());
Comment on lines 57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the ReportService.java file and understand the context
find . -type f -name "ReportService.java" | head -20

Repository: TEAMFICIAL/teamficial-be

Length of output: 149


🏁 Script executed:

# Also search for the mailService implementation
find . -type f -name "*MailService*" -o -name "*mail*" | grep -E "\.(java|kt)$" | head -20

Repository: TEAMFICIAL/teamficial-be

Length of output: 147


🏁 Script executed:

# Search for @Async configuration in the project
rg "@Async|@EnableAsync|Executor" --type java | head -30

Repository: TEAMFICIAL/teamficial-be

Length of output: 165


🏁 Script executed:

# Read ReportService.java to understand the context around lines 57-58
cat -n ./src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java | head -80

Repository: TEAMFICIAL/teamficial-be

Length of output: 3763


🏁 Script executed:

# Read MailService.java to check the email implementation
cat -n ./src/main/java/teamficial/teamficial_be/domain/report/service/MailService.java

Repository: TEAMFICIAL/teamficial-be

Length of output: 1898


🏁 Script executed:

# Search for @EnableAsync configuration
rg "@EnableAsync" --type java -A 2 -B 2

Repository: TEAMFICIAL/teamficial-be

Length of output: 50


🏁 Script executed:

# Search for other email sending patterns in the codebase
rg "mailService\.|MailService\.|sendEmail|@Async" --type java | grep -v "node_modules"

Repository: TEAMFICIAL/teamficial-be

Length of output: 299


🏁 Script executed:

# Check the full MailService usage across the project
rg "sendReportEmail|sendHtmlEmail" --type java -B 2 -A 2

Repository: TEAMFICIAL/teamficial-be

Length of output: 1980


🏁 Script executed:

# Look for event-driven or async patterns in the codebase
rg "ApplicationEvent|@EventListener|@Async|Executor" --type java -B 1 | head -50

Repository: TEAMFICIAL/teamficial-be

Length of output: 253


🏁 Script executed:

# Search for Spring configuration files that might have @EnableAsync
find . -type f \( -name "*.java" -o -name "*.xml" -o -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) | xargs grep -l "EnableAsync\|AsyncConfigurer\|TaskExecutor" 2>/dev/null | head -20

Repository: TEAMFICIAL/teamficial-be

Length of output: 50


🏁 Script executed:

# Check if there's a configuration class that enables async
fd ".*Config.*\.java$" --type f | xargs grep -l "@Configuration\|@EnableAsync\|Executor" 2>/dev/null | head -20

Repository: TEAMFICIAL/teamficial-be

Length of output: 667


🏁 Script executed:

# Look at PreSignedUrlService to see how @Async is implemented there
cat -n ./src/main/java/teamficial/teamficial_be/domain/profile/service/PreSignedUrlService.java | grep -A 10 -B 5 "@Async"

Repository: TEAMFICIAL/teamficial-be

Length of output: 645


🏁 Script executed:

# Check if @EnableAsync might be in the main application class
find . -type f -name "*Application.java" -o -name "Main.java" | xargs cat 2>/dev/null | head -50

Repository: TEAMFICIAL/teamficial-be

Length of output: 1603


🏁 Script executed:

# Check Spring Boot configuration in application properties/yml files
find . -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) -exec cat {} \; 2>/dev/null | head -100

Repository: TEAMFICIAL/teamficial-be

Length of output: 50


🏁 Script executed:

# Look for any async configuration that might exist
rg "TaskExecutor|ThreadPoolExecutor|@Bean.*Executor" --type java -B 3 -A 5

Repository: TEAMFICIAL/teamficial-be

Length of output: 50


🏁 Script executed:

# Double-check if @EnableAsync is somewhere else or if there's a different async configuration
rg "@EnableAsync|@EnableAsyncSupport" --type java

Repository: TEAMFICIAL/teamficial-be

Length of output: 50


🏁 Script executed:

# Check the current transaction behavior - look for @Transactional scope
grep -n "@Transactional" ./src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java

Repository: TEAMFICIAL/teamficial-be

Length of output: 189


🌐 Web query:

Spring Boot @Async configuration @EnableAsync requirements JavaMailSender synchronous blocking

💡 Result:

Short answer:

  • @EnableAsync (or an equivalent async config) is required to enable Spring’s @async processing; Spring Boot provides an auto-configured AsyncTaskExecutor but does not implicitly enable the @async annotation for you (unless some other dependencia/autoconfiguration does). Use @EnableAsync on a @configuration (or implement AsyncConfigurer / provide an Executor bean named taskExecutor/applicationTaskExecutor) to control which Executor @async will use. [1][2][4]

  • JavaMailSender.send(...) is a synchronous, blocking call (it delegates to the JavaMail implementation). If you don’t want the caller thread blocked, call mail-sending from an @async method (or submit to your Executor / message queue), and set SMTP timeouts (mail.smtp.connectiontimeout, mail.smtp.timeout, mail.smtp.writetimeout) so an unresponsive mail server won’t block threads forever. Spring Boot auto-configures JavaMailSender when spring.mail.* is present. [3][5]

Pointers / minimal examples:

  • Enable async: @configuration @EnableAsync public class Cfg { @bean Executor taskExecutor() { ThreadPoolTaskExecutor t = new ThreadPoolTaskExecutor(); t.setCorePoolSize(10); t.setMaxPoolSize(50); t.initialize(); return t; } }. Use @async on your mail method. [1][2]
  • Mail timeouts in application.properties: spring.mail.properties[mail.smtp.connectiontimeout]=5000 and spring.mail.properties[mail.smtp.timeout]=3000. [5]

Sources:

  1. Spring Framework — @EnableAsync / Async processing docs.
  2. Spring Boot — Task execution & scheduling (auto-configured AsyncTaskExecutor and applicationTaskExecutor details).
  3. JavaMailSender / Spring mail docs (JavaMailSender API is synchronous).
  4. Spring Framework integration docs — @async executor qualification and usage.
  5. Spring Boot Sending Email docs (timeout properties and auto-configuration).

이메일 전송이 동기적으로 수행되며, @EnableAsync가 프로젝트에 구성되지 않았습니다.

mailService.sendReportEmail()이 동기적으로 호출되어 이메일 전송이 완료될 때까지 요청 처리가 차단됩니다. 전송 실패 시 @transactional 범위 내에서 전체 트랜잭션이 롤백되며, 메일 서버 지연이 직접 API 응답 시간에 영향을 줍니다.

비동기 처리를 구현하려면 다음과 같이 진행하세요:

  1. @configuration 클래스에 @EnableAsync를 추가하고 TaskExecutor Bean을 구성
  2. MailService의 sendReportEmail 메서드에 @async 추가
  3. 또는 ApplicationEvent 기반 이벤트 처리로 메일 전송 분리

메일 서버 타임아웃도 설정하여 무한 대기를 방지하세요.

🤖 Prompt for AI Agents
In
@src/main/java/teamficial/teamficial_be/domain/report/service/ReportService.java
around lines 57 - 58, ReportService currently calls
mailService.sendReportEmail(user.getEmail()) synchronously inside the
transactional flow, blocking request handling and risking rollback on mail
failures; enable asynchronous email sending by adding @EnableAsync to a
@Configuration class and define a TaskExecutor bean, annotate
MailService.sendReportEmail(...) with @Async (or convert to an ApplicationEvent
and an @Async @EventListener handler) so mail sending runs off the request
thread, and ensure mail client timeouts are configured to avoid indefinite
waits.

}

@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);
}
Comment thread
dldusgh318 marked this conversation as resolved.

@Transactional(readOnly = true)
public Report getById(Long reportId) {
return reportRepository.findById(reportId)
.orElseThrow(()-> new GeneralException(ErrorStatus.NOT_FOUND_REPORT));
}

@Transactional(readOnly = true)
public Slice<Report> getAllReport(Pageable pageable) {
return reportRepository.findAllByIsApplied(pageable);
}

private boolean alreadyExistReport(Long keywordCommentId) {
return reportRepository.existsByReportedCommentId(keywordCommentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading