diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java index 8159ceb..9d5811d 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java +++ b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java @@ -68,4 +68,10 @@ public class FileEntityRequest { @JsonProperty("approve") private Boolean approve; + + @JsonProperty("reason_text") + private String reasonText; + + @JsonProperty("reason_text") + private String searchQuery; } diff --git a/src/main/java/ru/soune/nocopy/dto/file/ModerationReasonRequest.java b/src/main/java/ru/soune/nocopy/dto/file/ModerationReasonRequest.java new file mode 100644 index 0000000..3e475c5 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ModerationReasonRequest.java @@ -0,0 +1,16 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ModerationReasonRequest { + private String action; + private String token; + private Long reasonId; + private String reasonText; + private Integer page; + private Integer pageSize; + private String searchQuery; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/ModerationReasonResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ModerationReasonResponse.java new file mode 100644 index 0000000..a5bbaa8 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ModerationReasonResponse.java @@ -0,0 +1,15 @@ +package ru.soune.nocopy.dto.file; +import lombok.Builder; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@Builder +public class ModerationReasonResponse { + private Long id; + private String reasonText; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Boolean isUsed; + private Long usageCount; +} diff --git a/src/main/java/ru/soune/nocopy/exception/ModerationReasonInUseException.java b/src/main/java/ru/soune/nocopy/exception/ModerationReasonInUseException.java new file mode 100644 index 0000000..21525ba --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/ModerationReasonInUseException.java @@ -0,0 +1,17 @@ +package ru.soune.nocopy.exception; + +import lombok.Getter; + +@Getter +public class ModerationReasonInUseException extends RuntimeException { + private final Long reasonId; + private final Long usageCount; + + public ModerationReasonInUseException(Long reasonId, Long usageCount) { + super(String.format("Cannot delete moderation reason %d because it is used by %d files", + reasonId, usageCount)); + this.reasonId = reasonId; + this.usageCount = usageCount; + } + +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/exception/ModerationReasonNotFoundException.java b/src/main/java/ru/soune/nocopy/exception/ModerationReasonNotFoundException.java new file mode 100644 index 0000000..a829628 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/ModerationReasonNotFoundException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class ModerationReasonNotFoundException extends RuntimeException { + public ModerationReasonNotFoundException(Long id) { + super("Moderation reason not found with id: " + id); + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java index 0fc33b2..09bcdbe 100644 --- a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java @@ -12,12 +12,11 @@ import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.FileStatus; import ru.soune.nocopy.entity.file.ProtectionStatus; import ru.soune.nocopy.entity.file.moderation.ModerationLog; -import ru.soune.nocopy.exception.FileEntityNotFoundException; -import ru.soune.nocopy.exception.InvalidAppealException; -import ru.soune.nocopy.exception.NotFoundAuthToken; +import ru.soune.nocopy.exception.*; import ru.soune.nocopy.repository.FileAppealRepository; import ru.soune.nocopy.repository.FileEntityRepository; import ru.soune.nocopy.repository.ModerationLogRepository; +import ru.soune.nocopy.service.file.moderation.ModerationReasonService; import ru.soune.nocopy.service.file.moderation.ModerationService; import ru.soune.nocopy.service.register.AuthService; import ru.soune.nocopy.service.file.FileEntityService; @@ -48,6 +47,8 @@ public class FileEntityHandler implements RequestHandler { private final ModerationLogRepository moderationLogRepository; + private final ModerationReasonService moderationReasonService; + @Override public BaseResponse handle(BaseRequest request) { try { @@ -85,6 +86,14 @@ public class FileEntityHandler implements RequestHandler { return handleGetUserAppeals(request, fileRequest); case "files_for_moderation": return handleGetFilesForModeration(request, fileRequest); + case "get_moderation_reason": + return handleGetModerationReason(request, fileRequest); + case "create_moderation_reason": + return handleCreateModerationReason(request, fileRequest); + case "update_moderation_reason": + return handleUpdateModerationReason(request, fileRequest); + case "delete_moderation_reason": + return handleDeleteModerationReason(request, fileRequest); default: ActionResponse response = ActionResponse.builder() .action(action) @@ -937,6 +946,214 @@ public class FileEntityHandler implements RequestHandler { .collect(Collectors.toList()); } + /** + * Получение списка причин модерации + */ + private BaseResponse handleGetModerationReasons(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO + // authService.useAdminAuthToken(fileRequest.getToken()); + + int page = fileRequest.getPage() != null ? fileRequest.getPage() : 1; + int pageSize = fileRequest.getPageSize() != null ? fileRequest.getPageSize() : 20; + String searchQuery = fileRequest.getSearchQuery(); + + Page reasons = moderationReasonService.getAllReasons( + page - 1, pageSize, searchQuery); + + Map response = new HashMap<>(); + response.put("reasons", reasons.getContent()); + response.put("totalCount", reasons.getTotalElements()); + response.put("totalPages", reasons.getTotalPages()); + response.put("currentPage", page); + response.put("pageSize", pageSize); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + response); + + } catch (SecurityException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.ACCESS_DENIED.getCode(), + "Admin access required", + null); + } catch (Exception e) { + log.error("Error getting moderation reasons", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to get reasons: " + e.getMessage(), + null); + } + } + + /** + * Получение конкретной причины модерации + */ + private BaseResponse handleGetModerationReason(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO + // authService.useAdminAuthToken(fileRequest.getToken()); + + Long reasonId = fileRequest.getModerationReasonId(); + if (reasonId == null) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "reasonId is required", + null); + } + + ModerationReasonResponse reason = moderationReasonService.getReasonById(reasonId); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + reason); + + } catch (ModerationReasonNotFoundException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.NOT_FOUND.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error getting moderation reason", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to get reason: " + e.getMessage(), + null); + } + } + + /** + * Создание новой причины модерации + */ + private BaseResponse handleCreateModerationReason(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO + // authService.useAdminAuthToken(fileRequest.getToken()); + + String reasonText = fileRequest.getReasonText(); + if (reasonText == null || reasonText.trim().isEmpty()) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "reasonText is required", + null); + } + + ModerationReasonResponse reason = moderationReasonService.createReason(reasonText); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "Moderation reason created successfully", + reason); + + } catch (IllegalArgumentException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error creating moderation reason", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to create reason: " + e.getMessage(), + null); + } + } + + /** + * Обновление причины модерации + */ + private BaseResponse handleUpdateModerationReason(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO + // authService.useAdminAuthToken(fileRequest.getToken()); + + Long reasonId = fileRequest.getModerationReasonId(); + String reasonText = fileRequest.getReasonText(); + + if (reasonId == null) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "reasonId is required", + null); + } + + if (reasonText == null || reasonText.trim().isEmpty()) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "reasonText is required", + null); + } + + ModerationReasonResponse reason = moderationReasonService.updateReason(reasonId, reasonText); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "Moderation reason updated successfully", + reason); + + } catch (ModerationReasonNotFoundException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.NOT_FOUND.getCode(), + e.getMessage(), + null); + } catch (IllegalArgumentException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error updating moderation reason", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to update reason: " + e.getMessage(), + null); + } + } + + /** + * Удаление причины модерации + */ + private BaseResponse handleDeleteModerationReason(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO + // authService.useAdminAuthToken(fileRequest.getToken()); + + Long reasonId = fileRequest.getModerationReasonId(); + if (reasonId == null) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "reasonId is required", + null); + } + + moderationReasonService.deleteReason(reasonId); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "Moderation reason deleted successfully", + Map.of("reasonId", reasonId)); + + } catch (ModerationReasonNotFoundException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.NOT_FOUND.getCode(), + e.getMessage(), + null); + } catch (ModerationReasonInUseException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + e.getMessage(), + Map.of("usageCount", e.getUsageCount())); + } catch (Exception e) { + log.error("Error deleting moderation reason", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to delete reason: " + e.getMessage(), + null); + } + } + private String formatFileSize(long size) { if (size < 1024) return size + " B"; int exp = (int) (Math.log(size) / Math.log(1024)); diff --git a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java index 0e5e014..3b98025 100644 --- a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java @@ -72,4 +72,8 @@ public interface FileEntityRepository extends JpaRepository Page findByStatusIn(List statuses, Pageable pageable); List findByUserIdAndStatusIn(Long userId, List statuses); + + @Query("SELECT COUNT(f) FROM FileEntity f WHERE f.moderationReasonId = :reasonId AND f.status IN :statuses") + long countByModerationReasonIdAndStatusIn(@Param("reasonId") Long reasonId, + @Param("statuses") List statuses); } diff --git a/src/main/java/ru/soune/nocopy/repository/ModerationReasonRepository.java b/src/main/java/ru/soune/nocopy/repository/ModerationReasonRepository.java new file mode 100644 index 0000000..81ebce4 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/ModerationReasonRepository.java @@ -0,0 +1,22 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ru.soune.nocopy.entity.file.moderation.ModerationReason; + +import java.util.Optional; + +public interface ModerationReasonRepository extends JpaRepository { + Optional findByReasonText(String reasonText); + + Page findAll(Pageable pageable); + + @Query("SELECT mr FROM ModerationReason mr WHERE mr.reasonText LIKE %:search%") + Page searchByText(@Param("search") String search, Pageable pageable); + + @Query("SELECT COUNT(mr) FROM ModerationReason mr") + long countAllReasons(); +} diff --git a/src/main/java/ru/soune/nocopy/service/file/moderation/ModerationReasonService.java b/src/main/java/ru/soune/nocopy/service/file/moderation/ModerationReasonService.java new file mode 100644 index 0000000..d9515af --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/moderation/ModerationReasonService.java @@ -0,0 +1,187 @@ +package ru.soune.nocopy.service.file.moderation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.dto.file.ModerationReasonResponse; +import ru.soune.nocopy.entity.file.FileStatus; +import ru.soune.nocopy.entity.file.moderation.ModerationReason; +import ru.soune.nocopy.exception.ModerationReasonInUseException; +import ru.soune.nocopy.exception.ModerationReasonNotFoundException; +import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.repository.ModerationReasonRepository; + +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ModerationReasonService { + + private final ModerationReasonRepository moderationReasonRepository; + private final FileEntityRepository fileEntityRepository; + + private static final List BLOCKED_STATUSES = Arrays.asList( + FileStatus.BLOCKED, + FileStatus.VIOLATION, + FileStatus.MODERATION + ); + + /** + * Создание новой причины модерации + */ + @Transactional + public ModerationReasonResponse createReason(String reasonText) { + if (reasonText == null || reasonText.trim().isEmpty()) { + throw new IllegalArgumentException("Reason text cannot be empty"); + } + + // Проверяем на дубликат + if (moderationReasonRepository.findByReasonText(reasonText.trim()).isPresent()) { + throw new IllegalArgumentException("Reason with this text already exists"); + } + + ModerationReason reason = ModerationReason.builder() + .reasonText(reasonText.trim()) + .build(); + + ModerationReason saved = moderationReasonRepository.save(reason); + + log.info("Created new moderation reason: id={}, text={}", saved.getId(), saved.getReasonText()); + + return convertToResponse(saved, false, 0L); + } + + /** + * Обновление причины модерации + */ + @Transactional + public ModerationReasonResponse updateReason(Long reasonId, String newReasonText) { + ModerationReason reason = moderationReasonRepository.findById(reasonId) + .orElseThrow(() -> new ModerationReasonNotFoundException(reasonId)); + + if (newReasonText == null || newReasonText.trim().isEmpty()) { + throw new IllegalArgumentException("Reason text cannot be empty"); + } + + // Проверяем на дубликат (исключая текущую запись) + moderationReasonRepository.findByReasonText(newReasonText.trim()) + .ifPresent(existingReason -> { + if (!existingReason.getId().equals(reasonId)) { + throw new IllegalArgumentException("Reason with this text already exists"); + } + }); + + String oldText = reason.getReasonText(); + reason.setReasonText(newReasonText.trim()); + + ModerationReason updated = moderationReasonRepository.save(reason); + + log.info("Updated moderation reason: id={}, oldText={}, newText={}", + reasonId, oldText, newReasonText); + + long usageCount = getReasonUsageCount(reasonId); + + return convertToResponse(updated, usageCount > 0, usageCount); + } + + /** + * Удаление причины модерации + */ + @Transactional + public void deleteReason(Long reasonId) { + ModerationReason reason = moderationReasonRepository.findById(reasonId) + .orElseThrow(() -> new ModerationReasonNotFoundException(reasonId)); + + // Проверяем, используется ли причина в активных блокировках + long usageCount = getReasonUsageCount(reasonId); + + if (usageCount > 0) { + throw new ModerationReasonInUseException(reasonId, usageCount); + } + + moderationReasonRepository.delete(reason); + + log.info("Deleted moderation reason: id={}, text={}", reasonId, reason.getReasonText()); + } + + /** + * Получение причины по ID + */ + @Transactional(readOnly = true) + public ModerationReasonResponse getReasonById(Long reasonId) { + ModerationReason reason = moderationReasonRepository.findById(reasonId) + .orElseThrow(() -> new ModerationReasonNotFoundException(reasonId)); + + long usageCount = getReasonUsageCount(reasonId); + + return convertToResponse(reason, usageCount > 0, usageCount); + } + + /** + * Получение всех причин с пагинацией + */ + @Transactional(readOnly = true) + public Page getAllReasons(int page, int size, String searchQuery) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + + Page reasonsPage; + + if (searchQuery != null && !searchQuery.trim().isEmpty()) { + reasonsPage = moderationReasonRepository.searchByText(searchQuery.trim(), pageable); + } else { + reasonsPage = moderationReasonRepository.findAll(pageable); + } + + return reasonsPage.map(reason -> { + long usageCount = getReasonUsageCount(reason.getId()); + return convertToResponse(reason, usageCount > 0, usageCount); + }); + } + + /** + * Получение списка всех причин (без пагинации, для выпадающих списков) + */ + @Transactional(readOnly = true) + public List getAllReasonsList() { + List reasons = moderationReasonRepository.findAll(Sort.by("reasonText").ascending()); + + return reasons.stream() + .map(reason -> { + long usageCount = getReasonUsageCount(reason.getId()); + return convertToResponse(reason, usageCount > 0, usageCount); + }) + .collect(java.util.stream.Collectors.toList()); + } + + /** + * Подсчет количества файлов, заблокированных по данной причине + */ + private long getReasonUsageCount(Long reasonId) { + return fileEntityRepository.countByModerationReasonIdAndStatusIn(reasonId, BLOCKED_STATUSES); + } + + /** + * Проверка, используется ли причина в активных блокировках + */ + public boolean isReasonInUse(Long reasonId) { + return getReasonUsageCount(reasonId) > 0; + } + + private ModerationReasonResponse convertToResponse(ModerationReason reason, boolean isUsed, long usageCount) { + return ModerationReasonResponse.builder() + .id(reason.getId()) + .reasonText(reason.getReasonText()) + .createdAt(reason.getCreatedAt()) + .updatedAt(reason.getUpdatedAt()) + .isUsed(isUsed) + .usageCount(usageCount) + .build(); + } +}