diff --git a/src/main/java/ru/soune/nocopy/dto/file/AppealInfo.java b/src/main/java/ru/soune/nocopy/dto/file/AppealInfo.java new file mode 100644 index 0000000..6c2bc7c --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/AppealInfo.java @@ -0,0 +1,14 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class AppealInfo { + private String appealId; + private String status; + private LocalDateTime createdAt; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/AppealRequest.java b/src/main/java/ru/soune/nocopy/dto/file/AppealRequest.java new file mode 100644 index 0000000..1590820 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/AppealRequest.java @@ -0,0 +1,13 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AppealRequest { + private String fileId; + private String appealReason; + private String additionalInfo; + private String token; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/AppealResponse.java b/src/main/java/ru/soune/nocopy/dto/file/AppealResponse.java new file mode 100644 index 0000000..60739b6 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/AppealResponse.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class AppealResponse { + private String appealId; + private String fileId; + private String appealReason; + private String additionalInfo; + private String status; + private String adminComment; + private LocalDateTime createdAt; + private LocalDateTime resolvedAt; +} 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 c21f7b4..8159ceb 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java +++ b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java @@ -5,6 +5,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import ru.soune.nocopy.entity.file.FileStatus; @Data @Builder @@ -44,6 +45,27 @@ public class FileEntityRequest { @JsonProperty("type") private String type; + @JsonProperty("file_status") + private FileStatus fileStatus; + @JsonProperty("date_filter") private String dateFilter; + + @JsonProperty("moderation_reason_id") + private Long moderationReasonId; + + @JsonProperty("comment") + private String comment; + + @JsonProperty("appeal_reason") + private String appealReason; + + @JsonProperty("additional_info") + private String additionalInfo; + + @JsonProperty("appeal_id") + private String appealId; + + @JsonProperty("approve") + private Boolean approve; } diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileModerationInfo.java b/src/main/java/ru/soune/nocopy/dto/file/FileModerationInfo.java new file mode 100644 index 0000000..fbe409c --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileModerationInfo.java @@ -0,0 +1,21 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; +import ru.soune.nocopy.entity.file.FileStatus; + +import java.time.LocalDateTime; + +@Data +@Builder +public class FileModerationInfo { + private String fileId; + private String fileName; + private String ownerName; + private String ownerEmail; + private FileStatus currentStatus; + private LocalDateTime uploadDate; + private String moderationReason; + private Boolean hasActiveAppeal; + private AppealInfo activeAppeal; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/ModerationActionRequest.java b/src/main/java/ru/soune/nocopy/dto/file/ModerationActionRequest.java new file mode 100644 index 0000000..ad6f6b1 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ModerationActionRequest.java @@ -0,0 +1,22 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; +import ru.soune.nocopy.entity.file.FileStatus; + +@Data +@Builder +public class ModerationActionRequest { + private String fileId; + private FileStatus newStatus; + private Long moderationReasonId; + private String comment; + private String token; +} + + + + + + + diff --git a/src/main/java/ru/soune/nocopy/entity/file/AppealStatus.java b/src/main/java/ru/soune/nocopy/entity/file/AppealStatus.java new file mode 100644 index 0000000..01018fb --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/AppealStatus.java @@ -0,0 +1,9 @@ +package ru.soune.nocopy.entity.file; + +public enum AppealStatus { + PENDING, + IN_REVIEW, + APPROVED, + REJECTED, + EXPIRED +} diff --git a/src/main/java/ru/soune/nocopy/entity/file/FileAppeal.java b/src/main/java/ru/soune/nocopy/entity/file/FileAppeal.java new file mode 100644 index 0000000..36a5553 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/FileAppeal.java @@ -0,0 +1,84 @@ +package ru.soune.nocopy.entity.file; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "file_appeals") +@EntityListeners(AuditingEntityListener.class) +public class FileAppeal { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(name = "file_id", nullable = false) + private String fileId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "file_id", insertable = false, updatable = false) + @ToString.Exclude + private FileEntity file; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "appeal_reason", nullable = false, columnDefinition = "TEXT") + private String appealReason; + + @Column(name = "additional_info", columnDefinition = "TEXT") + private String additionalInfo; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private AppealStatus status; + + @Column(name = "admin_comment", columnDefinition = "TEXT") + private String adminComment; + + @Column(name = "moderator_id") + private Long moderatorId; + + @Column(name = "moderation_before_status") + @Enumerated(EnumType.STRING) + private FileStatus moderationBeforeStatus; + + @Column(name = "moderation_reason_id") + private Long moderationReasonId; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (this.status == null) { + this.status = AppealStatus.PENDING; + } + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/file/moderation/ModerationLog.java b/src/main/java/ru/soune/nocopy/entity/file/moderation/ModerationLog.java new file mode 100644 index 0000000..04249a7 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/moderation/ModerationLog.java @@ -0,0 +1,56 @@ +package ru.soune.nocopy.entity.file.moderation; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.entity.file.FileStatus; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "moderation_logs") +@EntityListeners(AuditingEntityListener.class) +public class ModerationLog { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(name = "file_id", nullable = false) + private String fileId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "file_id", insertable = false, updatable = false) + @ToString.Exclude + private FileEntity file; + + @Column(name = "moderator_id", nullable = false) + private Long moderatorId; + + @Enumerated(EnumType.STRING) + @Column(name = "old_status", nullable = false) + private FileStatus oldStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "new_status", nullable = false) + private FileStatus newStatus; + + @Column(name = "reason", columnDefinition = "TEXT") + private String reason; + + @Column(name = "moderation_reason_id") + private Long moderationReasonId; + + @Column(name = "comment", columnDefinition = "TEXT") + private String comment; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/ru/soune/nocopy/exception/InvalidAppealException.java b/src/main/java/ru/soune/nocopy/exception/InvalidAppealException.java new file mode 100644 index 0000000..b2ba888 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/InvalidAppealException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class InvalidAppealException extends RuntimeException { + public InvalidAppealException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java index 210b5c6..0fc33b2 100644 --- a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java @@ -3,15 +3,22 @@ package ru.soune.nocopy.handler; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; import ru.soune.nocopy.dto.*; import ru.soune.nocopy.dto.file.*; +import ru.soune.nocopy.entity.file.FileAppeal; 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.repository.FileAppealRepository; import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.repository.ModerationLogRepository; +import ru.soune.nocopy.service.file.moderation.ModerationService; import ru.soune.nocopy.service.register.AuthService; import ru.soune.nocopy.service.file.FileEntityService; import ru.soune.nocopy.service.file.FileStatsService; @@ -35,6 +42,12 @@ public class FileEntityHandler implements RequestHandler { private final FileEntityRepository fileEntityRepository; + private final ModerationService moderationService; + + private final FileAppealRepository fileAppealRepository; + + private final ModerationLogRepository moderationLogRepository; + @Override public BaseResponse handle(BaseRequest request) { try { @@ -60,6 +73,18 @@ public class FileEntityHandler implements RequestHandler { return handleProtectFile(request, fileRequest); case "delete_file": return handleDeleteFile(request, fileRequest); + case "change_file_status": + return handleChangeStatus(request, fileRequest); + case "moderate_file": + return handleModerateFile(request, fileRequest); + case "submit_appeal": + return handleSubmitAppeal(request, fileRequest); + case "review_appeal": + return handleReviewAppeal(request, fileRequest); + case "user_appeals": + return handleGetUserAppeals(request, fileRequest); + case "files_for_moderation": + return handleGetFilesForModeration(request, fileRequest); default: ActionResponse response = ActionResponse.builder() .action(action) @@ -183,6 +208,23 @@ public class FileEntityHandler implements RequestHandler { } } + private BaseResponse handleChangeStatus(BaseRequest request, FileEntityRequest fileRequest) { + FileStatus newStatus = fileRequest.getFileStatus(); + try { + fileEntityService.changeFileStatus(newStatus ,fileRequest.getFileId()); + } catch (FileEntityNotFoundException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_NOT_FOUND.getCode(), + MessageCode.FILE_NOT_FOUND.getDescription(), + null); + } + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + Map.of("new_status", newStatus)); + } + private BaseResponse handleSearchFiles(BaseRequest request, FileEntityRequest fileRequest) { try { Long userId = authService.useUserAuthToken(fileRequest.getToken()); @@ -568,6 +610,333 @@ public class FileEntityHandler implements RequestHandler { } } + private BaseResponse handleModerateFile(BaseRequest request, FileEntityRequest fileRequest) { + try { + //TODO добавить проверку на админа +// Long moderatorId = authService.useAdminAuthToken(fileRequest.getToken()); + + ModerationActionRequest moderationRequest = ModerationActionRequest.builder() + .fileId(fileRequest.getFileId()) + .newStatus(fileRequest.getFileStatus()) + .moderationReasonId(fileRequest.getModerationReasonId()) + .comment(fileRequest.getComment()) + .build(); + + //TODO set moderator ID + moderationService.moderateFile(moderationRequest, 0L); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "File moderated successfully", + null); + + } catch (SecurityException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.ACCESS_DENIED.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error moderating file", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to moderate file: " + e.getMessage(), + null); + } + } + + private BaseResponse handleSubmitAppeal(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = authService.useUserAuthToken(fileRequest.getToken()); + + AppealRequest appealRequest = AppealRequest.builder() + .fileId(fileRequest.getFileId()) + .appealReason(fileRequest.getAppealReason()) + .additionalInfo(fileRequest.getAdditionalInfo()) + .build(); + + AppealResponse response = moderationService.submitAppeal(appealRequest, userId); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "Appeal submitted successfully", + response); + + } catch (InvalidAppealException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error submitting appeal", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to submit appeal: " + e.getMessage(), + null); + } + } + + private BaseResponse handleReviewAppeal(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO +// Long moderatorId = authService.useAdminAuthToken(fileRequest.getToken()); + + String appealId = fileRequest.getAppealId(); + Boolean approve = fileRequest.getApprove(); + String comment = fileRequest.getComment(); + + if (appealId == null || approve == null) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "appealId and approve are required", + null); + } + //TODO + moderationService.reviewAppeal(appealId, approve, comment, 0L); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "Appeal reviewed successfully", + Map.of( + "appealId", appealId, + "approved", approve, + "comment", comment + )); + + } catch (SecurityException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.ACCESS_DENIED.getCode(), + "Admin access required", + null); + } catch (IllegalStateException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error reviewing appeal", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to review appeal: " + e.getMessage(), + null); + } + } + + /** + * Получение списка апелляций пользователя + */ + private BaseResponse handleGetUserAppeals(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = authService.useUserAuthToken(fileRequest.getToken()); + + int page = fileRequest.getPage() != null ? fileRequest.getPage() : 1; + int pageSize = fileRequest.getPageSize() != null ? fileRequest.getPageSize() : 20; + + Page appeals = moderationService.getUserAppeals(userId, page - 1, pageSize); + + Map response = Map.of( + "appeals", appeals.getContent(), + "totalCount", appeals.getTotalElements(), + "totalPages", appeals.getTotalPages(), + "currentPage", page, + "pageSize", pageSize); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + response); + + } catch (SecurityException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error getting user appeals", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to get appeals: " + e.getMessage(), + null); + } + } + + /** + * Получение списка файлов на модерации + */ + private BaseResponse handleGetFilesForModeration(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; + + Page files = moderationService.getFilesForModeration(page - 1, pageSize); + + List> filesWithInfo = files.getContent().stream() + .map(file -> { + FileModerationInfo info = moderationService.getFileModerationInfo(file.getId()); + return Map.of( + "fileId", file.getId(), + "fileName", file.getOriginalFileName(), + "userId", file.getUserId(), + "status", file.getStatus().name(), + "createdAt", file.getCreatedAt(), + "fileSize", file.getFileSize(), + "formattedSize", fileEntityService.formatFileSize(file.getFileSize()), + "moderationInfo", Map.of( + "hasActiveAppeal", info.getHasActiveAppeal(), + "appealInfo", info.getActiveAppeal() != null ? + Map.of( + "appealId", info.getActiveAppeal().getAppealId(), + "status", info.getActiveAppeal().getStatus(), + "createdAt", info.getActiveAppeal().getCreatedAt() + ) : null + ) + ); + }) + .collect(Collectors.toList()); + + Map response = Map.of( + "files", filesWithInfo, + "totalCount", files.getTotalElements(), + "totalPages", files.getTotalPages(), + "currentPage", page, + "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 files for moderation", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to get files: " + e.getMessage(), + null); + } + } + + /** + * Получение детальной информации о файле для модерации + */ + private BaseResponse handleGetFileModerationInfo(BaseRequest request, FileEntityRequest fileRequest) { + try { + // TODO + // authService.useAdminAuthToken(fileRequest.getToken()); + + String fileId = fileRequest.getFileId(); + if (fileId == null) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "fileId is required", + null); + } + + FileModerationInfo moderationInfo = moderationService.getFileModerationInfo(fileId); + + FileEntity file = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + + Map fileInfoMap = new HashMap<>(); + fileInfoMap.put("id", file.getId()); + fileInfoMap.put("fileName", file.getOriginalFileName()); + fileInfoMap.put("storedFileName", file.getStoredFileName()); + fileInfoMap.put("fileSize", file.getFileSize()); + fileInfoMap.put("formattedSize", fileEntityService.formatFileSize(file.getFileSize())); + fileInfoMap.put("mimeType", file.getMimeType()); + fileInfoMap.put("fileExtension", file.getFileExtension()); + fileInfoMap.put("checksum", file.getChecksum()); + fileInfoMap.put("status", file.getStatus() != null ? file.getStatus().name() : null); + fileInfoMap.put("createdAt", file.getCreatedAt()); + fileInfoMap.put("updatedAt", file.getUpdatedAt()); + + Map response = new HashMap<>(); + response.put("fileInfo", fileInfoMap); + response.put("moderationInfo", moderationInfo != null ? moderationInfo : new HashMap<>()); + response.put("moderationHistory", getModerationHistory(fileId)); + response.put("appeals", getFileAppeals(fileId)); + + 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 (FileEntityNotFoundException e) { + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_NOT_FOUND.getCode(), + e.getMessage(), + null); + } catch (Exception e) { + log.error("Error getting file moderation info", e); + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_FIELD.getCode(), + "Failed to get file info: " + e.getMessage(), + null); + } + } + + /** + * Получение истории модерации файла + */ + private List> getModerationHistory(String fileId) { + List logs = moderationLogRepository.findByFileIdOrderByCreatedAtDesc(fileId); + if (logs == null || logs.isEmpty()) { + return Collections.emptyList(); + } + + return logs.stream() + .map(log -> { + Map logMap = new HashMap<>(); + logMap.put("id", log.getId()); + logMap.put("moderatorId", log.getModeratorId()); + logMap.put("oldStatus", log.getOldStatus() != null ? log.getOldStatus().name() : null); + logMap.put("newStatus", log.getNewStatus() != null ? log.getNewStatus().name() : null); + logMap.put("reason", log.getReason()); + logMap.put("comment", log.getComment()); + logMap.put("createdAt", log.getCreatedAt()); + return logMap; + }) + .collect(Collectors.toList()); + } + + /** + * Получение всех апелляций файла + */ + private List> getFileAppeals(String fileId) { + List appeals = fileAppealRepository.findByFileId(fileId); + if (appeals == null || appeals.isEmpty()) { + return Collections.emptyList(); + } + + return appeals.stream() + .map(appeal -> { + Map appealMap = new HashMap<>(); + appealMap.put("id", appeal.getId()); + appealMap.put("userId", appeal.getUserId()); + appealMap.put("appealReason", appeal.getAppealReason()); + appealMap.put("additionalInfo", appeal.getAdditionalInfo()); + appealMap.put("status", appeal.getStatus() != null ? appeal.getStatus().name() : null); + appealMap.put("adminComment", appeal.getAdminComment()); + appealMap.put("moderatorId", appeal.getModeratorId()); + appealMap.put("createdAt", appeal.getCreatedAt()); + appealMap.put("resolvedAt", appeal.getResolvedAt()); + return appealMap; + }) + .collect(Collectors.toList()); + } + 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/FileAppealRepository.java b/src/main/java/ru/soune/nocopy/repository/FileAppealRepository.java new file mode 100644 index 0000000..c645496 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/FileAppealRepository.java @@ -0,0 +1,28 @@ +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.FileAppeal; +import ru.soune.nocopy.entity.file.AppealStatus; +import java.util.List; +import java.util.Optional; + +public interface FileAppealRepository extends JpaRepository { + + Optional findByFileIdAndStatus(String fileId, AppealStatus status); + + List findByFileId(String fileId); + + Page findByStatus(AppealStatus status, Pageable pageable); + + Page findByUserId(Long userId, Pageable pageable); + + @Query("SELECT fa FROM FileAppeal fa WHERE fa.status = :status ORDER BY fa.createdAt ASC") + Page findPendingAppeals(@Param("status") AppealStatus status, Pageable pageable); + + boolean existsByFileIdAndStatusIn(String fileId, List statuses); +} + diff --git a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java index 234cbc4..0e5e014 100644 --- a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java @@ -1,5 +1,7 @@ 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; @@ -66,4 +68,8 @@ public interface FileEntityRepository extends JpaRepository Integer findMaxSupportId(); List findByThumbnailPathIsNull(); + + Page findByStatusIn(List statuses, Pageable pageable); + + List findByUserIdAndStatusIn(Long userId, List statuses); } diff --git a/src/main/java/ru/soune/nocopy/repository/ModerationLogRepository.java b/src/main/java/ru/soune/nocopy/repository/ModerationLogRepository.java new file mode 100644 index 0000000..f407060 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/ModerationLogRepository.java @@ -0,0 +1,13 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.soune.nocopy.entity.file.moderation.ModerationLog; + +import java.util.List; + +public interface ModerationLogRepository extends JpaRepository { + + List findByFileIdOrderByCreatedAtDesc(String fileId); + + List findByModeratorId(Long moderatorId); +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java b/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java index 3131eac..16e2e3b 100644 --- a/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java +++ b/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java @@ -16,7 +16,6 @@ import ru.soune.nocopy.entity.monitoring.MonitoringType; import ru.soune.nocopy.entity.user.User; import ru.soune.nocopy.exception.DuplicateImageException; import ru.soune.nocopy.exception.FileEntityNotFoundException; -import ru.soune.nocopy.handler.MonitoringHandler; import ru.soune.nocopy.repository.FileEntityRepository; import ru.soune.nocopy.repository.FileMonitoringRepository; import ru.soune.nocopy.repository.SimilarImageProjection; @@ -252,7 +251,7 @@ public class FileEntityService { return totalSize != null ? totalSize : 0L; } - public void changeStatus(ProtectionStatus newStatus, String fileId) { + public void changeProtectionStatus(ProtectionStatus newStatus, String fileId) { FileEntity fileEntity = fileEntityRepository.findById(fileId) .orElseThrow(() -> new FileEntityNotFoundException(fileId)); fileEntity.setProtectionStatus(newStatus); @@ -260,6 +259,14 @@ public class FileEntityService { fileEntityRepository.save(fileEntity); } + public void changeFileStatus(FileStatus newStatus, String fileId) { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + fileEntity.setStatus(newStatus); + + fileEntityRepository.save(fileEntity); + } + @Transactional public void writeProtectedFile(String id, byte[] data, String fileExt) throws IOException { FileEntity fileEntity = fileEntityRepository.findById(id) diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java index 257cf65..0a7e9cf 100644 --- a/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java +++ b/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java @@ -16,21 +16,21 @@ public class NoCopyProcessingListenerImpl implements FileProtector.ProcessingLis @Override public void onStartProcessing(FileProtector.FileInfo fileInfo) { - fileEntityService.changeStatus(ProtectionStatus.PROCESSING, fileInfo.getId()); + fileEntityService.changeProtectionStatus(ProtectionStatus.PROCESSING, fileInfo.getId()); } @Override public void onProcessingFailed(FileProtector.FileInfo fileInfo) { - fileEntityService.changeStatus(ProtectionStatus.FAILED, fileInfo.getId()); + fileEntityService.changeProtectionStatus(ProtectionStatus.FAILED, fileInfo.getId()); } @Override public void onSavingFailed(FileProtector.FileInfo fileInfo) { - fileEntityService.changeStatus(ProtectionStatus.FAILED_SAVE, fileInfo.getId()); + fileEntityService.changeProtectionStatus(ProtectionStatus.FAILED_SAVE, fileInfo.getId()); } @Override public void onFinish(FileProtector.FileInfo fileInfo) { - fileEntityService.changeStatus(ProtectionStatus.PROTECTED, fileInfo.getId());; + fileEntityService.changeProtectionStatus(ProtectionStatus.PROTECTED, fileInfo.getId());; } } diff --git a/src/main/java/ru/soune/nocopy/service/file/moderation/ModerationService.java b/src/main/java/ru/soune/nocopy/service/file/moderation/ModerationService.java new file mode 100644 index 0000000..5ee458e --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/moderation/ModerationService.java @@ -0,0 +1,236 @@ +package ru.soune.nocopy.service.file.moderation; + +import lombok.RequiredArgsConstructor; +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.*; +import ru.soune.nocopy.entity.file.*; +import ru.soune.nocopy.entity.file.moderation.ModerationLog; +import ru.soune.nocopy.exception.FileEntityNotFoundException; + +import ru.soune.nocopy.exception.InvalidAppealException; +import ru.soune.nocopy.repository.FileAppealRepository; +import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.repository.ModerationLogRepository; +import ru.soune.nocopy.service.file.FileEntityService; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ModerationService { + private final FileEntityRepository fileEntityRepository; + + private final FileAppealRepository fileAppealRepository; + + private final ModerationLogRepository moderationLogRepository; + + private final FileEntityService fileEntityService; + + private static final List MODERATION_NEEDED_STATUSES = Arrays.asList( + FileStatus.MODERATION, + FileStatus.BLOCKED + ); + + /** + * Модерация файла администратором + */ + @Transactional + public void moderateFile(ModerationActionRequest request, Long moderatorId) { + FileEntity file = fileEntityRepository.findById(request.getFileId()) + .orElseThrow(() -> new FileEntityNotFoundException(request.getFileId())); + + if (!MODERATION_NEEDED_STATUSES.contains(file.getStatus())) { + throw new IllegalStateException("File is not in moderation state. Current status: " + file.getStatus()); + } + + ModerationLog log = ModerationLog.builder() + .fileId(file.getId()) + .moderatorId(moderatorId) + .oldStatus(file.getStatus()) + .newStatus(request.getNewStatus()) + .reason(request.getComment()) + .moderationReasonId(request.getModerationReasonId()) + .comment(request.getComment()) + .build(); + moderationLogRepository.save(log); + + file.setStatus(request.getNewStatus()); + file.setModerationReasonId(request.getModerationReasonId()); + fileEntityRepository.save(file); + + if (request.getNewStatus() == FileStatus.BLOCKED || + request.getNewStatus() == FileStatus.VIOLATION) { + + fileAppealRepository.findByFileIdAndStatus(file.getId(), AppealStatus.PENDING) + .ifPresent(appeal -> { + appeal.setStatus(AppealStatus.REJECTED); + appeal.setAdminComment("Апелляция отклонена в процессе модерации"); + appeal.setResolvedAt(LocalDateTime.now()); + fileAppealRepository.save(appeal); + }); + } + } + + /** + * Подача апелляции пользователем + */ + @Transactional + public AppealResponse submitAppeal(AppealRequest request, Long userId) { + FileEntity file = fileEntityRepository.findById(request.getFileId()) + .orElseThrow(() -> new FileEntityNotFoundException(request.getFileId())); + + if (!file.getUserId().equals(userId)) { + throw new SecurityException("User is not the owner of this file"); + } + + if (!canAppeal(file)) { + throw new InvalidAppealException("Cannot appeal this file. Current status: " + file.getStatus()); + } + + if (hasActiveAppeal(file.getId())) { + throw new InvalidAppealException("Active appeal already exists for this file"); + } + + FileAppeal appeal = FileAppeal.builder() + .fileId(file.getId()) + .userId(userId) + .appealReason(request.getAppealReason()) + .additionalInfo(request.getAdditionalInfo()) + .status(AppealStatus.PENDING) + .moderationBeforeStatus(file.getStatus()) + .moderationReasonId(file.getModerationReasonId()) + .build(); + + FileAppeal saved = fileAppealRepository.save(appeal); + + return convertToResponse(saved); + } + + /** + * Рассмотрение апелляции администратором + */ + @Transactional + public void reviewAppeal(String appealId, boolean approve, String comment, Long moderatorId) { + FileAppeal appeal = fileAppealRepository.findById(appealId) + .orElseThrow(() -> new RuntimeException("Appeal not found: " + appealId)); + + if (appeal.getStatus() != AppealStatus.PENDING) { + throw new IllegalStateException("Appeal already processed"); + } + + FileEntity file = fileEntityRepository.findById(appeal.getFileId()) + .orElseThrow(() -> new FileEntityNotFoundException(appeal.getFileId())); + + appeal.setStatus(approve ? AppealStatus.APPROVED : AppealStatus.REJECTED); + appeal.setAdminComment(comment); + appeal.setModeratorId(moderatorId); + appeal.setResolvedAt(LocalDateTime.now()); + + if (approve) { + file.setStatus(FileStatus.ACTIVE); + file.setModerationReasonId(null); + } + + ModerationLog log = ModerationLog.builder() + .fileId(file.getId()) + .moderatorId(moderatorId) + .oldStatus(file.getStatus()) + .newStatus(file.getStatus()) + .comment("Appeal review: " + comment) + .build(); + moderationLogRepository.save(log); + + fileEntityRepository.save(file); + fileAppealRepository.save(appeal); + } + + /** + * Проверка, можно ли подать апелляцию на файл + */ + public boolean canAppeal(FileEntity file) { + if (file.getStatus() != FileStatus.BLOCKED) { + return false; + } + + return true; + } + + /** + * Проверка наличия активной апелляции + */ + public boolean hasActiveAppeal(String fileId) { + return fileAppealRepository.existsByFileIdAndStatusIn( + fileId, + Arrays.asList(AppealStatus.PENDING, AppealStatus.IN_REVIEW) + ); + } + + /** + * Получение информации о файле для модерации + */ + @Transactional(readOnly = true) + public FileModerationInfo getFileModerationInfo(String fileId) { + FileEntity file = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + + FileAppeal activeAppeal = fileAppealRepository.findByFileIdAndStatus( + fileId, AppealStatus.PENDING).orElse(null); + + return FileModerationInfo.builder() + .fileId(file.getId()) + .fileName(file.getOriginalFileName()) + .currentStatus(file.getStatus()) + .uploadDate(file.getCreatedAt()) + .hasActiveAppeal(activeAppeal != null) + .activeAppeal(activeAppeal != null ? + AppealInfo.builder() + .appealId(activeAppeal.getId()) + .status(activeAppeal.getStatus().name()) + .createdAt(activeAppeal.getCreatedAt()) + .build() : null) + .build(); + } + + /** + * Получение списка файлов на модерации + */ + @Transactional(readOnly = true) + public Page getFilesForModeration(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + return fileEntityRepository.findByStatusIn( + Arrays.asList(FileStatus.MODERATION, FileStatus.BLOCKED), + pageable + ); + } + + /** + * Получение апелляций пользователя + */ + @Transactional(readOnly = true) + public Page getUserAppeals(Long userId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + return fileAppealRepository.findByUserId(userId, pageable) + .map(this::convertToResponse); + } + + private AppealResponse convertToResponse(FileAppeal appeal) { + return AppealResponse.builder() + .appealId(appeal.getId()) + .fileId(appeal.getFileId()) + .appealReason(appeal.getAppealReason()) + .additionalInfo(appeal.getAdditionalInfo()) + .status(appeal.getStatus().name()) + .adminComment(appeal.getAdminComment()) + .createdAt(appeal.getCreatedAt()) + .resolvedAt(appeal.getResolvedAt()) + .build(); + } +}