diff --git a/README.md b/README.md index aa5e26c..d4c144e 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,17 @@ ALTER TABLE file_entities ALTER COLUMN support_id SET DEFAULT nextval('file_support_id_seq'); +---------- +Раздать всем лимиты,у кого их нет + +INSERT INTO protect_check (user_id, check_limit, count_checked, last_check_at, version) +SELECT +id as user_id, +10 as check_limit, +0 as count_checked, +NULL as last_check_at, +0 as version +FROM users +WHERE id NOT IN (SELECT user_id FROM protect_check); + + diff --git a/src/main/java/ru/soune/nocopy/controller/FileController.java b/src/main/java/ru/soune/nocopy/controller/FileController.java index 7812198..772f9c4 100644 --- a/src/main/java/ru/soune/nocopy/controller/FileController.java +++ b/src/main/java/ru/soune/nocopy/controller/FileController.java @@ -6,13 +6,15 @@ import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import ru.soune.nocopy.dto.file.CheckStatus; import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.ProtectionStatus; +import ru.soune.nocopy.entity.user.ProtectedFileCheck; +import ru.soune.nocopy.entity.user.User; import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.service.file.CheckCounterService; import ru.soune.nocopy.service.file.FileStorageService; import java.io.IOException; @@ -28,6 +30,12 @@ public class FileController { @Autowired private FileEntityRepository fileRepository; + @Autowired + private CheckCounterService checkCounterService; + + @Autowired + private UserRepository userRepository; + @GetMapping("/public/{fileId}") public ResponseEntity getPublicFile( @PathVariable String fileId) throws IOException { @@ -47,4 +55,21 @@ public class FileController { "inline; filename=\"" + fileEntity.getOriginalFileName() + "\"") .body(resource); } + + @GetMapping("check-file/status/{userId}") + public ResponseEntity getStatus(@PathVariable Long userId) { + CheckStatus status = checkCounterService.getCurrentStatus(userId); + + return ResponseEntity.ok(status); + } + + @PostMapping("check-file/update-limit/{userId}/{limit}") + public ResponseEntity getStatus(@PathVariable Long userId, @PathVariable Integer limit) { + User user = userRepository.findById(userId).orElseThrow(); + ProtectedFileCheck protectedFileCheck = checkCounterService.updateLimit(user, limit); + + return ResponseEntity + .ok() + .body(protectedFileCheck); + } } diff --git a/src/main/java/ru/soune/nocopy/dto/file/CheckIncrementResult.java b/src/main/java/ru/soune/nocopy/dto/file/CheckIncrementResult.java new file mode 100644 index 0000000..a0d9149 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/CheckIncrementResult.java @@ -0,0 +1,35 @@ +package ru.soune.nocopy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CheckIncrementResult { + private boolean success; + private Integer countChecked; + private Integer remainingLimit; + private String message; + + public static CheckIncrementResult success(Integer countChecked, Integer remainingLimit) { + return CheckIncrementResult.builder() + .success(true) + .countChecked(countChecked) + .remainingLimit(remainingLimit) + .message("Check count incremented successfully") + .build(); + } + + public static CheckIncrementResult limitExceeded(Integer remainingLimit) { + return CheckIncrementResult.builder() + .success(false) + .countChecked(null) + .remainingLimit(remainingLimit) + .message("Check limit exceeded") + .build(); + } +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/CheckStatus.java b/src/main/java/ru/soune/nocopy/dto/file/CheckStatus.java new file mode 100644 index 0000000..d0ef230 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/CheckStatus.java @@ -0,0 +1,15 @@ +package ru.soune.nocopy.dto.file; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class CheckStatus { + private Long userId; + private Integer countChecked; + private Integer remainingLimit; + private LocalDateTime lastCheckAt; +} diff --git a/src/main/java/ru/soune/nocopy/entity/user/ProtectedFileCheck.java b/src/main/java/ru/soune/nocopy/entity/user/ProtectedFileCheck.java new file mode 100644 index 0000000..b4c20cf --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/user/ProtectedFileCheck.java @@ -0,0 +1,53 @@ +package ru.soune.nocopy.entity.user; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "protect_check") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class ProtectedFileCheck { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", unique = true, nullable = false) + private User user; + + @Column(nullable = false) + @ColumnDefault(value = "10") + @Builder.Default + private Integer limitCheck = 10; + + @Column(nullable = false) + @Builder.Default + private Integer countChecked = 0; + + @Column(name = "last_check_at") + private LocalDateTime lastCheckAt; + + @Version + private Long version; + + public boolean incrementCheck() { + if (this.limitCheck <= 0) { + return false; + } + + this.countChecked++; + this.limitCheck--; + this.lastCheckAt = LocalDateTime.now(); + return true; + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/entity/user/User.java b/src/main/java/ru/soune/nocopy/entity/user/User.java index 6346c0d..335f5f6 100644 --- a/src/main/java/ru/soune/nocopy/entity/user/User.java +++ b/src/main/java/ru/soune/nocopy/entity/user/User.java @@ -83,4 +83,9 @@ public class User { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore @ToString.Exclude - private List violations = new ArrayList<>();} + private List violations = new ArrayList<>(); + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore + private ProtectedFileCheck protectedFileCheck; +} diff --git a/src/main/java/ru/soune/nocopy/exception/RetryableException.java b/src/main/java/ru/soune/nocopy/exception/RetryableException.java new file mode 100644 index 0000000..3ea138d --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/RetryableException.java @@ -0,0 +1,11 @@ +package ru.soune.nocopy.exception; + +public class RetryableException extends RuntimeException { + public RetryableException(String message) { + super(message); + } + + public RetryableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java b/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java index e82ca62..5ef7dd9 100644 --- a/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java @@ -9,9 +9,15 @@ import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.MessageCode; import ru.soune.nocopy.dto.file.ImageSearchRequest; import ru.soune.nocopy.dto.file.YandexSearchResponse; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.exception.NotValidFieldException; +import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.service.file.CheckCounterService; import ru.soune.nocopy.service.search.YandexSearchService; import ru.soune.nocopy.service.search.GoogleVisionSearchService; +import java.util.Map; + @Slf4j @Component @RequiredArgsConstructor @@ -23,6 +29,10 @@ public class ImageFoundRequestHandler implements RequestHandler { private final GoogleVisionSearchService googleVisionSearchService; + private final CheckCounterService checkCounterService; + + private final FileEntityRepository fileEntityRepository; + @Override public BaseResponse handle(BaseRequest request) throws Exception { ImageSearchRequest imageSearchRequest = objectMapper.convertValue(request.getMessageBody(), @@ -30,11 +40,20 @@ public class ImageFoundRequestHandler implements RequestHandler { String fileId = imageSearchRequest.getFileId(); - YandexSearchResponse response = yandexSearchService.searchByFileEntity(fileId); + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> { + throw new NotValidFieldException("File not found", new BaseResponse(20007, + MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), + Map.of("fileId",fileId))); + }); + + YandexSearchResponse response = yandexSearchService.searchByFileEntity(fileEntity); //TODO uncommited when add billing // GoogleVisionSearchResponse googleVisionSearchResponse = googleVisionSearchService.searchByFileEntity(fileId); + checkCounterService.incrementCheckCount(fileEntity.getUserId()); + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), response); } diff --git a/src/main/java/ru/soune/nocopy/repository/ProtectedFileCheckRepository.java b/src/main/java/ru/soune/nocopy/repository/ProtectedFileCheckRepository.java new file mode 100644 index 0000000..5c4eda9 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/ProtectedFileCheckRepository.java @@ -0,0 +1,15 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.soune.nocopy.entity.user.ProtectedFileCheck; + +import java.util.Optional; + +@Repository +public interface ProtectedFileCheckRepository extends JpaRepository { + + Optional findByUser_Id(Long userId); + + boolean existsByUser_Id(Long userId); +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/service/file/CheckCounterService.java b/src/main/java/ru/soune/nocopy/service/file/CheckCounterService.java new file mode 100644 index 0000000..87f2ec5 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/CheckCounterService.java @@ -0,0 +1,79 @@ +package ru.soune.nocopy.service.file; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.dto.file.CheckIncrementResult; +import ru.soune.nocopy.dto.file.CheckStatus; +import ru.soune.nocopy.entity.user.ProtectedFileCheck; +import ru.soune.nocopy.entity.user.User; +import ru.soune.nocopy.exception.RetryableException; +import ru.soune.nocopy.repository.ProtectedFileCheckRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CheckCounterService { + + private final ProtectedFileCheckRepository protectedFileCheckRepository; + + @Transactional + public void incrementCheckCount(Long userId) { + try { + ProtectedFileCheck check = protectedFileCheckRepository.findByUser_Id(userId) + .orElseThrow(NullPointerException::new); + + boolean success = check.incrementCheck(); + + if (success) { + protectedFileCheckRepository.save(check); + log.info("Check count incremented for user {}: count={}, limit={}", + userId, check.getCountChecked(), check.getLimitCheck()); + CheckIncrementResult.success(check.getCountChecked(), check.getLimitCheck()); + } else { + log.warn("Check limit exceeded for user {}", userId); + CheckIncrementResult.limitExceeded(check.getLimitCheck()); + } + + } catch (ObjectOptimisticLockingFailureException e) { + log.warn("Optimistic lock failure for user {}, retrying...", userId); + throw new RetryableException("Please try again"); + } + } + + @Transactional(readOnly = true) + public CheckStatus getCurrentStatus(Long userId) { + ProtectedFileCheck check = protectedFileCheckRepository.findByUser_Id(userId) + .orElseThrow(NullPointerException::new); + + return CheckStatus.builder() + .userId(userId) + .countChecked(check.getCountChecked()) + .remainingLimit(check.getLimitCheck()) + .lastCheckAt(check.getLastCheckAt()) + .build(); + } + + + public void initProtectCheckLimit(User user, Integer checked, Integer limit) { + ProtectedFileCheck check = ProtectedFileCheck.builder() + .user(user) + .limitCheck(limit) + .countChecked(checked) + .build(); + + protectedFileCheckRepository.save(check); + } + + @Transactional + public ProtectedFileCheck updateLimit(User user, Integer limit) { + ProtectedFileCheck check = ProtectedFileCheck.builder() + .user(user) + .limitCheck(limit) + .build(); + + return protectedFileCheckRepository.save(check); + } +} diff --git a/src/main/java/ru/soune/nocopy/service/register/AuthService.java b/src/main/java/ru/soune/nocopy/service/register/AuthService.java index 379636b..c5272c8 100644 --- a/src/main/java/ru/soune/nocopy/service/register/AuthService.java +++ b/src/main/java/ru/soune/nocopy/service/register/AuthService.java @@ -1,7 +1,6 @@ package ru.soune.nocopy.service.register; import lombok.RequiredArgsConstructor; -import org.springframework.context.MessageSource; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +14,7 @@ import ru.soune.nocopy.exception.NotFoundAuthToken; import ru.soune.nocopy.exception.NotValidFieldException; import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.UserRepository; +import ru.soune.nocopy.service.file.CheckCounterService; import java.security.SecureRandom; import java.time.LocalDateTime; @@ -30,7 +30,7 @@ public class AuthService { private final PasswordEncoder passwordEncoder; - private final MessageSource messageSource; + private final CheckCounterService checkCounterService; private final SecureRandom secureRandom = new SecureRandom(); @@ -55,6 +55,8 @@ public class AuthService { AuthToken authToken = genereateAuthToken(savedUser); + checkCounterService.initProtectCheckLimit(user, 0, 10); + return authTokenRepository.save(authToken); } diff --git a/src/main/java/ru/soune/nocopy/service/search/YandexSearchService.java b/src/main/java/ru/soune/nocopy/service/search/YandexSearchService.java index d22bbc9..76497b1 100644 --- a/src/main/java/ru/soune/nocopy/service/search/YandexSearchService.java +++ b/src/main/java/ru/soune/nocopy/service/search/YandexSearchService.java @@ -46,13 +46,7 @@ public class YandexSearchService { objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); } - public YandexSearchResponse searchByFileEntity(String fileId) throws IOException { - FileEntity fileEntity = fileEntityRepository.findById(fileId) - .orElseThrow(() -> { - throw new NotValidFieldException("File not found", new BaseResponse(20007, - MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), - Map.of("fileId",fileId))); - }); + public YandexSearchResponse searchByFileEntity(FileEntity fileEntity) throws IOException { byte[] fileBytes; if (!isImageFile(fileEntity)) { @@ -67,7 +61,7 @@ public class YandexSearchService { } catch (IOException e) { throw new NotValidFieldException("File not found or cannot read file", new BaseResponse(20007, MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), - Map.of("fileId", fileId, + Map.of("fileId", fileEntity.getId(), "filePath", fileEntity.getFilePath()))); }