Merge branch 'dev' into NCBACK-35

# Conflicts:
#	README.md
#	src/main/java/ru/soune/nocopy/controller/ApiController.java
#	src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java
#	src/main/java/ru/soune/nocopy/service/file/FileCleanupService.java
#	src/main/java/ru/soune/nocopy/service/file/FileEntityService.java
#	src/main/java/ru/soune/nocopy/service/file/impl/FileUploadServiceImpl.java
#	src/main/java/ru/soune/nocopy/service/file/impl/ProtectionFileProviderImpl.java
This commit is contained in:
vladp
2026-03-31 14:24:03 +07:00
229 changed files with 10416 additions and 757 deletions
@@ -10,14 +10,19 @@ import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.dto.file.SimilarFileDTO;
import ru.soune.nocopy.dto.file.SimilarityFilter;
import ru.soune.nocopy.entity.company.Company;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.ImageHashEntity;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.ImageHashRepository;
import ru.soune.nocopy.repository.ImageSimilarityRepository;
import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.DuplicateImageException;
import ru.soune.nocopy.repository.*;
import ru.soune.nocopy.util.FileUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
@@ -34,48 +39,11 @@ public class FileSimilarityService {
private final AuthTokenRepository authTokenRepository;
private final FileEntityRepository fileEntityRepository;
@Value("${server.baseurl}")
private String baseUrl;
public List<SimilarFileDTO> findSimilarFiles(String fileId) {
var imageHashEntity = hashRepository.findById(fileId)
.orElseThrow(() -> new RuntimeException("Hash not found"));
Long hash64Hi = imageHashEntity.getHash64Hi();
Long hash64Lo = imageHashEntity.getHash64Lo();
List<SimilarImageProjection> candidates = repository.findCandidates(fileId);
return candidates.stream()
.map(c -> {
Long cHi = c.getHash64Hi();
Long cLo = c.getHash64Lo();
int hamming = fileUtil.hamming64(hash64Hi, hash64Lo, cHi, cLo);
String level;
if (hamming <= 5) {
level = "DUPLICATE";
} else if (hamming <= 12) {
level = "SIMILAR";
} else {
level = "DIFFERENT";
}
return SimilarFileDTO.builder()
.fileId(c.getId())
.ownerId(c.getUserId())
.originalFileName(c.getOriginalFileName())
.fileSize(c.getFileSize())
.hammingDistance(hamming)
.similarityLevel(level)
.build();
})
.sorted((a, b) ->
Integer.compare(a.getHammingDistance(), b.getHammingDistance()))
.toList();
}
public List<SimilarFileDTO> findDuplicateByHammingDistance(String fileId, int hammingDistance,
int duplicate, int similar) {
List<SimilarImageProjection> candidates = repository.findCandidates(fileId);
@@ -137,40 +105,114 @@ public class FileSimilarityService {
return duplicates;
}
public Page<SimilarFileDTO> findSimilarFiles(String fileId, SimilarityFilter filter, Pageable pageable, String authToken) {
var imageHashEntity = hashRepository.findById(fileId)
.orElseThrow(() -> new RuntimeException("Hash not found"));
public void hasDuplicatesByHash(String path, String mimeType, Long userId) throws Exception {
FileEntity duplicateByHash = findDuplicateByHash(path, mimeType, userId);
if (duplicateByHash != null) {
throw new DuplicateImageException("Duplicate", duplicateByHash.getId(),
duplicateByHash.getUserId());
}
}
Long hash64Hi = imageHashEntity.getHash64Hi();
Long hash64Lo = imageHashEntity.getHash64Lo();
public FileEntity findDuplicateByHash(String path, String mimeType, Long userId) throws Exception {
String hash = calculateFileHash(path);
List<SimilarImageProjection> candidates = authToken.equals("all") ? repository.findCandidates(fileId):
repository.findCandidatesFromUserFiles(fileId, authTokenRepository.findUserIdByToken(authToken));
User user = userRepository.findById(userId).orElseThrow();
Company company = user.getCompany();
List<Long> userIds = new ArrayList<>();
List<String> similarityLevels = (filter != null && filter.getSimilarityLevels() != null)
? filter.getSimilarityLevels()
: List.of("DUPLICATE", "SIMILAR", "DIFFERENT");
if (company != null) {
userIds.addAll(company.getUsers().stream().map(User::getId).toList());
} else {
userIds.add(userId);
}
List<SimilarFileDTO> allResults = candidates.stream()
.map(c -> createSimilarFileResponse(c, hash64Hi, hash64Lo))
.filter(response -> similarityLevels.contains(response.getSimilarityLevel()))
.sorted(Comparator.comparingInt(SimilarFileDTO::getHammingDistance))
.collect(Collectors.toList());
List<FileEntity> fileEntityList = fileEntityRepository.findByMimeTypeAndUserIds(mimeType, userIds);
int total = allResults.size();
int page = (pageable != null) ? pageable.getPageNumber() : 0;
int size = (pageable != null) ? pageable.getPageSize() : 20;
for (FileEntity file : fileEntityList) {
if (file.getProtectedFilePath() == null) continue;
File existingFile = new File(file.getProtectedFilePath());
File newFile = new File(path);
int fromIndex = Math.min(page * size, total);
int toIndex = Math.min(fromIndex + size, total);
if (existingFile.length() == newFile.length()) {
if (calculateFileHash(file.getProtectedFilePath()).equals(hash)) {
return file;
}
}
}
List<SimilarFileDTO> pageContent = allResults.subList(fromIndex, toIndex);
return null;
}
return new PageImpl<>(pageContent, pageable, total);
private final UserRepository userRepository;
public Page<SimilarFileDTO> findSimilarFiles(
String fileId,
List<String> similarityLevels,
Pageable pageable,
String authToken) {
try {
var imageHashEntity = hashRepository.findById(fileId)
.orElseThrow(() -> new RuntimeException("Hash not found for file: " + fileId));
List<Long> userIds;
List<SimilarImageProjection> candidates = repository.findCandidates(fileId);
Long userId = authTokenRepository.findUserIdByToken(authToken);
User user = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("User not found"));
Company company = user.getCompany();
if (company != null) {
userIds = company.getUsers().stream().map(User::getId).collect(Collectors.toList());
} else {
userIds = List.of(userId);
}
List<String> levels = (similarityLevels != null && !similarityLevels.isEmpty())
? similarityLevels
: List.of("DUPLICATE", "SIMILAR", "DIFFERENT");
List<SimilarFileDTO> allResults = candidates.stream()
.map(c -> createSimilarFileResponse(c,
imageHashEntity.getHash64Hi(),
imageHashEntity.getHash64Lo(), userIds))
.filter(dto -> levels.contains(dto.getSimilarityLevel()))
.filter(dto -> !(dto.getFileStatus().equals(FileStatus.MODERATION.name()) && !dto.getOwner()))
.sorted(Comparator.comparingInt(SimilarFileDTO::getHammingDistance))
.collect(Collectors.toList());
int start = (int) pageable.getOffset();
int end = Math.min((start + pageable.getPageSize()), allResults.size());
List<SimilarFileDTO> pageContent = start < allResults.size() ?
allResults.subList(start, end) : Collections.emptyList();
return new PageImpl<>(pageContent, pageable, allResults.size());
} catch (Exception e) {
log.error("Error finding similar files for fileId: {}", fileId, e);
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
}
private String calculateFileHash(String path) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream is = new FileInputStream(path)) {
byte[] buffer = new byte[8192];
int read;
while ((read = is.read(buffer)) > 0) {
digest.update(buffer, 0, read);
}
}
byte[] hashBytes = digest.digest();
return Base64.getEncoder().encodeToString(hashBytes);
}
private SimilarFileDTO createSimilarFileResponse(SimilarImageProjection similarImageProjection,
Long hash64Hi, Long hash64Lo) {
Long hash64Hi, Long hash64Lo, List<Long> userIds) {
Long imageProjectionHash64Hi = similarImageProjection.getHash64Hi();
Long similarImageProjectionHash64Lo = similarImageProjection.getHash64Lo();
int hamming = PerceptualHashHelper.INSTANCE.hammingDistance(new PHash(hash64Hi, hash64Lo),
@@ -185,17 +227,40 @@ public class FileSimilarityService {
level = "DIFFERENT";
}
boolean owner = userIds.contains(similarImageProjection.getUserId());
return SimilarFileDTO.builder()
.fileId(similarImageProjection.getId())
.ownerId(similarImageProjection.getUserId())
.originalFileName(similarImageProjection.getOriginalFileName())
.originalFileName(similarImageProjection.getOriginalFileName().replace("." +
similarImageProjection.getExtension(), "") + "_nocopy_protected" +
"." + similarImageProjection.getExtension())
.fileSize(similarImageProjection.getFileSize())
.hammingDistance(hamming)
.similarityLevel(level)
.supportId(similarImageProjection.getSupportId())
.uploadDate(similarImageProjection.getUploadDate())
.status(similarImageProjection.getProtectionStatus())
.url(baseUrl + "/api/files/protected/" + similarImageProjection.getId())
.owner(owner)
// .url(baseUrl + "/api/files/protected/" + similarImageProjection.getId())
.url(baseUrl + "/api/files/protected/" + similarImageProjection.getId() + "/thumbnail")
.fileStatus(similarImageProjection.getFileStatus())
.build();
}
public SimilarFileDTO buildDTO(FileEntity fileEntity) {
return SimilarFileDTO.builder()
.fileId(fileEntity.getId())
.ownerId(fileEntity.getUserId())
.originalFileName(fileEntity.getOriginalFileName().replace("." +
fileEntity.getFileExtension(), "") + "_nocopy_protected" +
"." + fileEntity.getFileExtension())
.fileSize(fileEntity.getFileSize())
.uploadDate(fileEntity.getCreatedAt())
.fileStatus(fileEntity.getStatus().name())
.status(fileEntity.getProtectionStatus())
.supportId(Long.valueOf(fileEntity.getSupportId()))
.build();
}
}
@@ -0,0 +1,48 @@
package ru.soune.nocopy.service.auth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.user.UserNotActive;
import ru.soune.nocopy.repository.UserNotActiveRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class CleanUpNotActiveUser {
@Autowired
private UserNotActiveRepository userNotActiveRepository;
@Autowired
private UserRepository userRepository;
@Transactional
@Scheduled(fixedDelay = 600000)
public void cleanupNotActiveUsers() {
LocalDateTime now = LocalDateTime.now();
List<UserNotActive> expiredTokens = userNotActiveRepository.findByBlockedUntilBefore(now);
for (UserNotActive userNotActive : expiredTokens) {
User user = userNotActive.getUser();
user.setActive(true);
user.setFailedLoginAttempts(0);
userRepository.save(user);
}
if (!expiredTokens.isEmpty()) {
userNotActiveRepository.deleteAll(expiredTokens);
}
}
}
@@ -0,0 +1,129 @@
package ru.soune.nocopy.service.complaint;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.complaint.ComplaintRequest;
import ru.soune.nocopy.dto.complaint.ComplaintResponse;
import ru.soune.nocopy.entity.complaint.ComplaintEntity;
import ru.soune.nocopy.entity.complaint.ComplaintStatus;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.violation.Violation;
import ru.soune.nocopy.exception.ComplaintNotFoundException;
import ru.soune.nocopy.exception.DuplicateComplaintException;
import ru.soune.nocopy.exception.ViolationNotFoundException;
import ru.soune.nocopy.repository.ComplaintEntityRepository;
import ru.soune.nocopy.repository.ViolationRepository;
import ru.soune.nocopy.service.violation.ViolationService;
import ru.soune.nocopy.service.violation.ViolationStatus;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class ComplaintEntityService {
private final ComplaintEntityRepository complaintRepository;
private final ViolationRepository violationRepository;
private final ViolationService violationService;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public ComplaintResponse createComplaint(ComplaintRequest request) {
Violation violation = violationRepository.findById(request.getViolationId())
.orElseThrow(() -> new ViolationNotFoundException("Violation not found: " + request.getViolationId()));
if (complaintRepository.existsByViolationId(request.getViolationId())) {
throw new DuplicateComplaintException("Complaint already exists for violation: " + request.getViolationId());
}
ComplaintEntity complaint = ComplaintEntity.builder()
.status(ComplaintStatus.CREATED)
.complaintText(request.getComplaintText())
.email(request.getEmail())
.violation(violation)
.not_moderated_file(violation.getFileEntity().getStatus().equals(FileStatus.MODERATION))
.build();
violationService.changeStatus(ViolationStatus.COMPLAINT_IN_WORK, request.getViolationId());
return mapToResponse(complaintRepository.save(complaint));
}
@Transactional(readOnly = true)
public ComplaintResponse getComplaintById(Long id) {
return complaintRepository.findById(id)
.map(this::mapToResponse)
.orElseThrow(() -> new ComplaintNotFoundException("Complaint not found: " + id));
}
@Transactional(readOnly = true)
public Page<ComplaintResponse> getAllComplaints(Pageable pageable) {
return complaintRepository.findAll(pageable).map(this::mapToResponse);
}
@Transactional(readOnly = true)
public List<ComplaintResponse> getComplaintsByViolationId(Long violationId) {
if (!violationRepository.existsById(violationId)) {
throw new ViolationNotFoundException("Violation not found: " + violationId);
}
return complaintRepository.findByViolationId(violationId)
.stream()
.map(this::mapToResponse)
.collect(Collectors.toList());
}
public ComplaintResponse updateComplaintStatus(Long id, ComplaintStatus status) {
ComplaintEntity complaint = complaintRepository.findById(id)
.orElseThrow(() -> new ComplaintNotFoundException("Complaint not found: " + id));
complaint.setStatus(status);
return mapToResponse(complaintRepository.save(complaint));
}
public ComplaintResponse updateComplaint(Long id, ComplaintRequest request) {
ComplaintEntity complaint = complaintRepository.findById(id)
.orElseThrow(() -> new ComplaintNotFoundException("Complaint not found: " + id));
if (request.getViolationId() != null) {
Violation violation = violationRepository.findById(request.getViolationId())
.orElseThrow(() -> new ViolationNotFoundException("Violation not found: " + request.getViolationId()));
complaint.setViolation(violation);
}
if (request.getComplaintText() != null) complaint.setComplaintText(request.getComplaintText());
if (request.getEmail() != null) complaint.setEmail(request.getEmail());
if (request.getStatus() != null) complaint.setStatus(ComplaintStatus.valueOf(request.getStatus()));
return mapToResponse(complaintRepository.save(complaint));
}
public ComplaintResponse deleteComplaint(Long id) {
ComplaintEntity complaint = complaintRepository.findById(id)
.orElseThrow(() -> new ComplaintNotFoundException("Complaint not found: " + id));
complaint.setStatus(ComplaintStatus.CANCELLED);
return mapToResponse(complaintRepository.save(complaint));
}
private ComplaintResponse mapToResponse(ComplaintEntity entity) {
return ComplaintResponse.builder()
.id(entity.getId())
.status(entity.getStatus() != null ? entity.getStatus().name() : null)
.complaintText(entity.getComplaintText())
.email(entity.getEmail())
.violationId(entity.getViolation() != null ? entity.getViolation().getId() : null)
.createdAt(entity.getCreatedAt() != null ? entity.getCreatedAt().format(DATE_FORMATTER) : null)
.updatedAt(entity.getUpdatedAt() != null ? entity.getUpdatedAt().format(DATE_FORMATTER) : null)
.notModerated(entity.getNot_moderated_file() != null ? entity.getNot_moderated_file() : null)
.build();
}
}
@@ -0,0 +1,29 @@
package ru.soune.nocopy.service.cost;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.service.tariff.TariffConstants;
@Slf4j
@Component
@RequiredArgsConstructor
public class CostService {
public int protectFileCost(String fileType) {
int cost = 0;
switch (fileType) {
case "video":
return TariffConstants.VIDEO_TOKEN_VALUE;
case "image":
return TariffConstants.IMAGE_TOKEN_VALUE;
case "audio":
return TariffConstants.AUDIO_TOKEN_VALUE;
case "document":
return TariffConstants.PDF_TOKEN_VALUE;
default:
return cost;
}
}
}
@@ -22,7 +22,7 @@ public class DaDataService {
private String apiKey ;
@Value("${dadata.url}")
private String url ;
private String dadataUrl;
private final OkHttpClient httpClient;
@@ -44,7 +44,7 @@ public class DaDataService {
String jsonBody = String.format("{\"query\": \"%s\"}", inn);
Request request = new Request.Builder()
.url("https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party")
.url(dadataUrl)
.post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), jsonBody))
.header("Accept", "application/json")
.header("Authorization", "Token " + apiKey)
@@ -52,6 +52,7 @@ public class DaDataService {
try (Response response = httpClient.newCall(request).execute()) {
ResponseBody body = response.body();
if (body == null) {
throw new IOException("Empty response body");
}
@@ -38,7 +38,6 @@ public class FileCleanupService {
public void cleanupExpiredFiles() {
log.info("Starting cleanup of expired temporary files");
cleanupOldPeriods();
cleanupTempFilesNightly();
Path tempDir = Paths.get(basePath, "temp");
@@ -91,19 +90,6 @@ public class FileCleanupService {
}
}
private void cleanupOldPeriods() {
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
List<ProtectedFileCheck> checks = protectedFileCheckRepository.findByPeriodStartDateBefore(thirtyDaysAgo);
for (ProtectedFileCheck check : checks) {
check.cleanupOldData();
}
protectedFileCheckRepository.saveAll(checks);
log.info("Cleaned up {} old protection usage records", checks.size());
}
private void cleanupTempFilesNightly() {
log.info("Starting nightly cleanup of temporary files");
@@ -2,19 +2,20 @@ package ru.soune.nocopy.service.file;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.FileResponse;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.entity.file.ProtectionStatus;
import ru.soune.nocopy.entity.monitoring.FileMonitoringEntity;
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.repository.FileEntityRepository;
import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.repository.FileMonitoringRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
@@ -27,7 +28,7 @@ import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@@ -37,70 +38,14 @@ public class FileEntityService {
private final FileEntityRepository fileEntityRepository;
private final ImageHashService imageHashService;
private final FileSimilarityService fileSimilarityService;
private final UserRepository userRepository;
private final ImageResizeService imageResizeService;
private final CloudStorageService cloudStorageService;
@Transactional(noRollbackFor = DuplicateImageException.class)
public FileEntity createFromUploadSession(FileUploadSession session, String checksum) {
log.info("Creating FileEntity for upload session: {}", session.getUploadId());
try {
Path filePath = Paths.get(session.getFilePath());
if (!Files.exists(filePath)) {
throw new IOException("File not found on disk: " + filePath);
}
Map<String, Long> imageHash = Map.of();
if (!session.getFileType().startsWith("video")) {
imageHash = imageHashService.calculateHash(filePath);
List<SimilarImageProjection> duplicatedByHash = fileSimilarityService.findDuplicatedByHash(
imageHash.get("hi"), imageHash.get("low"));
if (!duplicatedByHash.isEmpty()) {
SimilarImageProjection similarImageProjection = duplicatedByHash.get(0);
throw new DuplicateImageException("Duplicate", similarImageProjection.getId(),
similarImageProjection.getUserId());
}
}
long fileSize = Files.size(filePath);
String originalName = session.getFileName();
String storedName = filePath.getFileName().toString();
FileEntity fileEntity = FileEntity.builder()
.userId(session.getUserId())
.originalFileName(originalName)
.storedFileName(storedName)
.filePath(session.getFilePath())
.fileSize(fileSize)
.mimeType(session.getFileType())
.fileExtension(session.getExtension())
.checksum(checksum)
.uploadSessionId(session.getUploadId())
.status(FileStatus.ACTIVE)
.build();
FileEntity saved = fileEntityRepository.save(fileEntity);
if (!imageHash.isEmpty()) {
imageHashService.create(saved, imageHash);
}
return saved;
} catch (IOException e) {
log.error("Failed to create FileEntity for session {}: {}",
session.getUploadId(), e.getMessage(), e);
throw new RuntimeException("Failed to create file entity: " + e.getMessage(), e);
}
}
@Value("${server.baseurl}")
private String baseUrl;
@Transactional(readOnly = true)
public FileEntityResponse getById(String fileId, int version) {
@@ -119,6 +64,34 @@ public class FileEntityService {
return convertToResponse(fileEntity, version);
}
@Transactional(readOnly = true)
public FileEntityResponse getByFilePath(String filePath, int version) {
FileEntity fileEntity = fileEntityRepository.findByFilePath(filePath)
.orElseThrow(() -> new FileEntityNotFoundException("Path: " + filePath));
return convertToResponse(fileEntity, version);
}
@Transactional(readOnly = true)
public List<FileEntity> getAllUserFiles(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
List<FileEntity> allFiles = new ArrayList<>();
if (user.getCompany() != null) {
for (Long uId : userRepository.findByCompanyId(user.getCompany().getId()).stream()
.map(User::getId)
.toList()) {
allFiles.addAll(fileEntityRepository.findByUserIdAndStatusIn(uId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED)));
}
} else {
allFiles = fileEntityRepository.findByUserIdAndStatusIn(userId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED));
}
return allFiles;
}
@Transactional(readOnly = true)
public FileResponse getUserFiles(Long userId, int page, int pageSize, int version) {
User user = userRepository.findById(userId).orElseThrow();
@@ -128,12 +101,13 @@ public class FileEntityService {
for (Long uId : userRepository.findByCompanyId(user.getCompany().getId()).stream()
.map(User::getId)
.toList()) {
allFiles.addAll(fileEntityRepository.findByUserIdAndStatus(uId, FileStatus.ACTIVE));
allFiles.addAll(fileEntityRepository.findByUserIdAndStatusIn(uId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED)));
}
} else {
allFiles = fileEntityRepository.findByUserIdAndStatus(
userId, FileStatus.ACTIVE);
allFiles = fileEntityRepository.findByUserIdAndStatusIn(userId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED));
}
int start = (page - 1) * pageSize;
@@ -175,13 +149,13 @@ public class FileEntityService {
fileEntity.setImageHash(null);
}
fileEntity.setStatus(FileStatus.DELETED);
fileEntity.setStatus(FileStatus.REMOVED);
fileEntity.setUpdatedAt(LocalDateTime.now());
fileEntity.setProtectionStatus(ProtectionStatus.NOT_PROTECTED);
// fileEntity.setProtectionStatus(ProtectionStatus.NOT_PROTECTED);
Path path = Paths.get(fileEntity.getProtectedFilePath());
Files.deleteIfExists(path);
fileEntity.setProtectedFilePath("");
// Path path = Paths.get(fileEntity.getProtectedFilePath());
// Files.deleteIfExists(path);
// fileEntity.setProtectedFilePath("");
fileEntityRepository.save(fileEntity);
}
@@ -196,7 +170,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);
@@ -204,6 +178,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 FileEntity writeProtectedFile(String id, byte[] data, String fileExt) throws IOException {
FileEntity fileEntity = fileEntityRepository.findById(id)
@@ -218,6 +200,10 @@ public class FileEntityService {
Files.write(protectedFilePath, data);
if (imageResizeService.isImage(extension)) {
imageResizeService.generateSizes(fileEntity, data);
}
fileEntity.setProtectedFilePath(protectedFilePath.toString());
fileEntity.setProtectedAt(LocalDateTime.now());
fileEntity.setUpdatedAt(LocalDateTime.now());
@@ -308,14 +294,29 @@ public class FileEntityService {
}
}
private final FileMonitoringRepository fileMonitoringRepository;
private FileEntityResponse convertToResponse(FileEntity fileEntity, int version) {
boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath());
User user = userRepository.findById(fileEntity.getUserId()).get();
String fileName = fileEntity.getOriginalFileName();
String name = fileName;
Optional<FileMonitoringEntity> fileMonitoringRepositoryByFileId =
fileMonitoringRepository.findByFileId(fileEntity.getId());
String monitoring = fileMonitoringRepositoryByFileId.map(fileMonitoringEntity ->
fileMonitoringEntity.getMonitoringType().name())
.orElseGet(MonitoringType.NONE::name);
int lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex > 0) {
name = fileName.substring(0, lastDotIndex);
}
return FileEntityResponse.builder()
.id(fileEntity.getId())
.userId(fileEntity.getUserId())
.originalFileName(fileEntity.getOriginalFileName())
.originalFileName(name + "." + fileEntity.getFileExtension())
.storedFileName(fileEntity.getStoredFileName())
.filePath(fileEntity.getProtectedFilePath())
.fileSize(fileEntity.getFileSize())
@@ -331,16 +332,16 @@ public class FileEntityService {
.existsOnDisk(existsOnDisk)
.supportId(fileEntity.getSupportId())
.protectStatus(fileEntity.getProtectionStatus().toString())
.fileName(fileEntity.getOriginalFileName().replace("." +
fileEntity.getFileExtension(), "") + "_nocopy_protected" +
"." + fileEntity.getFileExtension())
.fileName(name + "_nocopy_protected" + "." + fileEntity.getFileExtension())
.ownerName(user.getFullName())
.ownerEmail(user.getEmail())
.ownerCompany(user.getCompanyName())
.monitoring(monitoring)
//TODO fix after add logic for check for protect file
.checksCount(0)
.fileUploadDate(fileEntity.getCreatedAt())
.protectedFilePath(fileEntity.getProtectedFilePath())
.thumbnailFileUrl(baseUrl + "/api/files/protected/" + fileEntity.getId() + "/thumbnail")
.build();
}
}
@@ -1,53 +0,0 @@
package ru.soune.nocopy.service.file;
import com.vrt.NoCopyFileService;
import com.vrt.fileprotection.FileProtector;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.ProtectionStatus;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.util.FileUtil;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class FileProcessingOrchestrator {
private final NoCopyFileService noCopyFileService;
private final FileEntityRepository fileRepository;
private final FileUtil fileUtil;
public void initializeProcessingQueue() {
List<FileEntity> filesToProtect = fileRepository.findByProtectionStatus(ProtectionStatus.NOT_PROTECTED);
for (FileEntity fileEntity : filesToProtect) {
try {
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
noCopyFileService.addFile(fileInfo);
log.info("Add to query: {}", fileEntity.getOriginalFileName());
} catch (Exception e) {
log.error("Fail add to query: {}", fileEntity.getId(), e);
}
}
}
@Scheduled(fixedDelay = 120000)
public void checkNewFilesForProtection() {
List<FileEntity> newFiles = fileRepository.findAllActiveFilesAndNotProtected();
for (FileEntity fileEntity : newFiles) {
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
noCopyFileService.addFile(fileInfo);
}
}
}
@@ -1,11 +1,10 @@
package ru.soune.nocopy.service.file;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.configuration.file.FileStorageConfig;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -16,11 +15,11 @@ import java.nio.file.Paths;
@Slf4j
public class FileStorageService {
@Autowired
private FileStorageConfig storageConfig;
@Value("${file.storage.base-path}")
private String basePath;
public Resource loadFileAsResource(String filePath) throws IOException {
Path path = Paths.get(storageConfig.getBasePath()).resolve(filePath).normalize();
Path path = Paths.get(basePath).resolve(filePath).normalize();
Resource resource = new UrlResource(path.toUri());
if (!resource.exists()) {
@@ -10,7 +10,7 @@ import java.io.IOException;
public interface FileUploadService {
FileUploadSession initUpload(Long userId, String fileName,
String fileType, String extension, long fileSize);
String fileType, String extension, String convertTo, long fileSize);
void handleExpiredSession(FileUploadSession session);
@@ -0,0 +1,92 @@
package ru.soune.nocopy.service.file;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.file.FileEntity;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@Service
@Slf4j
public class ImageResizeService {
private static final int THUMBNAIL_SIZE = 150;
private static final int MEDIUM_SIZE = 600;
@Value("${file.storage.base-path}")
private String storagePath;
public boolean isImage(String extension) {
return extension != null && List.of("jpg", "jpeg", "png", "webp")
.contains(extension.toLowerCase());
}
@Transactional
public void generateSizes(FileEntity file, byte[] data) throws IOException {
if (!isImage(file.getFileExtension())) {
return;
}
try (ByteArrayInputStream bis = new ByteArrayInputStream(data)) {
BufferedImage original = ImageIO.read(bis);
if (original == null) {
log.warn("Cannot read image: {}", file.getId());
return;
}
String baseName = file.getId().replace(".", "_");
if (original.getWidth() > THUMBNAIL_SIZE) {
String thumbPath = resizeAndSave(original, baseName + "_thumb", THUMBNAIL_SIZE);
file.setThumbnailPath(thumbPath);
}
if (original.getWidth() > MEDIUM_SIZE) {
String mediumPath = resizeAndSave(original, baseName + "_medium", MEDIUM_SIZE);
file.setMediumPath(mediumPath);
}
}
}
private String resizeAndSave(BufferedImage original, String fileName, int targetWidth) throws IOException {
int targetHeight = (int) ((double) original.getHeight() / original.getWidth() * targetWidth);
BufferedImage resized = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = resized.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(original, 0, 0, targetWidth, targetHeight, null);
g.dispose();
Path dirPath = Paths.get(storagePath, "resized");
Files.createDirectories(dirPath);
Path filePath = dirPath.resolve(fileName + ".jpg");
ImageIO.write(resized, "jpg", filePath.toFile());
return filePath.toString();
}
public String getPath(FileEntity file, String size) {
if ("thumbnail".equals(size) && file.getThumbnailPath() != null) {
return file.getThumbnailPath();
}
if ("medium".equals(size) && file.getMediumPath() != null) {
return file.getMediumPath();
}
return file.getProtectedFilePath();
}
}
@@ -26,7 +26,9 @@ public class AudioLocalSearchImpl implements AudioLocalSearch {
public @Nullable FileProtector.FileInfo findBySignature(@NotNull String signature) {
FileEntity fileEntity = fileEntityService.findBySignature(signature);
return fileUtil.createFileInfo(fileEntity);
if (fileEntity == null) return null;
return fileUtil.createFileInfo(fileEntity, null);
}
@Override
@@ -23,7 +23,7 @@ public class DocumentLocalSearchImpl implements DocumentLocalSearch {
@Override
public @Nullable FileProtector.FileInfo findByIds(@NotNull String ownerId, @NotNull String fileId) {
FileEntity fileEntity = fileEntityRepository.findByIdAndUserId(fileId, Long.valueOf(ownerId));
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity, null);
log.info("fileInfo: {}", fileInfo);
return fileInfo;
@@ -2,27 +2,29 @@ package ru.soune.nocopy.service.file.impl;
import com.vrt.NoCopyFileService;
import com.vrt.fileprotection.FileProtector;
import com.vrt.fileprotection.NoCopyCheckResult;
import com.vrt.fileprotection.audio.AudioCheckResult;
import com.vrt.fileprotection.documents.DocumentCheckResult;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import ru.soune.nocopy.dto.file.UploadProgressResponse;
import ru.soune.nocopy.entity.file.*;
import ru.soune.nocopy.exception.ChunkSizeExceededException;
import ru.soune.nocopy.exception.DuplicateImageException;
import ru.soune.nocopy.exception.FileUploadException;
import ru.soune.nocopy.exception.UploadSessionNotFoundException;
import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
import ru.soune.nocopy.service.cost.CostService;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService;
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
@@ -59,9 +61,6 @@ public class FileUploadServiceImpl implements FileUploadService {
@Value("${file.storage.max-retry-attempts:3}")
private int maxRetryAttempts;
@Value("${file.storage.chunk-timeout-ms:300000}") // 5 минут
private long chunkTimeoutMs;
@Value("${file.storage.session-expiry-hours:24}")
private int sessionExpiryHours;
@@ -79,6 +78,9 @@ public class FileUploadServiceImpl implements FileUploadService {
@Autowired
private FileEntityService fileEntityService;
private final CostService costService;
@Lazy
private final NoCopyFileService noCopyFileService;
private final FileUtil fileUtil;
@@ -112,7 +114,7 @@ public class FileUploadServiceImpl implements FileUploadService {
@Override
@Transactional
public FileUploadSession initUpload(Long userId, String fileName,
String fileType, String extension, long fileSize) {
String fileType, String extension, String convertTo, long fileSize) {
log.info("Initializing upload for user {}: {} ({} bytes, type: {})",
userId, fileName, fileSize, fileType);
@@ -143,6 +145,7 @@ public class FileUploadServiceImpl implements FileUploadService {
.status(UploadStatus.INITIATED)
.expiresAt(LocalDateTime.now().plusHours(sessionExpiryHours))
.retryCount(0)
.convertTo(convertTo)
.build();
FileUploadSession savedSession = sessionRepository.save(session);
@@ -252,6 +255,12 @@ public class FileUploadServiceImpl implements FileUploadService {
cleanupSessionFiles(session);
if (status != FileStatus.TEMP) {
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity, session.getConvertTo());
noCopyFileService.addFile(fileInfo);
}
log.info("File processing completed for session: {}", session.getUploadId());
} catch (Exception e) {
log.error("Failed to complete file processing for session {}: {}",
@@ -313,21 +322,58 @@ public class FileUploadServiceImpl implements FileUploadService {
chunkNumber, session.getChunksUploaded(), session.getTotalChunks());
boolean isLastChunk = session.getChunksUploaded().equals(session.getTotalChunks());
//TODO CHECK
if (isLastChunk) {
log.info("All chunks uploaded for session {}. Starting assembly...",
session.getUploadId());
String finalFilePath = assembleFileSynchronously(session);
if (session.getFileType().startsWith("image") && findSimilar == 0) {
checkForDuplicatesSynchronously(finalFilePath);
if (findSimilar == 0) {
if (session.getFileType().startsWith("image")) {
checkForDuplicatesSynchronously(finalFilePath);
} else if (session.getFileType().startsWith("audio") || session.getFileType().startsWith("document")) {
fileSimilarityService.hasDuplicatesByHash(finalFilePath ,session.getFileType(),
session.getUserId());
File file = new File(finalFilePath);
if (session.getFileType().startsWith("audio") && !isWavFile(file)) {
session.setStatus(UploadStatus.FAILED);
session.setLastError("wav file not have RIFF header");
sessionRepository.save(session);
throw new FileFormatException("Failed to upload chunk: .wav file not have RIFF header or " +
"another problems with .wav file");
}
FileProtector.Type type = "document".equals(session.getFileType()) ?
FileProtector.Type.DOC : FileProtector.Type.valueOf(session.getFileType().toUpperCase());
NoCopyCheckResult checkResult = noCopyFileService.checkFile(file, type);
switch (checkResult) {
case DocumentCheckResult.Success success -> {
FileProtector.FileInfo info = success.getInfo();
throw new DuplicateImageException("Duplicate", info.getId(),
Long.valueOf(info.getOwnerId()));
}
case AudioCheckResult.Success success -> {
FileProtector.FileInfo info = success.getInfo();
throw new DuplicateImageException("Duplicate", info.getId(),
Long.valueOf(info.getOwnerId()));
}
case DocumentCheckResult.Failed failed ->
log.warn("Document check failed: {}", failed.getMessage());
case AudioCheckResult.Failed failed ->
log.warn("Audio check failed: {}", failed.getMessage());
default -> log.warn("Unexpected result type: {}", checkResult.getClass().getSimpleName());
}
}
}
session.setStatus(UploadStatus.COMPLETED);
session.setFilePath(finalFilePath);
FileStatus status = findSimilar == 0 ? FileStatus.ACTIVE: FileStatus.TEMP;
FileStatus status = findSimilar == 0 ? FileStatus.MODERATION: FileStatus.TEMP;
if (status == FileStatus.TEMP) {
fileEntityService.clearTempFiles(session.getUserId());
@@ -351,7 +397,7 @@ public class FileUploadServiceImpl implements FileUploadService {
if (status != FileStatus.TEMP) {
String fileType = session.getFileType();
tariffInfoService.writeOffTokens(session.getUserId(), protectCost(fileType));
tariffInfoService.writeOffTokens(session.getUserId(), costService.protectFileCost(fileType));
}
} else {
sessionRepository.save(session);
@@ -388,21 +434,87 @@ public class FileUploadServiceImpl implements FileUploadService {
}
}
private int protectCost(String fileType) {
int cost = 0;
switch (fileType) {
case "video":
return TariffConstants.VIDEO_TOKEN_VALUE;
case "image":
return TariffConstants.IMAGE_TOKEN_VALUE;
case "audio":
return TariffConstants.AUDIO_TOKEN_VALUE;
case "document":
return TariffConstants.PDF_TOKEN_VALUE;
//TODO убрать когда научимся защищать другие типы
public boolean isWavFile(File file) {
if (file == null || !file.exists() || file.length() < 12) {
return false;
}
return cost;
try (FileInputStream fis = new FileInputStream(file)) {
byte[] header = new byte[4];
int bytesRead = fis.read(header);
if (bytesRead < 4) {
return false;
}
return header[0] == 82 && header[1] == 73 &&
header[2] == 70 && header[3] == 70;
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
public static boolean isValidWav(File file) {
if (file == null || !file.exists() || file.length() < 44) {
return false;
}
try (FileInputStream fis = new FileInputStream(file)) {
byte[] header = new byte[44];
if (fis.read(header) < 44) {
return false;
}
if (header[0] != 'R' || header[1] != 'I' ||
header[2] != 'F' || header[3] != 'F') {
return false;
}
if (header[8] != 'W' || header[9] != 'A' ||
header[10] != 'V' || header[11] != 'E') {
return false;
}
if (header[12] != 'f' || header[13] != 'm' ||
header[14] != 't' || header[15] != ' ') {
return false;
}
if (header[36] != 'd' || header[37] != 'a' ||
header[38] != 't' || header[39] != 'a') {
return false;
}
int audioFormat = (header[20] & 0xFF) | (header[21] & 0xFF) << 8;
if (audioFormat != 1 && audioFormat != 3) {
return false;
}
int numChannels = (header[22] & 0xFF) | (header[23] & 0xFF) << 8;
if (numChannels < 1 || numChannels > 8) {
return false;
}
int sampleRate = (header[24] & 0xFF) | (header[25] & 0xFF) << 8 |
(header[26] & 0xFF) << 16 | (header[27] & 0xFF) << 24;
if (sampleRate < 8000 || sampleRate > 192000) {
return false;
}
int bitsPerSample = (header[34] & 0xFF) | (header[35] & 0xFF) << 8;
if (bitsPerSample < 8 || bitsPerSample > 32) {
return false;
}
return true;
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
private String assembleFileSynchronously(FileUploadSession session) throws IOException {
@@ -510,76 +622,6 @@ public class FileUploadServiceImpl implements FileUploadService {
}
}
private void assembleFile(FileUploadSession session) throws IOException {
log.info("Starting file assembly for session: {} ({})",
session.getUploadId(), session.getFileName());
Path finalFilePath = null;
String checksum;
try {
finalFilePath = prepareFinalFile(session);
log.info("Final file path: {}", finalFilePath);
validateAllChunksExist(session);
mergeChunksToFile(session, finalFilePath);
validateFinalFile(session, finalFilePath);
checksum = calculateChecksum(finalFilePath);
log.debug("File checksum calculated: {}", checksum);
session.setFilePath(finalFilePath.toString());
session.setChecksum(checksum);
session.setStatus(UploadStatus.COMPLETED);
session.setCompletedAt(LocalDateTime.now());
sessionRepository.save(session);
log.info("Upload session updated to COMPLETED: {}", session.getUploadId());
try {
FileEntity fileEntity = fileEntityService.createFromUploadSession(session, checksum);
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
noCopyFileService.addFile(fileInfo);
log.info("FileEntity successfully created for session: {}",
session.getUploadId());
} catch (DuplicateImageException e) {
throw new DuplicateImageException("Duplicate", e.duplicateFileId(), e.userId());
} catch (Exception e) {
log.error("Failed to create FileEntity for session {} (file uploaded, but metadata not saved): {}",
session.getUploadId(), e.getMessage());
}
cleanupSessionFiles(session);
log.info("File assembly completed successfully: {} -> {} ({} bytes)",
session.getFileName(), finalFilePath, session.getFileSize());
} catch (Exception e) {
log.error("File assembly failed for session {}: {}",
session.getUploadId(), e.getMessage(), e);
if (finalFilePath != null) {
try {
Files.deleteIfExists(finalFilePath);
log.debug("Cleaned up partial file: {}", finalFilePath);
} catch (IOException ioException) {
log.warn("Failed to cleanup partial file: {}", finalFilePath, ioException);
}
}
session.setStatus(UploadStatus.FAILED);
session.setLastError(e.getMessage());
sessionRepository.save(session);
throw new IOException("File assembly failed: " + e.getMessage(), e);
}
}
private Path prepareFinalFile(FileUploadSession session) throws IOException {
Path userUploadsDir = storageRoot.resolve("uploads")
.resolve(String.valueOf(session.getUserId()))
@@ -44,11 +44,13 @@ public class ImageLocalSearchImpl implements ImageLocalSearch {
@Override
public @Nullable FileProtector.FileInfo findByPHash(@NotNull PHash pHash) {
ImageHashEntity imageHashEntity =
imageHashRepository.findByHash64HiAndHash64Lo(pHash.getFirstPart(),
pHash.getSecondPart());
imageHashRepository.findByHash64HiAndHash64Lo(pHash.getFirstPart(), pHash.getSecondPart());
if (imageHashEntity == null) return null;
FileEntity file = fileEntityRepository.findByFileId(imageHashEntity.getFileId());
return new FileProtector.FileInfo(FileProtector.Type.IMAGE, imageHashEntity.getFileId(),
String.valueOf(file.getUserId()));
String.valueOf(file.getUserId()), null);
}
}
@@ -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());;
}
}
@@ -9,7 +9,11 @@ import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService;
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
import java.io.File;
@@ -27,6 +31,10 @@ public class ProtectionFileProviderImpl implements FileProtector.FileProvider {
private final FileEntityService fileEntityService;
private final FileUploadSessionRepository fileUploadSessionRepository;
private final FileEntityRepository fileEntityRepository;
private final CloudStorageService cloudStorageService;
private final FileEntityRepository fileEntityRepository;
@@ -0,0 +1,238 @@
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.io.IOException;
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<FileStatus> 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())
.comment(request.getComment())
.build();
moderationLogRepository.save(log);
file.setStatus(request.getNewStatus());
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(request.getComment());
appeal.setResolvedAt(LocalDateTime.now());
fileAppealRepository.save(appeal);
});
} else if (request.getNewStatus() == FileStatus.REMOVED) {
try {
fileEntityService.softDeleteFileWithHash(file);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
/**
* Подача апелляции пользователем
*/
@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())
.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);
}
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) {
return file.getStatus() == FileStatus.BLOCKED;
}
/**
* Проверка наличия активной апелляции
*/
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<FileEntity> 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<AppealResponse> 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();
}
}
@@ -19,6 +19,9 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -38,22 +41,78 @@ public class EmailService {
@Value("${app.email.verification.user}")
private String user;
public void sendVerificationEmail(User user, String code) throws MessagingException, IOException {
@Value("${app.email.verification.drop-password-subject}")
private String dropPasswordSubject;
@Value("${server.baseurl}")
private String baseUrl;
public void sendEmail(String from, String sendTo, String subject, String body) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(standartAdressFrom);
helper.setTo(user.getEmail());
helper.setSubject(standartSubject);
String htmlContent = loadAndProcessTemplate(user.getFullName(), code);
helper.setText(htmlContent, true);
helper.setFrom(from);
helper.setTo(sendTo);
helper.setSubject(subject);
helper.setText(body, true);
mailSender.send(message);
}
public void sendVerificationEmail(User user, String code) throws MessagingException, IOException {
String htmlContent = loadAndProcessTemplate(Map.of("username", user.getFullName(), "code", code),
"templates/email-verification.html");
sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent);
}
public void sendTokensNotFoundEmail(User user, Integer currentTokens, Integer missingTokens)
throws MessagingException, IOException {
String htmlContent = loadAndProcessTemplate(Map.of("username", user.getFullName(),
"current_tokens", String.valueOf(currentTokens), "missing_tokens",
String.valueOf(missingTokens), "link_to_token_board", baseUrl + "/pages/payment"),
"templates/token-not-found.html");
sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent);
}
public void sendAutoRenewalFailedEmail(User user, String tariffName, Double price)
throws MessagingException, IOException {
Map<String, String> templateData = new HashMap<>();
templateData.put("username", user.getFullName());
templateData.put("tariff_name", tariffName);
templateData.put("price", String.format("%.2f", price));
templateData.put("support_email", "support@no-copy.ru");
String htmlContent = loadAndProcessTemplate(templateData, "templates/failed-template.html");
sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent);
}
public void sendAutoRenewalSuccessEmail(User user, String tariffName, Double price, LocalDateTime nextPaymentDate)
throws MessagingException, IOException {
Map<String, String> templateData = new HashMap<>();
templateData.put("username", user.getFullName());
templateData.put("tariff_name", tariffName);
templateData.put("price", String.format("%.2f", price));
templateData.put("renewal_date", LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")));
templateData.put("next_payment_date", nextPaymentDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
String htmlContent = loadAndProcessTemplate(templateData, "templates/auto-renewal-success.html");
sendEmail("noreply@no-copy.ru", user.getEmail(), standartAdressFrom, htmlContent);
}
@Transactional
public EmailVerificationToken createEmailVerificationToken(AuthToken authToken) {
EmailVerificationToken existingToken = emailVerificationTokenRepository
.findByUserId(authToken.getUser().getId());
if (existingToken != null) {
emailVerificationTokenRepository.deleteById(existingToken.getId());
}
EmailVerificationToken emailToken = new EmailVerificationToken();
emailToken.setUser(authToken.getUser());
emailToken.setToken(generateVerificationCode());
@@ -69,8 +128,8 @@ public class EmailService {
return String.valueOf(code);
}
private String loadAndProcessTemplate(String username, String code) throws IOException {
ClassPathResource resource = new ClassPathResource("templates/email-verification.html");
private String loadAndProcessTemplate(String username, String code, String templatePath) throws IOException {
ClassPathResource resource = new ClassPathResource(templatePath);
String template = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String safeUsername = escapeHtml(username != null ? username : user);
String safeCode = escapeHtml(code);
@@ -80,6 +139,18 @@ public class EmailService {
.replace("{{code}}", safeCode);
}
private String loadAndProcessTemplate(Map<String, String> fieldsForTemplate, String templatePath) throws IOException {
ClassPathResource resource = new ClassPathResource(templatePath);
String template = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
for (Map.Entry<String, String> entry : fieldsForTemplate.entrySet()) {
template = template.replace("{{" + entry.getKey() + "}}", escapeHtml(entry.getValue()));
}
return template;
}
private String escapeHtml(String text) {
if (text == null) return "";
return text
@@ -89,4 +160,29 @@ public class EmailService {
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
public void sendResetPasswordEmail(User user, String code) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(standartAdressFrom);
helper.setTo(user.getEmail());
helper.setSubject(dropPasswordSubject);
String htmlContent = loadAndProcessTemplate(user.getFullName(), code,
"templates/reset-password.html");
//TODO frontPort убрать после настройки редиректа nginx
String resetLink = escapeHtml(baseUrl + "/reset-password?email=" + user.getEmail());
htmlContent = htmlContent.replace("{{resetLink}}", resetLink);
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("Email sent to: {}", user.getEmail());
} catch (Exception e) {
log.error("Failed to send email: {}", e.getMessage());
}
}
}
@@ -0,0 +1,117 @@
package ru.soune.nocopy.service.monitoring;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.monitoring.MonitoringStatusResponse;
import ru.soune.nocopy.dto.tarriff.TariffDTO;
import ru.soune.nocopy.entity.file.*;
import ru.soune.nocopy.entity.monitoring.FileMonitoringEntity;
import ru.soune.nocopy.entity.monitoring.MonitoringType;
import ru.soune.nocopy.entity.tarif.TariffInfo;
import ru.soune.nocopy.entity.tarif.TariffType;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.UserNotHavePermission;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.FileMonitoringRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.tariff.TariffService;
import java.io.FileNotFoundException;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
@Slf4j
@Service
@RequiredArgsConstructor
public class FileMonitoringService {
private final FileMonitoringRepository monitoringRepository;
private final FileEntityRepository fileRepository;
private final TariffService tariffService;
private final UserRepository userRepository;
@Transactional
public FileMonitoringEntity setMonitoring(Long userId, String fileId, MonitoringType type) throws FileNotFoundException {
FileEntity file = fileRepository.findById(fileId)
.orElseThrow(() -> new FileNotFoundException(fileId));
if (!file.getUserId().equals(userId)) {
throw new UserNotHavePermission("User not Have permission for file");
}
FileMonitoringEntity monitoring = monitoringRepository.findByFileId(fileId)
.orElse(FileMonitoringEntity.builder()
.file(file)
.userId(userId)
.build());
monitoring.setMonitoringType(type);
monitoring.setActive(type != MonitoringType.NONE);
monitoring.setNextRun(calculateNextRun(type, LocalDateTime.now()));
monitoring.setUpdatedAt(LocalDateTime.now());
if (monitoring.getCreatedAt() == null) {
monitoring.setCreatedAt(LocalDateTime.now());
}
return monitoringRepository.save(monitoring);
}
@Transactional
public void updateNextRun(FileMonitoringEntity monitoring) {
if (monitoring.isActive() && monitoring.getMonitoringType() != MonitoringType.NONE) {
LocalDateTime nextRun = calculateNextRun(monitoring.getMonitoringType(), LocalDateTime.now());
monitoring.setNextRun(nextRun);
monitoringRepository.save(monitoring);
}
}
private LocalDateTime calculateNextRun(MonitoringType type, LocalDateTime from) {
LocalDateTime nextRun = from.withHour(2).withMinute(0).withSecond(0).withNano(0);
if (from.isAfter(nextRun)) {
nextRun = nextRun.plusDays(1);
}
return switch (type) {
case MONITORING_DAILY -> nextRun;
case MONITORING_WEEKLY -> nextRun.with(TemporalAdjusters.nextOrSame(java.time.DayOfWeek.MONDAY));
case MONITORING_MONTHLY -> nextRun.with(TemporalAdjusters.firstDayOfNextMonth());
case NONE -> null;
};
}
public MonitoringStatusResponse getMonitoringStatus(String fileId, Long userId) {
FileMonitoringEntity monitoring = monitoringRepository.findByFileId(fileId).orElse(null);
FileEntity file = fileRepository.findById(fileId).orElseThrow();
User user = userRepository.findById(userId).orElseThrow();
return MonitoringStatusResponse.builder()
.fileId(fileId)
.fileName(file.getOriginalFileName())
.monitoringType(monitoring != null ? monitoring.getMonitoringType() : MonitoringType.NONE)
.nextRun(monitoring != null ? monitoring.getNextRun() : null)
.lastRun(monitoring != null ? monitoring.getLastRun() : null)
.lastRunStatus(monitoring != null ? monitoring.getLastRunStatus() : null)
.isActive(monitoring != null && monitoring.isActive())
.needAddTokens(checkTokensForMonitoring(user, monitoring.getMonitoringType()))
.build();
}
public boolean checkTokensForMonitoring(User user, MonitoringType monitoringType) {
if (monitoringType == MonitoringType.NONE) {
return false;
}
TariffDTO tariff = tariffService.getTariffByType(TariffType.valueOf(monitoringType.name()));
TariffInfo activeTariffInfo = user.getActiveTariffInfo();
int currentUserCountTokens = activeTariffInfo.getTokens() + activeTariffInfo.getBoughtTokens();
return currentUserCountTokens > tariff.getTokens();
}
}
@@ -0,0 +1,169 @@
package ru.soune.nocopy.service.monitoring;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.configuration.search.SearchProperties;
import ru.soune.nocopy.dto.file.YandexSearchResponse;
import ru.soune.nocopy.dto.tarriff.TariffDTO;
import ru.soune.nocopy.entity.monitoring.FileMonitoringEntity;
import ru.soune.nocopy.entity.monitoring.MonitoringType;
import ru.soune.nocopy.entity.tarif.TariffInfo;
import ru.soune.nocopy.entity.tarif.TariffType;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.TariffNotFoundException;
import ru.soune.nocopy.repository.FileMonitoringRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.mail.EmailService;
import ru.soune.nocopy.service.search.SearchImageService;
import ru.soune.nocopy.service.tariff.TariffInfoService;
import ru.soune.nocopy.service.tariff.TariffService;
import ru.soune.nocopy.service.violation.ViolationService;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;
@Slf4j
@Service
@RequiredArgsConstructor
public class MonitoringSearchService {
private final SearchImageService searchImageService;
private final FileMonitoringRepository monitoringRepository;
private final TariffInfoService tariffInfoService;
private final TariffService tariffService;
private final EmailService emailService;
private final UserRepository userRepository;
private final ViolationService violationService;
private final SearchProperties searchProperties;
@Transactional(noRollbackFor = TariffNotFoundException.class)
public void processFileSearch(FileMonitoringEntity monitoring) throws MessagingException, IOException {
monitoring.setLastRun(LocalDateTime.now());
monitoring.setLastRunStatus("IN_PROGRESS");
monitoringRepository.save(monitoring);
MonitoringType monitoringType = monitoring.getMonitoringType();
TariffDTO tariffMonitoring = tariffService.getTariffByType(TariffType.valueOf(monitoringType.name()));
try {
tariffInfoService.writeOffTokens(monitoring.getUserId(), tariffMonitoring.getTokens());
boolean useYandex = searchProperties.getEngines().getOrDefault("yandex",
new SearchProperties.EngineConfig()).isEnabled();
boolean useGoogle = searchProperties.getEngines().getOrDefault("google",
new SearchProperties.EngineConfig()).isEnabled();
log.info("Monitoring search settings: useYandex={}, useGoogle={}", useYandex, useGoogle);
List<YandexSearchResponse.ImageResult> allResults = new ArrayList<>();
if (useYandex) {
try {
String yandexResponse = searchImageService.searchReverseByPublicUrl(
monitoring.getFile(), "yandex_reverse_image", "visual_matches");
List<YandexSearchResponse.ImageResult> yandexImages =
searchImageService.getAllImagesWithoutPagination(yandexResponse, "visual_matches");
allResults.addAll(yandexImages);
log.info("Yandex search found {} results", yandexImages.size());
} catch (TimeoutException e) {
log.warn("Yandex search timeout for monitoring file {}", monitoring.getFile().getId());
} catch (IOException e) {
log.error("Yandex search failed for monitoring file {}", monitoring.getFile().getId(), e);
}
} else {
log.info("Yandex search is disabled by settings");
}
if (useGoogle) {
try {
String googleResponse = searchImageService.searchReverseByPublicUrl(
monitoring.getFile(), "google_lens", "exact_matches");
List<YandexSearchResponse.ImageResult> googleImages =
searchImageService.getAllImagesWithoutPagination(googleResponse, "exact_matches");
allResults.addAll(googleImages);
log.info("Google search found {} results", googleImages.size());
} catch (TimeoutException e) {
log.warn("Google search timeout for monitoring file {}", monitoring.getFile().getId());
} catch (IOException e) {
log.error("Google search failed for monitoring file {}", monitoring.getFile().getId(), e);
}
} else {
log.info("Google search is disabled by settings");
}
List<YandexSearchResponse.ImageResult> uniqueResults = allResults;
if (useYandex && useGoogle) {
uniqueResults = searchImageService.removeDuplicateUrls(
useYandex ? allResults : new ArrayList<>(),
useGoogle ? allResults : new ArrayList<>()
);
log.info("After deduplication: {} unique results", uniqueResults.size());
}
for (YandexSearchResponse.ImageResult imageResult : uniqueResults) {
violationService.processViolation(imageResult, monitoring.getFile(), null);
}
monitoring.setLastRunStatus("SUCCESS");
if (uniqueResults.isEmpty()) {
log.info("No results found for monitoring file {}", monitoring.getFile().getId());
}
} catch (TariffNotFoundException e) {
User user = userRepository.findById(monitoring.getUserId()).orElseThrow();
TariffInfo activeTariffInfo = user.getActiveTariffInfo();
int currentTokens = activeTariffInfo.getTokens() + activeTariffInfo.getBoughtTokens();
emailService.sendTokensNotFoundEmail(user, currentTokens, tariffMonitoring.getTokens());
monitoring.setLastRunStatus("ERROR: " + e.getMessage());
updateNextRun(monitoring);
monitoringRepository.save(monitoring);
} catch (Exception e) {
log.error("Error processing monitoring search", e);
monitoring.setLastRunStatus("ERROR: " + e.getMessage());
updateNextRun(monitoring);
monitoringRepository.save(monitoring);
}
}
private void updateNextRun(FileMonitoringEntity monitoring) {
LocalDateTime nextRun = calculateNextRun(monitoring.getMonitoringType());
monitoring.setNextRun(nextRun);
}
private LocalDateTime calculateNextRun(MonitoringType type) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime nextRun = now.withHour(2).withMinute(0).withSecond(0).withNano(0);
if (now.isAfter(nextRun)) {
nextRun = nextRun.plusDays(1);
}
return switch (type) {
case MONITORING_DAILY -> nextRun;
case MONITORING_WEEKLY -> nextRun.plusWeeks(1);
case MONITORING_MONTHLY -> nextRun.plusMonths(1);
default -> null;
};
}
}
@@ -0,0 +1,12 @@
package ru.soune.nocopy.service.notification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class NotificationService {
}
@@ -0,0 +1,225 @@
package ru.soune.nocopy.service.payment;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.ReferralService;
import ru.soune.nocopy.client.YooKassaClient;
import ru.soune.nocopy.dto.payment.PaymentMethodDTO;
import ru.soune.nocopy.entity.payment.Payment;
import ru.soune.nocopy.entity.payment.PaymentMethod;
import ru.soune.nocopy.entity.payment.PaymentOperationType;
import ru.soune.nocopy.entity.payment.PaymentStatus;
import ru.soune.nocopy.entity.tarif.Tariff;
import ru.soune.nocopy.entity.tarif.TariffInfo;
import ru.soune.nocopy.entity.tarif.TariffStatus;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.PaymentNotFoundException;
import ru.soune.nocopy.exception.TariffNotFoundException;
import ru.soune.nocopy.exception.UserNotFoundException;
import ru.soune.nocopy.repository.*;
import ru.soune.nocopy.service.tariff.TariffInfoService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {
private final PaymentRepository paymentRepository;
private final UserRepository userRepository;
private final TariffRepository tariffRepository;
private final TariffInfoRepository tariffInfoRepository;
private final TariffInfoService tariffInfoService;
private final ReferralService referralService;
private final PaymentMethodRepository paymentMethodRepository;
private final YooKassaClient yooKassaClient;
@Transactional
public Payment createPayment(String email, Long tariffId, String operationType, String paymentUuid) {
User user = userRepository.findByEmail(email);
if (user == null) {
throw new UserNotFoundException(email);
}
Tariff tariff = tariffRepository.findById(tariffId).orElseThrow(() ->
new TariffNotFoundException("Tariff not found"));
Payment payment = new Payment();
payment.setStatus(PaymentStatus.PENDING);
payment.setAmount(tariff.getPrice());
payment.setOperationType(PaymentOperationType.valueOf(operationType));
payment.setUser(user);
payment.setTariff(tariff);
payment.setPaymentUuid(paymentUuid);
return paymentRepository.save(payment);
}
private void savePaymentMethod(User user, Map<String, Object> paymentMethodData) {
if (paymentMethodData == null) return;
String paymentMethodId = (String) paymentMethodData.get("id");
if (paymentMethodRepository.findByPaymentMethodId(paymentMethodId).isPresent()) return;
Map<String, Object> card = (Map<String, Object>) paymentMethodData.get("card");
if (card == null) return;
PaymentMethod pm = new PaymentMethod();
pm.setUser(user);
pm.setPaymentMethodId(paymentMethodId);
pm.setLastFour((String) card.get("last4"));
pm.setCardType((String) card.get("card_type"));
pm.setExpiryMonth((String) card.get("expiry_month"));
pm.setExpiryYear((String) card.get("expiry_year"));
pm.setCreatedAt(LocalDateTime.now());
paymentMethodRepository.save(pm);
}
@Transactional(readOnly = true)
public List<PaymentMethodDTO> getUserPaymentMethods(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
return paymentMethodRepository.findByUserAndActiveTrue(user)
.stream()
.map(pm -> new PaymentMethodDTO(
pm.getPaymentMethodId(),
pm.getLastFour(),
pm.getCardType(),
pm.getExpiryMonth(),
pm.getExpiryYear()
))
.collect(Collectors.toList());
}
@Transactional
public void deletePaymentMethod(Long userId, String paymentMethodId) {
User user = userRepository.findById(userId).orElseThrow();
PaymentMethod pm = paymentMethodRepository.findByPaymentMethodId(paymentMethodId)
.orElseThrow(() -> new RuntimeException("Payment method not found"));
if (!pm.getUser().getId().equals(user.getId())) {
throw new RuntimeException("Access denied");
}
pm.setActive(false);
paymentMethodRepository.save(pm);
}
@Transactional
public void changeAutoRenewal(Long userId, boolean autoRenewal) {
User user = userRepository.findById(userId).orElseThrow();
TariffInfo tariffInfo = user.getActiveTariffInfo();
if (tariffInfo != null) {
tariffInfo.setAutoRenewal(autoRenewal);
tariffInfoRepository.save(tariffInfo);
}
}
@Transactional
public void handlePaymentNotification(Map<String, Object> notification) {
String eventType = (String) notification.get("event");
Map<String, Object> object = (Map<String, Object>) notification.get("object");
String paymentUuid = (String) object.get("id");
Payment payment = paymentRepository.findByPaymentUuid(paymentUuid)
.orElseThrow(() -> new PaymentNotFoundException("Payment not found"));
if ("payment.succeeded".equals(eventType)) {
Tariff tariff = payment.getTariff();
double price = tariff.getPrice();
int intPrice = (int) Math.round(price);
payment.setStatus(PaymentStatus.SUCCEEDED);
payment.setCancellationReason(null);
Map<String, String> metadata = (Map<String, String>) object.get("metadata");
Map<String, Object> paymentMethod = (Map<String, Object>) object.get("payment_method");
String paymentMethodId = paymentMethod != null ? (String) paymentMethod.get("id") : null;
boolean autoRenewal = metadata != null && Boolean.parseBoolean(metadata.get("auto_renewal"));
activateTariffForUser(payment.getUser(), tariff, autoRenewal, paymentMethodId);
savePaymentMethod(payment.getUser(), paymentMethod);
referralService.onUserAccountRefill(payment.getUser().getId(), intPrice);
} else if ("payment.canceled".equals(eventType)) {
payment.setStatus(PaymentStatus.CANCELED);
Map<String, Object> cancellationDetails = (Map<String, Object>) object.get("cancellation_details");
if (cancellationDetails != null) {
String reason = (String) cancellationDetails.get("reason");
payment.setCancellationReason(reason);
}
} else if ("payment.waiting_for_capture".equals(eventType)) {
payment.setStatus(PaymentStatus.WAITING);
payment.setCancellationReason(null);
} else {
payment.setStatus(PaymentStatus.FAILED);
Map<String, Object> cancellationDetails = (Map<String, Object>) object.get("cancellation_details");
if (cancellationDetails != null) {
String reason = (String) cancellationDetails.get("reason");
payment.setCancellationReason(reason);
}
}
paymentRepository.save(payment);
}
public List<Payment> userPayments(String email) {
User user = userRepository.findByEmail(email);
if (user == null) {
throw new UserNotFoundException("User not found");
}
return paymentRepository.findByUserId(user.getId());
}
public Payment paymentInfo(String uuid) {
return paymentRepository.findByPaymentUuid(uuid).orElseThrow(() ->
new PaymentNotFoundException("Payment not found"));
}
private void activateTariffForUser(User user, Tariff tariff, boolean autoRenewal, String paymentMethodId) {
String type = tariff.getTariffAccountType();
TariffInfo activeTariffInfo = user.getActiveTariffInfo();
if (type.equals("token")) {
Integer tokens = activeTariffInfo.getBoughtTokens();
activeTariffInfo.setBoughtTokens(tokens + tariff.getTokens());
tariffInfoRepository.save(activeTariffInfo);
} else {
TariffInfo tariffInfo = new TariffInfo();
tariffInfo.setStatus(TariffStatus.ACTIVE);
tariffInfo.setStartTariff(LocalDateTime.now());
tariffInfo.setEndTariff(LocalDateTime.now().plusMonths(1));
tariffInfo.setTokens(tariff.getTokens());
tariffInfo.setBoughtTokens(activeTariffInfo.getBoughtTokens());
tariffInfo.setTariff(tariff);
activeTariffInfo.setAutoRenewal(autoRenewal);
tariffInfo.setPaymentMethodId(paymentMethodId);
tariffInfoService.linkUserTariffInfo(user, tariffInfoService.addTariffInfo(tariffInfo));
}
}
}
@@ -0,0 +1,94 @@
package ru.soune.nocopy.service.payout;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.referral.Referral;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.InsufficientFundsException;
import ru.soune.nocopy.repository.ReferralJpaRepository;
import java.math.BigDecimal;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BalanceService {
private final ReferralJpaRepository referralJpaRepository;
public boolean hasSufficientFunds(User user, BigDecimal amount) {
Referral referral = referralJpaRepository.findByUserId(user.getId());
BigDecimal available = BigDecimal.valueOf(referral.getAvailableIncome());
return available.compareTo(amount) >= 0;
}
public BigDecimal getBalance(User user) {
Referral referral = referralJpaRepository.findByUserId(user.getId());
return BigDecimal.valueOf(referral.getAvailableIncome());
}
public BigDecimal getFrozenBalance(User user) {
Referral referral = referralJpaRepository.findByUserId(user.getId());
return BigDecimal.valueOf(referral.getHoldBalance());
}
@Transactional
public void freezeAmount(User user, BigDecimal amount) {
Referral referral = referralJpaRepository.findByUserId(user.getId());
BigDecimal available = BigDecimal.valueOf(referral.getAvailableIncome());
if (available.compareTo(amount) < 0) {
throw new InsufficientFundsException(
String.format("Not have amount. Amount have: %.2f", available)
);
}
BigDecimal newAvailable = available.subtract(amount);
BigDecimal newHold = BigDecimal.valueOf(referral.getHoldBalance()).add(amount);
referral.setAvailableIncome(newAvailable.intValue());
referral.setHoldBalance(newHold.intValue());
referralJpaRepository.save(referral);
log.info("Amount freeze. Have: {}, Frozen: {}", newAvailable, newHold);
}
@Transactional
public void unfreezeAmount(Long userId, BigDecimal amount) {
log.info("Unfreeze {} for user {}", amount, userId);
Referral referral = referralJpaRepository.findByUserId(userId);
BigDecimal hold = BigDecimal.valueOf(referral.getHoldBalance());
if (hold.compareTo(amount) < 0) {
throw new IllegalStateException(
String.format("Not have freeze amount. Frozen: %.2f", hold)
);
}
BigDecimal newAvailable = BigDecimal.valueOf(referral.getAvailableIncome()).add(amount);
BigDecimal newHold = hold.subtract(amount);
referral.setAvailableIncome(newAvailable.intValue());
referral.setHoldBalance(newHold.intValue());
referralJpaRepository.save(referral);
log.info("Amount frozen. Have: {}, Frozen: {}", newAvailable, newHold);
}
public void withdrawAmount(Long userId, BigDecimal amount) {
Referral referral = referralJpaRepository.findByUserId(userId);
if (referral.getHoldBalance() < amount.intValue()) {
throw new IllegalStateException("Not have amount");
}
referral.setHoldBalance(referral.getHoldBalance() - amount.intValue());
}
}
@@ -0,0 +1,156 @@
package ru.soune.nocopy.service.payout;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.payout.AddBankTransferRequest;
import ru.soune.nocopy.dto.payout.AddCardRequest;
import ru.soune.nocopy.dto.payout.PayoutMethodDTO;
import ru.soune.nocopy.entity.payout.*;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.DuplicateMethodException;
import ru.soune.nocopy.repository.PayoutMethodRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.user.UserService;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class PayoutMethodService {
private final PayoutMethodRepository payoutMethodRepository;
private final UserRepository userRepository;
@Transactional
public void initiatedStandartPayoutMethods(User user) {
createInternalMethod(user);
}
@Transactional
public InternalPayoutMethod createInternalMethod(User user) {
if (payoutMethodRepository.findByUserAndType(user.getId(), InternalPayoutMethod.class).isPresent()) {
return (InternalPayoutMethod) payoutMethodRepository.findByUserAndType(
user.getId(), InternalPayoutMethod.class).get();
}
InternalPayoutMethod internalMethod = new InternalPayoutMethod();
internalMethod.setUser(user);
internalMethod.setDefault(true);
return payoutMethodRepository.save(internalMethod);
}
public List<PayoutMethodDTO> getUserMethods(long userId) {
return payoutMethodRepository.findByUserId(userId)
.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}
@Transactional
public void setDefaultMethod(Long userId, Long methodId) {
payoutMethodRepository.clearOtherDefault(userId, methodId);
PayoutMethod method = payoutMethodRepository.findByIdAndUserId(methodId, userId)
.orElseThrow(() -> new IllegalArgumentException("Not found method"));
method.setDefault(true);
payoutMethodRepository.save(method);
}
@Transactional
public BankTransferPayoutMethod addBankTransferMethod(Long userId, AddBankTransferRequest request) {
User user = userRepository.findById(userId).orElseThrow();
BankTransferPayoutMethod bankMethod = new BankTransferPayoutMethod();
bankMethod.setUser(user);
bankMethod.setBankName(request.getBankName());
bankMethod.setBic(request.getBic());
bankMethod.setCorrespondentAccount(request.getCorrespondentAccount());
bankMethod.setAccountNumber(request.getAccountNumber());
bankMethod.setAccountHolder(request.getAccountHolder());
bankMethod.setInn(request.getInn());
bankMethod.setKpp(request.getKpp());
bankMethod.setDefault(payoutMethodRepository.countByUserId(user.getId()) == 0);
return payoutMethodRepository.save(bankMethod);
}
@Transactional
public CardPayoutMethod addCardMethod(Long userId, AddCardRequest request) {
List<PayoutMethod> byUserId = payoutMethodRepository.findByUserId(userId);
if (!byUserId.isEmpty()) {
for (PayoutMethod payoutMethod : byUserId) {
if (payoutMethod.getMethodType().equals(PayoutType.CARD.getDisplayName().toUpperCase())) {
payoutMethodRepository.delete(payoutMethod);
}
}
}
String cardHash = generateCardHash(request.getCardNumber());
if (payoutMethodRepository.existsByCardHash(cardHash)) {
throw new DuplicateMethodException("Card was added");
}
User user = userRepository.findById(userId).orElseThrow();
CardPayoutMethod cardMethod = new CardPayoutMethod();
cardMethod.setUser(user);
cardMethod.setCardNumber(request.getCardNumber());
cardMethod.setCardHash(cardHash);
cardMethod.setDefault(payoutMethodRepository.countByUserId(user.getId()) == 0);
CardPayoutMethod saved = payoutMethodRepository.save(cardMethod);
log.info("Added user card: {}", user.getId());
return saved;
}
public PayoutMethod getDefaultMethod(User user) {
return payoutMethodRepository.findByUserIdAndIsDefaultTrue(user.getId())
.orElseGet(() -> {
List<PayoutMethod> methods = payoutMethodRepository.findByUserId(user.getId());
if (methods.isEmpty()) {
return createInternalMethod(user);
}
return methods.get(0);
});
}
public PayoutMethodDTO convertToDto(PayoutMethod method) {
PayoutMethodDTO payoutMethodDTO = PayoutMethodDTO.builder()
.id(method.getId())
.type(method.getClass().getSimpleName().replace("PayoutMethod", ""))
.displayName(method.getDisplayName())
.maskedDetails(method.getMaskedDetails())
.isDefault(method.isDefault())
.build();
if (method instanceof CardPayoutMethod cardMethod) {
payoutMethodDTO.setCardNumber(cardMethod.getCardNumber());
}
return payoutMethodDTO;
}
private String generateCardHash(String cardNumber) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(cardNumber.getBytes());
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Failed generate hash", e);
}
}
}
@@ -0,0 +1,157 @@
package ru.soune.nocopy.service.payout;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.ReferralService;
import ru.soune.nocopy.dto.payout.PayoutRequestDTO;
import ru.soune.nocopy.entity.payout.PayoutMethod;
import ru.soune.nocopy.entity.payout.PayoutRequest;
import ru.soune.nocopy.entity.payout.PayoutRequestStatus;
import ru.soune.nocopy.entity.payout.PayoutType;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.InsufficientFundsException;
import ru.soune.nocopy.exception.InvalidPayoutMethodException;
import ru.soune.nocopy.exception.PendingPayoutExistsException;
import ru.soune.nocopy.repository.PayoutMethodRepository;
import ru.soune.nocopy.repository.PayoutRequestRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class PayoutRequestService {
private final PayoutRequestRepository payoutRequestRepository;
private final PayoutMethodRepository payoutMethodRepository;
private final BalanceService balanceService;
private final UserRepository userRepository;
private static final BigDecimal MIN_PAYOUT_AMOUNT = new BigDecimal("1000.00");
private static final List<PayoutRequestStatus> BLOCKING_STATUSES = Arrays.asList(PayoutRequestStatus.PENDING,
PayoutRequestStatus.PROCESSING);
@Transactional
public PayoutRequest createPayoutRequest(Long userId, PayoutRequestDTO request) {
log.info("Create request for money {}: sum {}", userId, request.getAmount());
validatePayoutAmount(request.getAmount());
User user = userRepository.findById(userId).orElseThrow();
if (!balanceService.hasSufficientFunds(user, request.getAmount())) {
throw new InsufficientFundsException(String.format("Not have money for payout. Have: %.2f",
balanceService.getBalance(user)));
}
if (hasActivePayoutRequests(user)) {
throw new PendingPayoutExistsException("Have active payout. Wait for end.");
}
PayoutMethod payoutMethod = payoutMethodRepository
.findByIdAndUserId(request.getPayoutMethodId(), user.getId())
.orElseThrow(() -> new InvalidPayoutMethodException("Not found payout method"));
PayoutType payoutType = determinePayoutType(payoutMethod);
PayoutRequest payoutRequest = PayoutRequest.builder()
.user(user)
.payoutMethod(payoutMethod)
.amount(request.getAmount())
.status(PayoutRequestStatus.PENDING)
.payoutType(payoutType)
.build();
PayoutRequest savedRequest = payoutRequestRepository.save(payoutRequest);
balanceService.freezeAmount(user, request.getAmount());
return savedRequest;
}
private void validatePayoutAmount(BigDecimal amount) {
if (amount == null) {
throw new IllegalArgumentException("Sum is null");
}
if (amount.compareTo(MIN_PAYOUT_AMOUNT) < 0) {
throw new IllegalArgumentException(
String.format("Min sum for payout: %.2f rub.", MIN_PAYOUT_AMOUNT)
);
}
}
private boolean hasActivePayoutRequests(User user) {
return payoutRequestRepository.existsByUserIdAndStatusIn(user.getId(), BLOCKING_STATUSES);
}
private PayoutType determinePayoutType(PayoutMethod payoutMethod) {
String className = payoutMethod.getClass().getSimpleName();
switch (className) {
case "CardPayoutMethod":
return PayoutType.CARD;
case "BankTransferPayoutMethod":
return PayoutType.BANK_TRANSFER;
case "InternalPayoutMethod":
return PayoutType.INTERNAL;
default:
throw new InvalidPayoutMethodException("Unknown method");
}
}
@Transactional(readOnly = true)
public List<PayoutRequest> getUserPayoutRequests(Long userId) {
return payoutRequestRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
@Transactional(readOnly = true)
public PayoutRequest getPayoutRequest(Long userId, Long requestId) {
return payoutRequestRepository.findByIdAndUserId(requestId, userId)
.orElseThrow(() -> new IllegalArgumentException("Payout not found"));
}
@Transactional
public PayoutRequest cancelPayoutRequest(Long userId, Long requestId) {
PayoutRequest request = getPayoutRequest(userId, requestId);
if (request.getStatus() != PayoutRequestStatus.PENDING) {
throw new IllegalStateException(
"Can't cnacel: " + request.getStatus()
);
}
request.setStatus(PayoutRequestStatus.CANCELLED);
balanceService.unfreezeAmount(userId, request.getAmount());
return payoutRequestRepository.save(request);
}
@Transactional
public PayoutRequest processRequest(Long requestId) {
PayoutRequest request = payoutRequestRepository.findById(requestId)
.orElseThrow();
if (request.getStatus() != PayoutRequestStatus.PENDING) {
throw new IllegalStateException("Can't proceed in status: " + request.getStatus());
}
request.setStatus(PayoutRequestStatus.COMPLETED);
request.setProcessAt(LocalDateTime.now());
balanceService.withdrawAmount(request.getUser().getId(), request.getAmount());
return payoutRequestRepository.save(request);
}
}
@@ -99,8 +99,7 @@ public class ReferralRepoImpl implements ReferralRepo {
@Override
public int getAvailableIncome() {
Integer holdBalance = entity.getHoldBalance();
return entity.getTotalIncome() - (holdBalance != null ? holdBalance : 0);
return entity.getAvailableIncome();
}
@Override
@@ -109,6 +108,11 @@ public class ReferralRepoImpl implements ReferralRepo {
return holdBalance != null ? holdBalance : 0;
}
@Override
public int getSpentBalance() {
return entity.getTotalIncome() - entity.getAvailableIncome() - entity.getHoldBalance();
}
@Override
public boolean getActive() { return entity.isActive(); }
};
@@ -151,6 +155,7 @@ public class ReferralRepoImpl implements ReferralRepo {
referral.setInviterId(entity.getInviter());
referral.setLevelId(entity.getCurrentLevel());
referral.setTotalIncome(entity.getTotalIncome());
referral.setAvailableIncome(entity.getAvailableIncome());
referral.setHoldBalance(entity.getHoldBalance());
referral.setActive(entity.getActive());
@@ -11,22 +11,18 @@ import ru.soune.nocopy.dto.register.LoginAnswer;
import ru.soune.nocopy.dto.register.LoginRequest;
import ru.soune.nocopy.dto.register.RegRequest;
import ru.soune.nocopy.entity.company.Company;
import ru.soune.nocopy.entity.tarif.Tariff;
import ru.soune.nocopy.entity.tarif.TariffInfo;
import ru.soune.nocopy.entity.tarif.TariffType;
import ru.soune.nocopy.entity.user.AuthToken;
import ru.soune.nocopy.entity.user.Permission;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.MaxUserCountIsOver;
import ru.soune.nocopy.entity.user.UserNotActive;
import ru.soune.nocopy.exception.CompanyAlreadyExist;
import ru.soune.nocopy.exception.NotFoundAuthToken;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.CompanyRepository;
import ru.soune.nocopy.repository.TariffInfoRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.repository.*;
import ru.soune.nocopy.service.file.CheckCounterService;
import ru.soune.nocopy.service.tariff.TariffInfoService;
import ru.soune.nocopy.service.tariff.TariffService;
import java.security.SecureRandom;
import java.time.LocalDateTime;
@@ -50,21 +46,27 @@ public class AuthService {
private final TariffInfoService tariffInfoService;
private final UserNotActiveRepository userNotActiveRepository;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
public AuthToken register(RegRequest registerRequest) {
public AuthToken register(RegRequest registerRequest, boolean verified) {
User user = new User();
user.setFullName(registerRequest.getFullName());
user.setEmail(registerRequest.getEmail());
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
user.setActive(true);
user.setEmailVerified(true);
user.setActive(!verified);
user.setEmailVerified(!verified);
AccountType accountType = AccountType.valueOf(registerRequest.getAccountType().toUpperCase());
if (accountType == AccountType.B2B && registerRequest.getCompanyName() != null) {
createCompanyAccount(registerRequest, user);
try {
createCompanyAccount(registerRequest, user);
} catch (CompanyAlreadyExist exist) {
throw new CompanyAlreadyExist("Company already exist");
}
} else if (accountType == AccountType.B2C && registerRequest.getCompanyName() == null) {
createIndividualAccount(user);
} else {
@@ -104,14 +106,30 @@ public class AuthService {
return authToken.getUser().getId();
}
@Transactional
public AuthToken login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail());
if (!user.isActive()) {
throw new NotValidFieldException("User not active", new BaseResponse(20003,
MessageCode.USER_NOT_ACTIVE.getCode(),
MessageCode.USER_NOT_ACTIVE.getDescription(), Map.of("userId", user.getId())));
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
LoginAnswer loginAnswer = new LoginAnswer();
loginAnswer.setFieldErrors(Arrays.asList(Map.of("password", request.getPassword())));
loginAnswer.setVerified(user.isEmailVerified());
loginAnswer.setActive(user.isActive());
int attempts = user.getFailedLoginAttempts() + 1;
user.setFailedLoginAttempts(attempts);
if (attempts >= 5) {
user.setActive(false);
blockUser(user);
}
userRepository.save(user);
throw new NotValidFieldException("Invalid password", new BaseResponse(20003,
MessageCode.AUTH_PASSWORD_NOT_MATCHES.getCode(),
@@ -119,6 +137,9 @@ public class AuthService {
}
user.setLastLoginAt(LocalDateTime.now());
user.setFailedLoginAttempts(0);
user.setActive(true);
User savedUser = userRepository.save(user);
AuthToken authToken = new AuthToken();
@@ -134,10 +155,15 @@ public class AuthService {
.ifPresent(authTokenRepository::delete);
}
@Transactional
public AuthToken generateAuthToken(User user) {
return authTokenRepository.save(genereateAuthToken(user));
}
public AuthToken getAuthToken(String token) {
return authTokenRepository.findByToken(token).get();
}
private AuthToken genereateAuthToken(User user) {
AuthToken authToken = new AuthToken();
authToken.setToken(generateAuthToken());
@@ -153,8 +179,15 @@ public class AuthService {
}
private void createCompanyAccount(RegRequest request, User user) {
Optional<Company> byInn = companyRepository.findByInn(request.getInn());
if (byInn.isPresent()) {
throw new CompanyAlreadyExist("Company was already created");
}
Company company = new Company();
company.setCompanyName(request.getCompanyName());
company.setInn(request.getInn());
TariffInfo companyTariff = tariffInfoService.createTariffInfo(TariffType.DEMO);
company.setTariffInfo(companyTariff);
@@ -192,18 +225,33 @@ public class AuthService {
throw new IllegalStateException("Company not have tariff");
}
Tariff tariff = tariffInfo.getTariff();
Long maxUsers = tariff.getMaxUsers();
long currentCountUsers = userRepository.countByCompanyId(company.getId());
if (currentCountUsers >= maxUsers) {
throw new NotValidFieldException("Max user is over.", new BaseResponse(20002,
MessageCode.USER_LIMIT_IS_OVER.getCode(),
MessageCode.USER_LIMIT_IS_OVER.getDescription(), Map.of(
"limit", maxUsers, "countUser", currentCountUsers)));
}
// Tariff tariff = tariffInfo.getTariff();
// Long maxUsers = tariff.getMaxUsers();
// long currentCountUsers = userRepository.countByCompanyId(company.getId());
//
// if (currentCountUsers >= maxUsers) {
// throw new NotValidFieldException("Max user is over.", new BaseResponse(20002,
// MessageCode.USER_LIMIT_IS_OVER.getCode(),
// MessageCode.USER_LIMIT_IS_OVER.getDescription(), Map.of(
// "limit", maxUsers, "countUser", currentCountUsers)));
// }
user.setCompany(company);
user.grantPermission(Permission.DEFAULT_USER_PERMISSIONS);
}
public void blockUser(User user) {
Optional<UserNotActive> existing = userNotActiveRepository.findByUserId(user.getId());
if (existing.isEmpty()) {
LocalDateTime now = LocalDateTime.now();
UserNotActive notActive = UserNotActive.builder()
.user(user)
.createdAt(now)
.blockedUntil(now.plusMinutes(15))
.build();
userNotActiveRepository.save(notActive);
}
}
}
@@ -1,6 +1,7 @@
package ru.soune.nocopy.service.register;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -12,6 +13,7 @@ import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class CleanUpVerifyTokens {
private final EmailVerificationTokenRepository emailVerificationTokenRepository;
@@ -20,8 +22,7 @@ public class CleanUpVerifyTokens {
@Scheduled(fixedDelay = 30000)
public void cleanupExpiredTokens() {
LocalDateTime now = LocalDateTime.now();
log.info("Check time for tokens");
List<EmailVerificationToken> expiredTokens = emailVerificationTokenRepository.findByExpiresAtBefore(now);
if (!expiredTokens.isEmpty()) {
@@ -0,0 +1,178 @@
package ru.soune.nocopy.service.search;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.configuration.search.SearchProperties;
import ru.soune.nocopy.dto.file.YandexSearchResponse;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.search.GlobalSearchResult;
import ru.soune.nocopy.entity.search.GlobalSearchTask;
import ru.soune.nocopy.entity.search.SearchStatus;
import ru.soune.nocopy.repository.GlobalSearchResultRepository;
import ru.soune.nocopy.repository.GlobalSearchTaskRepository;
import ru.soune.nocopy.service.tariff.TariffConstants;
import ru.soune.nocopy.service.tariff.TariffInfoService;
import ru.soune.nocopy.service.violation.ViolationService;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalSearchAsyncProcessor {
private final GlobalSearchResultRepository globalSearchResultRepository;
private final SearchImageService searchImageService;
private final TariffInfoService tariffInfoService;
private final GlobalSearchTaskRepository globalSearchTaskRepository;
private final ViolationService violationService;
private final SearchProperties searchProperties;
@Async
@Transactional
public void processFilesAsync(String taskId, List<FileEntity> filesToProcess, Long userId) {
try {
for (int i = 0; i < filesToProcess.size(); i++) {
FileEntity file = filesToProcess.get(i);
tariffInfoService.writeOffTokens(userId, TariffConstants.TOKEN_VALUE_FOR_SEARCH);
GlobalSearchResult result = processFile(file, taskId, userId);
globalSearchResultRepository.save(result);
GlobalSearchTask task = globalSearchTaskRepository.findById(taskId).orElseThrow();
task.setProcessedFiles(i + 1);
task.setUpdatedAt(LocalDateTime.now());
globalSearchTaskRepository.save(task);
}
GlobalSearchTask task = globalSearchTaskRepository.findById(taskId).orElseThrow();
task.setStatus(SearchStatus.COMPLETED.name());
task.setUpdatedAt(LocalDateTime.now());
globalSearchTaskRepository.save(task);
} catch (Exception e) {
log.error("Global search failed for task: {}", taskId, e);
GlobalSearchTask task = globalSearchTaskRepository.findById(taskId).orElseThrow();
task.setStatus(SearchStatus.FAILED.name());
task.setUpdatedAt(LocalDateTime.now());
globalSearchTaskRepository.save(task);
}
}
private GlobalSearchResult processFile(FileEntity file, String taskId, Long userId) {
GlobalSearchResult result = new GlobalSearchResult();
result.setTaskId(taskId);
result.setFileId(file.getId());
result.setFileName(file.getOriginalFileName());
result = globalSearchResultRepository.save(result);
boolean useGoogle = searchProperties.getEngines().getOrDefault("google",
new SearchProperties.EngineConfig()).isEnabled();
boolean useYandex = searchProperties.getEngines().getOrDefault("yandex",
new SearchProperties.EngineConfig()).isEnabled();
log.info("Global search settings for file {}: useGoogle={}, useYandex={}",
file.getId(), useGoogle, useYandex);
List<YandexSearchResponse.ImageResult> googleImages = new ArrayList<>();
List<YandexSearchResponse.ImageResult> yandexImages = new ArrayList<>();
List<YandexSearchResponse.ImageResult> allResults = new ArrayList<>();
boolean hasTimeout = false;
if (useGoogle) {
try {
String searchResponseGoogle = searchImageService.searchReverseByPublicUrl(
file, "google_lens", "exact_matches");
googleImages = searchImageService.getAllImagesWithoutPagination(searchResponseGoogle,
"exact_matches");
allResults.addAll(googleImages);
log.info("Google search OK for file {}, found {} results", file.getId(), googleImages.size());
} catch (TimeoutException e) {
log.warn("Google search timeout for file {}", file.getId());
hasTimeout = true;
} catch (IOException e) {
log.error("Google search failed for file {}", file.getId(), e);
}
} else {
log.info("Google search is disabled by settings for file {}", file.getId());
}
if (useYandex) {
try {
String searchResponseYandex = searchImageService.searchReverseByPublicUrl(
file, "yandex_reverse_image", "visual_matches");
yandexImages = searchImageService.getAllImagesWithoutPagination(searchResponseYandex, "visual_matches");
allResults.addAll(yandexImages);
log.info("Yandex search OK for file {}, found {} results", file.getId(), yandexImages.size());
} catch (TimeoutException e) {
log.warn("Yandex search timeout for file {}", file.getId());
hasTimeout = true;
} catch (IOException e) {
log.error("Yandex search failed for file {}", file.getId(), e);
}
} else {
log.info("Yandex search is disabled by settings for file {}", file.getId());
}
List<YandexSearchResponse.ImageResult> allUniqueImages;
if (useGoogle && useYandex) {
allUniqueImages = searchImageService.removeDuplicateUrls(
yandexImages.stream()
.filter(img -> img.getUrl() != null)
.collect(Collectors.toList()),
googleImages.stream()
.filter(img -> img.getUrl() != null)
.collect(Collectors.toList())
);
log.info("Merged results from both engines: {} unique images", allUniqueImages.size());
} else if (useGoogle) {
allUniqueImages = googleImages.stream()
.filter(img -> img.getUrl() != null)
.collect(Collectors.toList());
log.info("Results only from Google: {} images", allUniqueImages.size());
} else if (useYandex) {
allUniqueImages = yandexImages.stream()
.filter(img -> img.getUrl() != null)
.collect(Collectors.toList());
log.info("Results only from Yandex: {} images", allUniqueImages.size());
} else {
allUniqueImages = new ArrayList<>();
log.warn("All search engines are disabled for file {}", file.getId());
}
for (YandexSearchResponse.ImageResult imageResult : allUniqueImages) {
violationService.processViolation(imageResult, file, result);
}
if (allUniqueImages.isEmpty() && hasTimeout) {
result.setFileStatus(SearchStatus.TIMEOUT.name());
} else if (allUniqueImages.isEmpty() && (!useGoogle && !useYandex)) {
result.setFileStatus(SearchStatus.FAILED.name());
log.warn("No results for file {} because all engines are disabled", file.getId());
} else {
result.setFileStatus(SearchStatus.SUCCESS.name());
}
return result;
}
}
@@ -0,0 +1,72 @@
package ru.soune.nocopy.service.search;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.search.GlobalSearchStartRequest;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileType;
import ru.soune.nocopy.entity.monitoring.FileMonitoringEntity;
import ru.soune.nocopy.entity.search.GlobalSearchTask;
import ru.soune.nocopy.entity.search.SearchStatus;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.FileMonitoringRepository;
import ru.soune.nocopy.repository.GlobalSearchTaskRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class GlobalSearchService {
private final FileEntityRepository fileEntityRepository;
private final FileMonitoringRepository fileMonitoringRepository;
private final GlobalSearchTaskRepository globalSearchTaskRepository;
private final GlobalSearchAsyncProcessor asyncProcessor;
private final FileEntityService fileEntityService;
@Transactional
public String startSearch(GlobalSearchStartRequest request, Long userId, List<FileEntity> filesToProcess) {
GlobalSearchTask task = new GlobalSearchTask();
task.setUserId(userId);
task.setSearchType(request.getSearchType());
task.setStatus(SearchStatus.PROCESSING.name());
task.setTotalFiles(filesToProcess.size());
task.setProcessedFiles(0);
task.setCreatedAt(LocalDateTime.now());
globalSearchTaskRepository.save(task);
asyncProcessor.processFilesAsync(task.getTaskId(), filesToProcess, userId);
return task.getTaskId();
}
public List<FileEntity> getFilesToProcess(GlobalSearchStartRequest request, Long userId) {
if ("monitoring".equals(request.getSearchType())) {
List<FileMonitoringEntity> monitoringFiles =
fileMonitoringRepository.findByUserIdAndIsActiveTrue(userId);
return monitoringFiles.isEmpty() ? new ArrayList<>(): monitoringFiles.stream()
.map(FileMonitoringEntity::getFile)
.toList();
} else {
List<String> fileIds = request.getFileIds().isEmpty() ? fileEntityService.getAllUserFiles(userId)
.stream()
.filter(f -> f.getMimeType().equals(FileType.IMAGE.getDisplayName()))
.map(FileEntity::getId)
.toList(): request.getFileIds();
return fileEntityRepository.findAllById(fileIds);
}
}
}
@@ -0,0 +1,101 @@
package ru.soune.nocopy.service.search;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.search.GlobalSearchStatisticsResponse;
import ru.soune.nocopy.entity.company.Company;
import ru.soune.nocopy.entity.search.GlobalSearchResult;
import ru.soune.nocopy.entity.search.GlobalSearchTask;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.repository.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class GlobalSearchStatisticsService {
private final GlobalSearchTaskRepository taskRepository;
private final GlobalSearchResultRepository resultRepository;
private final ViolationRepository violationRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public GlobalSearchStatisticsResponse getStatistics(Long userId, Integer limit, Integer days) {
User user = userRepository.findById(userId).orElseThrow();
Company company = user.getCompany();
List<Long> userIds = company == null ? List.of(userId) :
user.getCompany()
.getUsers()
.stream()
.map(User::getId).toList();
long totalSearches = taskRepository.countByUserId(userIds);
List<Object[]> statusCounts = taskRepository.countByStatusAndUserId(userIds);
GlobalSearchStatisticsResponse.StatusCountDto statusCountDto =
GlobalSearchStatisticsResponse.StatusCountDto.builder()
.processing(0)
.completed(0)
.failed(0)
.total(totalSearches)
.build();
for (Object[] row : statusCounts) {
String status = (String) row[0];
Long count = (Long) row[1];
if ("PROCESSING".equalsIgnoreCase(status)) {
statusCountDto.setProcessing(count);
} else if ("COMPLETED".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status)) {
statusCountDto.setCompleted(statusCountDto.getCompleted() + count);
} else if ("FAILED".equalsIgnoreCase(status) || "TIMEOUT".equalsIgnoreCase(status)) {
statusCountDto.setFailed(statusCountDto.getFailed() + count);
}
}
List<GlobalSearchTask> recentTasks;
if (days != null && days > 0) {
LocalDateTime fromDate = LocalDateTime.now().minusDays(days);
recentTasks = taskRepository.findByUserIdAndCreatedAtAfterOrderByCreatedAtDesc(userId, fromDate);
if (limit != null && limit > 0 && recentTasks.size() > limit) {
recentTasks = recentTasks.subList(0, limit);
}
} else {
org.springframework.data.domain.Pageable pageable =
org.springframework.data.domain.PageRequest.of(0, limit != null ? limit : 10);
recentTasks = taskRepository.findTopByUserIdOrderByCreatedAtDesc(userId, pageable);
}
List<GlobalSearchStatisticsResponse.SearchStatDto> recentSearchDtos = new ArrayList<>();
for (GlobalSearchTask task : recentTasks) {
List<GlobalSearchResult> globalSearchResults = resultRepository.findByTaskId(task.getTaskId());
List<String> fileIds = globalSearchResults.stream()
.map(GlobalSearchResult::getFileId)
.toList();
GlobalSearchStatisticsResponse.SearchStatDto searchStatDto =
GlobalSearchStatisticsResponse.SearchStatDto.fromEntity(task,
violationRepository.countByFileEntityIdIn(fileIds), fileIds);
recentSearchDtos.add(searchStatDto);
}
return GlobalSearchStatisticsResponse.builder()
.totalSearches(totalSearches)
.searchesByStatus(statusCountDto)
.recentSearches(recentSearchDtos)
.build();
}
}
@@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.configuration.search.SearchProperties;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.file.YandexSearchResponse;
@@ -15,30 +16,23 @@ import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.exception.NotValidFieldException;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.*;
@Slf4j
@Service
public class SearchImageService {
private final ObjectMapper objectMapper;
private final OkHttpClient httpClient;
public SearchImageService() {
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build();
private final SearchProperties searchProperties;
public SearchImageService(SearchProperties searchProperties) {
this.searchProperties = searchProperties;
this.objectMapper = new ObjectMapper();
}
@@ -95,19 +89,41 @@ public class SearchImageService {
return mimeType != null && mimeType.startsWith("image");
}
private String callReverseImageApiByUrl(String imageUrl, String engine, String searchType) throws IOException {
private OkHttpClient createHttpClient() {
boolean useProxy = searchProperties.getProxy().isEnabled();
OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectTimeout(35, TimeUnit.SECONDS)
.writeTimeout(35, TimeUnit.SECONDS)
.readTimeout(35, TimeUnit.SECONDS)
.callTimeout(35, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.retryOnConnectionFailure(true);
if (useProxy && searchProperties.getProxy().isEnabled()) {
Proxy proxy = new Proxy(Proxy.Type.HTTP,
new InetSocketAddress(searchProperties.getProxy().getHost(),
searchProperties.getProxy().getPort()));
builder.proxy(proxy);
}
return builder.build();
}
private String callReverseImageApiByUrl(String imageUrl, String engine, String searchType) throws IOException {
if (searchApiKey == null || searchApiKey.isBlank()) {
throw new IllegalStateException("SearchAPI key not configured");
}
HttpUrl url = HttpUrl.parse("https://searchapi.io/api/v1/search")
OkHttpClient client = createHttpClient();
HttpUrl url = HttpUrl.parse("https://www.searchapi.io/api/v1/search")
.newBuilder()
.addQueryParameter("engine", engine)
.addQueryParameter("api_key", searchApiKey)
.addQueryParameter("url", imageUrl)
.addQueryParameter("search_type", searchType)
.addQueryParameter("wait", "true")
.addQueryParameter("t_", String.valueOf(System.currentTimeMillis()))
.build();
Request request = new Request.Builder()
@@ -118,16 +134,17 @@ public class SearchImageService {
long start = System.currentTimeMillis();
try (Response response = httpClient.newCall(request).execute()) {
try (Response response = client.newCall(request).execute()) {
ResponseBody body = response.body();
long duration = System.currentTimeMillis() - start;
log.info("Yandex response code={}, duration={}ms", response.code(), duration);
log.info("SearchAPI response code={}, duration={}ms, engine={}",
response.code(), duration, engine);
if (!response.isSuccessful()) {
throw new IOException("API error: " + response.code());
String errorBody = response.body() != null ? response.body().string() : "null";
throw new IOException("API error " + response.code() + ": " + errorBody);
}
ResponseBody body = response.body();
if (body == null) {
throw new IOException("Empty response body");
}
@@ -149,8 +166,13 @@ public class SearchImageService {
if ("exact_matches".equals(findType)) {
JsonNode thumbnail = match.path("thumbnail");
if (!thumbnail.isMissingNode() && thumbnail.asText().startsWith("data:image")) {
result.setUrl(thumbnail.asText());
if (!thumbnail.isMissingNode()) {
String thumbnailUrl = thumbnail.asText();
if (thumbnailUrl.startsWith("data:image")) {
result.setUrl(thumbnailUrl);
} else if (!thumbnailUrl.isBlank()) {
result.setUrl(thumbnailUrl);
}
}
JsonNode imageNode = match.path("image");
@@ -1,6 +1,8 @@
package ru.soune.nocopy.service.tariff;
import jakarta.mail.MessagingException;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.tarriff.TariffInfoDTO;
@@ -13,13 +15,16 @@ import ru.soune.nocopy.exception.TariffNotFoundException;
import ru.soune.nocopy.repository.TariffInfoRepository;
import ru.soune.nocopy.repository.TariffRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.mail.EmailService;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
@AllArgsConstructor
@Slf4j
public class TariffInfoService {
private TariffInfoRepository tariffInfoRepository;
@@ -28,6 +33,8 @@ public class TariffInfoService {
private UserRepository userRepository;
private EmailService emailService;
public TariffInfo createTariffInfo(LocalDateTime startTariff, LocalDateTime endTariff, TariffStatus tariffStatus,
Integer tokens) {
TariffInfo tariffInfo = new TariffInfo();
@@ -39,9 +46,9 @@ public class TariffInfoService {
return tariffInfoRepository.save(tariffInfo);
}
@Transactional
public void writeOffTokens(long userId, Integer value) {
// @Transactional
public void writeOffTokens(long userId, Integer value) throws MessagingException, IOException {
User user = userRepository.findById(userId).orElseThrow();
TariffInfo activeTariffInfo;
@@ -52,6 +59,10 @@ public class TariffInfoService {
activeTariffInfo = user.getCompany().getTariffInfo();
}
writeOffTokensForTariff(activeTariffInfo, value);
}
public void writeOffTokensForTariff(TariffInfo activeTariffInfo, Integer value) throws MessagingException, IOException {
Integer tokens = activeTariffInfo.getTokens();
Integer boughtTokens = activeTariffInfo.getBoughtTokens();
@@ -64,6 +75,12 @@ public class TariffInfoService {
tariffInfoRepository.save(activeTariffInfo);
} else {
int needTokens = boughtTokens - value;
User user = userRepository.findByPersonalTariffInfo(activeTariffInfo);
emailService.sendTokensNotFoundEmail(user, tokens, Math.abs(needTokens));
log.error("User:" + user.getId() + " not have tokens for protect");
throw new TariffNotFoundException("User not have tokens for protect");
}
}
@@ -71,15 +88,15 @@ public class TariffInfoService {
public TariffInfo createTariffInfo(TariffType tariffType) {
Tariff tariff = tariffRepository.findByType(tariffType.toString()).orElseThrow();
TariffInfo tariffInfo = createTariffInfo(LocalDateTime.now(), LocalDateTime.now().plusDays(30),
TariffStatus.ACTIVE, tariff.getTokens());
TariffInfo tariffInfo = createTariffInfo(LocalDateTime.now(),
LocalDateTime.now().plusDays(tariff.getTariffTerm().getDays()), TariffStatus.ACTIVE, tariff.getTokens());
tariffInfo.setTariff(tariff);
return tariffInfoRepository.save(tariffInfo);
}
public void addTariffInfo(TariffInfo tariffInfo) {
tariffInfoRepository.save(tariffInfo);
public TariffInfo addTariffInfo(TariffInfo tariffInfo) {
return tariffInfoRepository.save(tariffInfo);
}
public List<TariffInfoDTO> getAllTariffInfos() {
@@ -130,6 +147,13 @@ public class TariffInfoService {
tariffInfo.getTariff().getMaxFilesCount());
}
@Transactional
public void linkUserTariffInfo(User user, TariffInfo tariffInfo) {
user.setPersonalTariffInfo(tariffInfo);
userRepository.save(user);
}
private void updateTariffInfoFromDTO(TariffInfo tariffInfo, TariffInfoDTO dto) {
tariffInfo.setStatus(TariffStatus.valueOf(dto.getStatus()));
tariffInfo.setStartTariff(dto.getStartTariff());
@@ -7,13 +7,12 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.tarif.Tariff;
import ru.soune.nocopy.entity.tarif.TariffTimeTerm;
import ru.soune.nocopy.entity.tarif.TariffType;
import ru.soune.nocopy.repository.TariffRepository;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -109,6 +108,18 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 100);
tariff.setMaxUsers(1L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case DEMO:
tariff.setName("demo");
tariff.setPrice(0);
tariff.setTokens(30);
tariff.setMaxFilesCount(10);
tariff.setDiskSize(1024L * 1024 * 100);
tariff.setMaxUsers(1L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case START:
@@ -119,6 +130,18 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 500);
tariff.setMaxUsers(1L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case START_YEAR:
tariff.setName("start_year");
tariff.setPrice(1100);
tariff.setTokens(300);
tariff.setMaxFilesCount(80);
tariff.setDiskSize(1024L * 1024 * 500);
tariff.setMaxUsers(1L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case PRO:
@@ -129,8 +152,21 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 1024 * 10L);
tariff.setMaxUsers(1L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case PRO_YEAR:
tariff.setName("pro_year");
tariff.setPrice(4100);
tariff.setTokens(900);
tariff.setMaxFilesCount(300);
tariff.setDiskSize(1024L * 1024 * 1024 * 10L);
tariff.setMaxUsers(1L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case STUDIO:
tariff.setName("studio");
tariff.setPrice(9750);
@@ -139,6 +175,18 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(2L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case STUDIO_YEAR:
tariff.setName("studio_year");
tariff.setPrice(9500);
tariff.setTokens(3000);
tariff.setMaxFilesCount(1000);
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(2L);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case BASIC:
@@ -149,6 +197,18 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(basicUserCount);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case BASIC_YEAR:
tariff.setName("basic_year");
tariff.setPrice(basicPrice);
tariff.setTokens(basicTokens);
tariff.setMaxFilesCount(enterpriceMaxFileCount);
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(basicUserCount);
tariff.setTariffAccountType("b2c");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case ENTERPRISE:
@@ -159,6 +219,18 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 1024 * 10L);
tariff.setMaxUsers(10L);
tariff.setTariffAccountType("b2b");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case ENTERPRISE_YEAR:
tariff.setName("enterprise_year");
tariff.setPrice(200000);
tariff.setTokens(30000);
tariff.setMaxFilesCount(100000);
tariff.setDiskSize(1024L * 1024 * 1024 * 10L);
tariff.setMaxUsers(10L);
tariff.setTariffAccountType("b2b");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case BUISNES:
@@ -169,8 +241,21 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(5L);
tariff.setTariffAccountType("b2b");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case BUISNES_YEAR:
tariff.setName("buisnes_year");
tariff.setPrice(29000);
tariff.setTokens(5000);
tariff.setMaxFilesCount(5000);
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(5L);
tariff.setTariffAccountType("b2b");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case CORPORAT:
tariff.setName("corporat");
tariff.setPrice(99000);
@@ -179,6 +264,90 @@ public class TariffInitializationService {
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(10L);
tariff.setTariffAccountType("b2b");
tariff.setTariffTerm(TariffTimeTerm.MONTHLY);
break;
case CORPORAT_YEAR:
tariff.setName("corporat_year");
tariff.setPrice(99000);
tariff.setTokens(20000);
tariff.setMaxFilesCount(50000);
tariff.setDiskSize(1024L * 1024 * 1024 * 50L);
tariff.setMaxUsers(10L);
tariff.setTariffAccountType("b2b");
tariff.setTariffTerm(TariffTimeTerm.YEAR);
break;
case TOKEN_100:
tariff.setName("token_100");
tariff.setPrice(299);
tariff.setTokens(100);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("token");
break;
case TOKEN_500:
tariff.setName("token_500");
tariff.setPrice(1299);
tariff.setTokens(500);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("token");
break;
case TOKEN_1000:
tariff.setName("token_1000");
tariff.setPrice(2399);
tariff.setTokens(1000);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("token");
break;
case TOKEN_5000:
tariff.setName("token_5000");
tariff.setPrice(9999);
tariff.setTokens(5000);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("token");
break;
case MONITORING_DAILY:
tariff.setName("monitoring_daily");
tariff.setPrice(0);
tariff.setTokens(10);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("monitoring");
break;
case MONITORING_WEEKLY:
tariff.setName("monitoring_weekly");
tariff.setPrice(0);
tariff.setTokens(25);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("monitoring");
break;
case MONITORING_MONTHLY:
tariff.setName("monitoring_monthly");
tariff.setPrice(0);
tariff.setTokens(50);
tariff.setMaxFilesCount(0);
tariff.setDiskSize(0L);
tariff.setMaxUsers(0L);
tariff.setTariffAccountType("monitoring");
break;
}
@@ -5,6 +5,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.tarriff.TariffDTO;
import ru.soune.nocopy.entity.tarif.Tariff;
import ru.soune.nocopy.entity.tarif.TariffTimeTerm;
import ru.soune.nocopy.entity.tarif.TariffType;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.TariffNotFoundException;
@@ -20,7 +21,7 @@ public class TariffService {
private final TariffRepository tariffRepository;
public void addTariff(String tariffName, double price, String type, int tokens, long diskSize, long maxUsers,
int maxFilesCount) {
int maxFilesCount, String tariffTerm) {
Tariff tariff = new Tariff();
tariff.setName(tariffName);
tariff.setPrice(price);
@@ -29,6 +30,7 @@ public class TariffService {
tariff.setDiskSize(diskSize);
tariff.setMaxUsers(maxUsers);
tariff.setMaxFilesCount(maxFilesCount);
tariff.setTariffTerm(TariffTimeTerm.valueOf(tariffTerm.toUpperCase()));
tariffRepository.save(tariff);
}
@@ -42,6 +44,22 @@ public class TariffService {
.collect(Collectors.toList());
}
public List<TariffDTO> getTariffByAccountTypeAndTariffTerm(User user, TariffTimeTerm tariffTerm) {
String typeAccount = user.canManageCompanySettings() ? "b2b": "b2c";
return tariffRepository.findByTariffAccountTypeAndTariffTerm(typeAccount, tariffTerm)
.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
public List<TariffDTO> getTariffByType(String type) {
return tariffRepository.findByTariffAccountType(type)
.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
public List<TariffDTO> getAllTariffs() {
return tariffRepository.findAllByOrderByPriceAsc()
.stream()
@@ -84,6 +102,8 @@ public class TariffService {
}
private TariffDTO convertToDTO(Tariff tariff) {
TariffTimeTerm tariffTerm = tariff.getTariffTerm();
return new TariffDTO(
tariff.getId(),
tariff.getType(),
@@ -92,7 +112,8 @@ public class TariffService {
tariff.getTokens(),
tariff.getMaxFilesCount(),
tariff.getDiskSize(),
tariff.getMaxUsers()
tariff.getMaxUsers(),
tariffTerm == null ? "" : tariffTerm.name()
);
}
@@ -104,5 +125,6 @@ public class TariffService {
tariff.setMaxFilesCount(dto.getMaxFilesCount());
tariff.setDiskSize(dto.getDiskSize());
tariff.setMaxUsers(dto.getMaxUsers());
tariff.setTariffTerm(TariffTimeTerm.valueOf(dto.getTariffTerm().toUpperCase()));
}
}
@@ -0,0 +1,56 @@
package ru.soune.nocopy.service.user;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.user.UserAdditionalInfo;
import ru.soune.nocopy.exception.RegistrationAdditionalInfoNullException;
import ru.soune.nocopy.repository.UserAdditionalInfoRepository;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AdditionalInfoService {
private final UserAdditionalInfoRepository additionalInfoRepository;
@Transactional
public void additionalInfo(String ip, String userAgent, User user) {
if (ip == null || userAgent == null) {
throw new RegistrationAdditionalInfoNullException("Ip address or user agent is null");
}
UserAdditionalInfo userAdditionalInfo = UserAdditionalInfo.builder()
.ipAddress(ip)
.userAgent(userAgent)
.user(user)
.build();
additionalInfoRepository.save(userAdditionalInfo);
}
public UserAdditionalInfo getAdditionalInfoByUser(Long userId) {
return additionalInfoRepository.findById(userId).orElse(null);
}
public List<UserAdditionalInfo> getAllAdditionalInfos() {
return additionalInfoRepository.findAll();
}
public void deleteAdditionalInfo(Long userId) {
additionalInfoRepository.deleteById(userId);
}
@Transactional
public Optional<UserAdditionalInfo> updateAdditionalInfo(Long userId, String ip, String userAgent) {
return additionalInfoRepository.findById(userId)
.map(info -> {
info.setIpAddress(ip);
info.setUserAgent(userAgent);
return additionalInfoRepository.save(info);
});
}
}
@@ -0,0 +1,127 @@
package ru.soune.nocopy.service.violation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.violation.ViolationNotionResponse;
import ru.soune.nocopy.dto.violation.ViolationNotionsListResponse;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.entity.violation.Violation;
import ru.soune.nocopy.entity.violation.ViolationNotion;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.repository.ViolationNotionRepository;
import ru.soune.nocopy.repository.ViolationRepository;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ViolationNotionService {
private final ViolationNotionRepository notionRepository;
private final UserRepository userRepository;
private final ViolationRepository violationRepository;
@Transactional(readOnly = true)
public ViolationNotionsListResponse getNotionsByViolationId(Long violationId, Long userId) {
Violation violation = violationRepository.findById(violationId)
.orElseThrow(() -> new IllegalArgumentException("Violation not found: " + violationId));
List<ViolationNotion> notions = notionRepository.findByViolationId(violationId);
long totalCount = notionRepository.countByViolationId(violationId);
List<ViolationNotionResponse> notionResponses = notions.stream()
.map(this::mapToResponse)
.collect(Collectors.toList());
return ViolationNotionsListResponse.builder()
.violationId(violationId)
.url(violation.getUrl())
.host(violation.getHost())
.totalCount((int) totalCount)
.notions(notionResponses)
.build();
}
@Transactional(readOnly = true)
public ViolationNotionResponse getNotionById(Long notionId, Long userId) {
ViolationNotion notion = notionRepository.findById(notionId)
.orElseThrow(() -> new IllegalArgumentException("Notion not found: " + notionId));
if (!notion.getUser().getId().equals(userId)) {
throw new SecurityException("User does not have access to this notion");
}
return mapToResponse(notion);
}
@Transactional
public ViolationNotionResponse createNotion(Long violationId, Long userId, String message) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
Violation violation = violationRepository.findById(violationId)
.orElseThrow(() -> new IllegalArgumentException("Violation not found: " + violationId));
ViolationNotion notion = new ViolationNotion();
notion.setUser(user);
notion.setViolation(violation);
notion.setMessage(message);
ViolationNotion saved = notionRepository.save(notion);
log.info("Created notion for violation: {} by user: {}", violationId, userId);
return mapToResponse(saved);
}
@Transactional
public ViolationNotionResponse updateNotion(Long notionId, Long userId, String message) {
ViolationNotion notion = notionRepository.findById(notionId)
.orElseThrow(() -> new IllegalArgumentException("Notion not found: " + notionId));
if (!notion.getUser().getId().equals(userId)) {
throw new SecurityException("Only author can update this notion");
}
notion.setMessage(message);
ViolationNotion updated = notionRepository.save(notion);
log.info("Updated notion: {} by user: {}", notionId, userId);
return mapToResponse(updated);
}
@Transactional
public void deleteNotion(Long notionId, Long userId) {
ViolationNotion notion = notionRepository.findById(notionId)
.orElseThrow(() -> new IllegalArgumentException("Notion not found: " + notionId));
if (!notion.getUser().getId().equals(userId)) {
throw new SecurityException("Only author can delete this notion");
}
notionRepository.delete(notion);
log.info("Deleted notion: {} by user: {}", notionId, userId);
}
private ViolationNotionResponse mapToResponse(ViolationNotion notion) {
return ViolationNotionResponse.builder()
.id(notion.getId())
.message(notion.getMessage())
.createdAt(notion.getCreatedAt())
.updatedAt(notion.getUpdatedAt())
.user(ViolationNotionResponse.UserInfo.builder()
.id(notion.getUser().getId())
.fullName(notion.getUser().getFullName())
.email(notion.getUser().getEmail())
.build())
.violation(ViolationNotionResponse.ViolationInfo.builder()
.id(notion.getViolation().getId())
.url(notion.getViolation().getPageUrl())
.host(notion.getViolation().getHost())
.build())
.build();
}
}
@@ -0,0 +1,232 @@
package ru.soune.nocopy.service.violation;
import lombok.AllArgsConstructor;
import org.apache.commons.codec.digest.DigestUtils;
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.YandexSearchResponse;
import ru.soune.nocopy.dto.violation.FileViolationSummaryDTO;
import ru.soune.nocopy.dto.violation.ViolationStatisticsResponse;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.search.GlobalSearchResult;
import ru.soune.nocopy.entity.violation.Violation;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.ViolationRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
@AllArgsConstructor
public class ViolationService {
private final ViolationRepository violationRepository;
private final FileEntityRepository fileEntityRepository;
private final FileEntityService fileEntityService;
public void processViolation(YandexSearchResponse.ImageResult imageResult, FileEntity file,
GlobalSearchResult result) {
String url = imageResult.getUrl();
String urlHash = DigestUtils.sha256Hex(url);
if (!violationRepository.existsByUrlHash(urlHash)) {
Violation violation = new Violation();
if (result != null) {
violation.setGlobalSearchResult(result);
}
violation.setHost(imageResult.getHost());
violation.setUrl(url);
violation.setUrlHash(urlHash);
violation.setPageUrl(imageResult.getPageUrl());
violation.setPageTitle(imageResult.getPageTitle());
violation.setFileEntity(file);
violation.setStatus(ViolationStatus.NEW.name());
violationRepository.save(violation);
}
}
public List<FileViolationSummaryDTO> getFileViolationsSummary(Long userId) {
List<FileEntity> userFiles = fileEntityService.getAllUserFiles(userId);
List<Violation> violations = violationRepository.findByFileEntityIn(userFiles);
Map<String, List<Violation>> violationsByFileId = violations.stream()
.collect(Collectors.groupingBy(v -> v.getFileEntity().getId()));
return userFiles.stream()
.map(file -> {
List<Violation> fileViolations = violationsByFileId.getOrDefault(file.getId(),
Collections.emptyList());
if (fileViolations.isEmpty()) {
return null;
}
LocalDateTime latestDate = fileViolations.stream()
.map(Violation::getCreatedDate)
.max(LocalDateTime::compareTo)
.orElse(null);
return FileViolationSummaryDTO.builder()
.fileId(file.getId())
.supportId(file.getSupportId())
.fileName(file.getOriginalFileName())
.fileSize(file.getFileSize())
.mimeType(file.getMimeType())
.status(file.getStatus())
.createdAt(file.getCreatedAt())
.violationCount(fileViolations.size())
.latestViolationDate(latestDate)
.build();
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
@Transactional
public void changeStatus(ViolationStatus status, Long violationId) {
Violation violation = violationRepository.findById(violationId).orElseThrow();
violation.setStatus(status.name());
violationRepository.save(violation);
}
public Page<Violation> getViolationsByFiles(List<FileEntity> files, int page, int size, String sortDirection) {
Sort sort = Sort.by(sortDirection.equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC, "createdDate");
Pageable pageable = PageRequest.of(page, size, sort);
return violationRepository.findByFileEntityIn(files, pageable);
}
public Page<Violation> getViolationsByFilesAndStatus(List<FileEntity> files, String status, int page, int size, String sortDirection) {
Sort sort = Sort.by(sortDirection.equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC, "createdDate");
Pageable pageable = PageRequest.of(page, size, sort);
return violationRepository.findByFileEntityInAndStatus(files, status, pageable);
}
public Page<Violation> getViolationsByFilesAndDateRange(List<FileEntity> files, LocalDateTime startDate, LocalDateTime endDate,
int page, int size, String sortDirection) {
Sort sort = Sort.by(sortDirection.equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC, "createdDate");
Pageable pageable = PageRequest.of(page, size, sort);
return violationRepository.findByFileEntityInAndCreatedDateBetween(files, startDate, endDate, pageable);
}
public Map<String, Long> getGroupedViolations(List<FileEntity> files, String groupBy, String status,
LocalDateTime startDate, LocalDateTime endDate,
String sortDirection) {
List<Violation> violations;
if (status != null && !status.isEmpty() && startDate != null && endDate != null) {
violations = violationRepository.findByFileEntityInAndStatusAndCreatedDateBetween(files, status, startDate, endDate);
} else if (status != null && !status.isEmpty()) {
violations = violationRepository.findByFileEntityInAndStatus(files, status);
} else if (startDate != null && endDate != null) {
violations = violationRepository.findByFileEntityInAndCreatedDateBetween(files, startDate, endDate);
} else {
violations = violationRepository.findByFileEntityIn(files);
}
Map<String, Long> grouped = violations.stream()
.collect(Collectors.groupingBy(
v -> extractGroupKey(v.getPageUrl(), groupBy),
Collectors.counting()));
return grouped.entrySet().stream()
.sorted((e1, e2) -> {
if (sortDirection.equalsIgnoreCase("desc")) {
return e2.getValue().compareTo(e1.getValue());
} else {
return e1.getValue().compareTo(e2.getValue());
}
})
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(e1, e2) -> e1,
LinkedHashMap::new));
}
public ViolationStatisticsResponse getViolationStatistics(Long userId, String fileId,
LocalDateTime startDate, LocalDateTime endDate) {
List<FileEntity> userFiles;
if (fileId != null && !fileId.isEmpty()) {
FileEntity file = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new IllegalArgumentException("File not found with id: " + fileId));
if (!file.getUserId().equals(userId)) {
throw new IllegalArgumentException("File does not belong to user");
}
userFiles = List.of(file);
} else {
userFiles = fileEntityService.getAllUserFiles(userId);
}
if (userFiles.isEmpty()) {
return ViolationStatisticsResponse.builder()
.totalViolations(0)
.newViolations(0)
.inProgressViolations(0)
.resolvedViolations(0)
.build();
}
List<Violation> violations;
if (startDate != null && endDate != null) {
violations = violationRepository.findByFileEntityInAndCreatedDateBetween(userFiles, startDate, endDate);
} else {
violations = violationRepository.findByFileEntityIn(userFiles);
}
Set<String> inProgressStatuses = Set.of(
ViolationStatus.SHOWED.name(),
ViolationStatus.LEGAL_IN_WORK.name(),
ViolationStatus.COMPLAINT_IN_WORK.name(),
ViolationStatus.COMPLAINT_AND_LEGAL_IN_WORK.name()
);
long newViolations = violations.stream()
.filter(v -> v.getStatus().equals(ViolationStatus.NEW.name()))
.count();
long inProgressViolations = violations.stream()
.filter(v -> inProgressStatuses.contains(v.getStatus()))
.count();
long resolvedViolations = violations.stream()
.filter(v -> v.getStatus().equals(ViolationStatus.AUTHORIZED_USE.name()))
.count();
return ViolationStatisticsResponse.builder()
.totalViolations(violations.size())
.newViolations(newViolations)
.inProgressViolations(inProgressViolations)
.resolvedViolations(resolvedViolations)
.build();
}
private String extractGroupKey(String url, String groupBy) {
try {
String domain = url.replaceAll("https?://(www\\.)?", "").split("/")[0];
if ("tld".equalsIgnoreCase(groupBy)) {
int lastDot = domain.lastIndexOf('.');
return lastDot > 0 ? domain.substring(lastDot + 1) : domain;
}
return domain;
} catch (Exception e) {
return "unknown";
}
}
}
@@ -0,0 +1,5 @@
package ru.soune.nocopy.service.violation;
public enum ViolationStatus {
NEW , SHOWED, LEGAL_IN_WORK, COMPLAINT_IN_WORK, COMPLAINT_AND_LEGAL_IN_WORK , AUTHORIZED_USE
}