dev add check limits logic
Test Workflow / test (push) Successful in 3s

This commit is contained in:
vladp
2026-01-28 23:25:16 +07:00
parent 31fac0ab93
commit ec20d68261
12 changed files with 283 additions and 16 deletions
+14
View File
@@ -76,3 +76,17 @@ ALTER TABLE file_entities
ALTER COLUMN support_id ALTER COLUMN support_id
SET DEFAULT nextval('file_support_id_seq'); 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);
@@ -6,13 +6,15 @@ import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable; import ru.soune.nocopy.dto.file.CheckStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.ProtectionStatus; 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.FileEntityRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.file.CheckCounterService;
import ru.soune.nocopy.service.file.FileStorageService; import ru.soune.nocopy.service.file.FileStorageService;
import java.io.IOException; import java.io.IOException;
@@ -28,6 +30,12 @@ public class FileController {
@Autowired @Autowired
private FileEntityRepository fileRepository; private FileEntityRepository fileRepository;
@Autowired
private CheckCounterService checkCounterService;
@Autowired
private UserRepository userRepository;
@GetMapping("/public/{fileId}") @GetMapping("/public/{fileId}")
public ResponseEntity<Resource> getPublicFile( public ResponseEntity<Resource> getPublicFile(
@PathVariable String fileId) throws IOException { @PathVariable String fileId) throws IOException {
@@ -47,4 +55,21 @@ public class FileController {
"inline; filename=\"" + fileEntity.getOriginalFileName() + "\"") "inline; filename=\"" + fileEntity.getOriginalFileName() + "\"")
.body(resource); .body(resource);
} }
@GetMapping("check-file/status/{userId}")
public ResponseEntity<CheckStatus> getStatus(@PathVariable Long userId) {
CheckStatus status = checkCounterService.getCurrentStatus(userId);
return ResponseEntity.ok(status);
}
@PostMapping("check-file/update-limit/{userId}/{limit}")
public ResponseEntity<ProtectedFileCheck> getStatus(@PathVariable Long userId, @PathVariable Integer limit) {
User user = userRepository.findById(userId).orElseThrow();
ProtectedFileCheck protectedFileCheck = checkCounterService.updateLimit(user, limit);
return ResponseEntity
.ok()
.body(protectedFileCheck);
}
} }
@@ -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();
}
}
@@ -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;
}
@@ -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;
}
}
@@ -83,4 +83,9 @@ public class User {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore @JsonIgnore
@ToString.Exclude @ToString.Exclude
private List<Violation> violations = new ArrayList<>();} private List<Violation> violations = new ArrayList<>();
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
private ProtectedFileCheck protectedFileCheck;
}
@@ -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);
}
}
@@ -9,9 +9,15 @@ import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode; import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.file.ImageSearchRequest; import ru.soune.nocopy.dto.file.ImageSearchRequest;
import ru.soune.nocopy.dto.file.YandexSearchResponse; 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.YandexSearchService;
import ru.soune.nocopy.service.search.GoogleVisionSearchService; import ru.soune.nocopy.service.search.GoogleVisionSearchService;
import java.util.Map;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -23,6 +29,10 @@ public class ImageFoundRequestHandler implements RequestHandler {
private final GoogleVisionSearchService googleVisionSearchService; private final GoogleVisionSearchService googleVisionSearchService;
private final CheckCounterService checkCounterService;
private final FileEntityRepository fileEntityRepository;
@Override @Override
public BaseResponse handle(BaseRequest request) throws Exception { public BaseResponse handle(BaseRequest request) throws Exception {
ImageSearchRequest imageSearchRequest = objectMapper.convertValue(request.getMessageBody(), ImageSearchRequest imageSearchRequest = objectMapper.convertValue(request.getMessageBody(),
@@ -30,11 +40,20 @@ public class ImageFoundRequestHandler implements RequestHandler {
String fileId = imageSearchRequest.getFileId(); 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 //TODO uncommited when add billing
// GoogleVisionSearchResponse googleVisionSearchResponse = googleVisionSearchService.searchByFileEntity(fileId); // GoogleVisionSearchResponse googleVisionSearchResponse = googleVisionSearchService.searchByFileEntity(fileId);
checkCounterService.incrementCheckCount(fileEntity.getUserId());
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), response); MessageCode.SUCCESS.getDescription(), response);
} }
@@ -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<ProtectedFileCheck, Long> {
Optional<ProtectedFileCheck> findByUser_Id(Long userId);
boolean existsByUser_Id(Long userId);
}
@@ -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);
}
}
@@ -1,7 +1,6 @@
package ru.soune.nocopy.service.register; package ru.soune.nocopy.service.register;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.exception.NotValidFieldException;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository; import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.file.CheckCounterService;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -30,7 +30,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final MessageSource messageSource; private final CheckCounterService checkCounterService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@@ -55,6 +55,8 @@ public class AuthService {
AuthToken authToken = genereateAuthToken(savedUser); AuthToken authToken = genereateAuthToken(savedUser);
checkCounterService.initProtectCheckLimit(user, 0, 10);
return authTokenRepository.save(authToken); return authTokenRepository.save(authToken);
} }
@@ -46,13 +46,7 @@ public class YandexSearchService {
objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
} }
public YandexSearchResponse searchByFileEntity(String fileId) throws IOException { public YandexSearchResponse searchByFileEntity(FileEntity fileEntity) 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)));
});
byte[] fileBytes; byte[] fileBytes;
if (!isImageFile(fileEntity)) { if (!isImageFile(fileEntity)) {
@@ -67,7 +61,7 @@ public class YandexSearchService {
} catch (IOException e) { } catch (IOException e) {
throw new NotValidFieldException("File not found or cannot read file", new BaseResponse(20007, throw new NotValidFieldException("File not found or cannot read file", new BaseResponse(20007,
MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(),
Map.of("fileId", fileId, Map.of("fileId", fileEntity.getId(),
"filePath", fileEntity.getFilePath()))); "filePath", fileEntity.getFilePath())));
} }