Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,49 @@ 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) {
//코멘트 삭제
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,12 @@ 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", "해당 신고를 찾을 수 없습니다.")
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
);
Expand Down
Loading