17 Commits
Author SHA1 Message Date
vladp 3d679440f6 NCBACK-34 add logic for clear does not exist files,add dto for similar file search controller
Test Workflow / test (push) Successful in 4s
2026-01-16 14:30:45 +07:00
vladp 4a45f40e9f NCBACK-34 need fix delete filentity and hash
Test Workflow / test (push) Successful in 4s
2026-01-15 20:41:35 +07:00
vladp bbb0d7b17a Merge branch 'dev' into NCBACK-34 2026-01-15 16:55:47 +07:00
vladp 0ed3f958ff DEV_DB change db creds
Test Workflow / test (push) Successful in 7s
2026-01-15 16:52:33 +07:00
vladp de810e32b4 DEV_DB change db creds
Test Workflow / test (push) Successful in 4s
2026-01-15 16:42:37 +07:00
vladp 1a6823f7a9 DEV_DB change db creds
Test Workflow / test (push) Successful in 4s
2026-01-15 16:17:03 +07:00
vladp 82a57a2983 NCBACK-32 add all case for json
Test Workflow / test (push) Successful in 4s
2026-01-15 11:09:05 +07:00
vladp d3e50aa8d1 NCBACK-19 add all case for json
Test Workflow / test (push) Successful in 3s
2026-01-14 19:10:22 +07:00
vladp 5c6309e228 NCBACK-33 fix check company name
Test Workflow / test (push) Successful in 3s
2026-01-14 18:47:34 +07:00
vladp 8ac73ff045 NCBACK-34 add logic to find similar image in db
Test Workflow / test (push) Successful in 3s
2026-01-13 22:40:52 +07:00
vladp 344ec075d4 Dev
Test Workflow / test (push) Successful in 2s
2026-01-10 01:19:30 +07:00
vladp 7b8546fd4d Dev
Test Workflow / test (push) Successful in 2s
2026-01-10 01:06:32 +07:00
vladp eb12e219ec Dev
Test Workflow / test (push) Successful in 2s
2026-01-10 00:54:49 +07:00
vladp 920cde3cdf Dev 2026-01-10 00:51:03 +07:00
backdev 5b4dca23fb Merge pull request 'NCBACK-3' (#14) from NCBACK-3 into dev
Test Workflow / test (push) Successful in 3s
Reviewed-on: #14
2026-01-10 01:43:36 +08:00
backdev 8102d72982 Merge pull request 'NCBACK-3 Add found by yandex image' (#10) from NCBACK-3 into dev
Test Workflow / test (push) Successful in 10s
Reviewed-on: #10
2026-01-10 01:03:33 +08:00
vladp d896f23872 add user_files_info for avaibale actions
Test Workflow / test (push) Successful in 2s
2026-01-09 23:05:37 +07:00
38 changed files with 806 additions and 327 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
POSTGRES_DB=no_copy_ POSTGRES_DB=no_copy_
POSTGRES_USER=postgres POSTGRES_USER=ncp_db
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=ncpDbApp
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
SERVER_PORT=8080 SERVER_PORT=8080
+1 -1
View File
@@ -16,4 +16,4 @@ RUN mkdir -p /data/uploads && chmod 755 /data/uploads
EXPOSE 8080 EXPOSE 8080
CMD ["java", "-jar", "app.jar"] CMD ["java", "-jar", "app.jar"]
+48
View File
@@ -0,0 +1,48 @@
суперпользователь БД:
postgres/postgres
пользователь бд для бэка
ncp_db / ncpDbApp
Подключение к БД для приложения,если volume не существует :
Создаться автоматически.
Если уже существует volume :
docker exec -it postgres psql -U postgres -d postgres
CREATE USER ncp_db WITH PASSWORD 'ncpDbApp';
GRANT ALL PRIVILEGES ON DATABASE no_copy_ TO ncp_db;
\du
\q
-------
psql -U postgres -d no_copy_
-- база
GRANT CONNECT ON DATABASE no_copy_ TO ncp_db;
-- схема
GRANT USAGE ON SCHEMA public TO ncp_db;
-- существующие таблицы
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ncp_db;
-- существующие sequence (очень важно для id)
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ncp_db;
-- будущие таблицы
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON TABLES TO ncp_db;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON SEQUENCES TO ncp_db;
+2
View File
@@ -31,6 +31,8 @@ dependencies {
implementation 'org.mapstruct:mapstruct:1.5.5.Final' implementation 'org.mapstruct:mapstruct:1.5.5.Final'
implementation 'commons-validator:commons-validator:1.7' implementation 'commons-validator:commons-validator:1.7'
implementation 'org.flywaydb:flyway-core:9.22.0'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.0' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.0'
implementation 'tools.jackson.core:jackson-core:3.0.3' implementation 'tools.jackson.core:jackson-core:3.0.3'
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.0' implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.0'
+16 -7
View File
@@ -18,7 +18,7 @@ services:
restart: unless-stopped restart: unless-stopped
db: db:
image: postgres:17 image: postgres:17.7
restart: always restart: always
deploy: deploy:
resources: resources:
@@ -30,14 +30,17 @@ services:
memory: 1G memory: 1G
environment: environment:
POSTGRES_DB: no_copy_ POSTGRES_DB: no_copy_
POSTGRES_USER: postgres # POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres # POSTGRES_PASSWORD: postgres
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_SHARED_BUFFERS: 512MB POSTGRES_SHARED_BUFFERS: 512MB
POSTGRES_EFFECTIVE_CACHE_SIZE: 1536MB POSTGRES_EFFECTIVE_CACHE_SIZE: 1536MB
ports: ports:
- "54320:5432" - "54320:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
container_name: postgres container_name: postgres
networks: networks:
app-network: app-network:
@@ -61,15 +64,17 @@ services:
# FILE_CHUNK_SIZE: 1048576 # FILE_CHUNK_SIZE: 1048576
FILE_CHUNK_SIZE: 1000000 FILE_CHUNK_SIZE: 1000000
POSTGRES_DB: no_copy_ POSTGRES_DB: no_copy_
POSTGRES_USER: postgres # POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres # POSTGRES_PASSWORD: postgres
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_HOST: db POSTGRES_HOST: db
STORAGE_SERVICE_URL: http://storage:8081
SPRING_PROFILES_ACTIVE: docker
YANDEX_API_KEY: AQVNyaVaUmgUb1GMCtf5zSEqFxy0woXrcMOOB43q YANDEX_API_KEY: AQVNyaVaUmgUb1GMCtf5zSEqFxy0woXrcMOOB43q
YANDEX_FOLDER_ID: b1gokpdbm6qfpsou8pcd YANDEX_FOLDER_ID: b1gokpdbm6qfpsou8pcd
YANDEX_SEARCH_URL: "https://searchapi.api.cloud.yandex.net/v2/image/search_by_image" YANDEX_SEARCH_URL: "https://searchapi.api.cloud.yandex.net/v2/image/search_by_image"
STORAGE_SERVICE_URL: http://storage:8081
SPRING_PROFILES_ACTIVE: docker
depends_on: depends_on:
- db - db
ports: ports:
@@ -82,6 +87,10 @@ services:
- api - api
volumes: volumes:
- uploads_data:/data/uploads:rw - uploads_data:/data/uploads:rw
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d no_copy_" ]
interval: 10s
retries: 5
grafana: grafana:
image: grafana/grafana:10.3.1 image: grafana/grafana:10.3.1
+66 -63
View File
@@ -30,6 +30,7 @@ pipeline {
} }
} }
stage('Deploy with docker-compose') { stage('Deploy with docker-compose') {
steps { steps {
script { script {
@@ -38,71 +39,73 @@ pipeline {
credentialsId: 'server-root-password', credentialsId: 'server-root-password',
usernameVariable: 'SSH_USER', usernameVariable: 'SSH_USER',
passwordVariable: 'SSH_PASS' passwordVariable: 'SSH_PASS'
) ),
string(credentialsId: 'DB_USER', variable: 'DB_USER'),
string(credentialsId: 'DB_PASSWORD', variable: 'DB_PASSWORD')
]) { ]) {
sh """ sh """
echo "Deploying branch: ${params.BRANCH}" echo "Deploying branch: ${params.BRANCH}"
echo "Copying files to server..." echo "Copying files to server..."
sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "mkdir -p /opt/deployments/${params.BRANCH}" sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "mkdir -p /opt/deployments/${params.BRANCH}"
sshpass -p '$SSH_PASS' scp -r -o StrictHostKeyChecking=no ./* $SSH_USER@$SERVER:/opt/deployments/${params.BRANCH}/ sshpass -p '$SSH_PASS' scp -r -o StrictHostKeyChecking=no ./* $SSH_USER@$SERVER:/opt/deployments/${params.BRANCH}/
sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER " sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "
cd /opt/deployments/${params.BRANCH} cd /opt/deployments/${params.BRANCH}
echo '1. Остановка старого приложения...' echo '1. Остановка старого приложения...'
docker stop app-backend 2>/dev/null || echo 'Контейнер не найден' docker stop app-backend 2>/dev/null || echo 'Контейнер не найден'
docker rm app-backend 2>/dev/null || echo 'Контейнер не найден' docker rm app-backend 2>/dev/null || echo 'Контейнер не найден'
echo '2. Удаление старых образов...' echo '2. Удаление старых образов...'
docker images --filter 'reference=*app*' -q | xargs -r docker rmi -f 2>/dev/null || echo 'Нет образов для удаления' docker images --filter 'reference=*app*' -q | xargs -r docker rmi -f 2>/dev/null || echo 'Нет образов для удаления'
echo '3. Создание сети если нужно...' echo '3. Создание сети если нужно...'
docker network create app-network 2>/dev/null || echo 'Сеть уже существует' docker network create app-network 2>/dev/null || echo 'Сеть уже существует'
echo '4. Запуск инфраструктуры...' echo '4. Запуск инфраструктуры...'
docker-compose up -d db storage docker-compose up -d db storage
echo '5. Ожидание PostgreSQL...' echo '5. Ожидание PostgreSQL...'
sleep 10 sleep 10
echo '6. Сборка нового образа приложения...' echo '6. Сборка нового образа приложения...'
docker build --no-cache -t app-backend:latest . docker build --no-cache -t app-backend:latest .
echo '7. Запуск приложения...' echo '7. Запуск приложения...'
docker run -d \\ docker run -d \\
--name app-backend \\ --name app-backend \\
--network app-network \\ --network app-network \\
--network-alias app \\ --network-alias app \\
-p 80:8080 \\ -p 80:8080 \\
-v uploads_data:/data/uploads:rw \\ -v uploads_data:/data/uploads:rw \\
-e POSTGRES_DB=no_copy_ \\ -e POSTGRES_DB=no_copy_ \\
-e POSTGRES_USER=postgres \\ -e POSTGRES_USER=$DB_USER \\
-e POSTGRES_PASSWORD=postgres \\ -e POSTGRES_PASSWORD=$DB_PASSWORD \\
-e POSTGRES_PORT=5432 \\ -e POSTGRES_PORT=5432 \\
-e POSTGRES_HOST=db \\ -e POSTGRES_HOST=db \\
--restart unless-stopped \\ --restart unless-stopped \\
app-backend:latest app-backend:latest
echo '8. Запуск мониторинга...' echo '8. Запуск мониторинга...'
docker-compose up -d grafana prometheus loki tempo alloy docker-compose up -d grafana prometheus loki tempo alloy
echo '9. Проверка...' echo '9. Проверка...'
sleep 5 sleep 5
echo 'Статус контейнеров:' echo 'Статус контейнеров:'
docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}'
echo '10. Проверка health...' echo '10. Проверка health...'
if curl -s -f http://localhost:80/health > /dev/null 2>&1; then if curl -s -f http://localhost:80/health > /dev/null 2>&1; then
echo 'Приложение работает' echo 'Приложение работает'
echo 'URL: http://${params.SERVER}:80' echo 'URL: http://${params.SERVER}:80'
else else
echo 'Проверка health не удалась' echo 'Проверка health не удалась'
docker logs app-backend --tail=20 docker logs app-backend --tail=20
fi fi
" "
""" """
} }
} }
} }
+19
View File
@@ -0,0 +1,19 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'ncp_db') THEN
CREATE USER ncp_db WITH PASSWORD 'ncpDbApp';
END IF;
END
$$;
GRANT CONNECT ON DATABASE no_copy_ TO ncp_db;
GRANT USAGE ON SCHEMA public TO ncp_db;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ncp_db;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ncp_db;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON TABLES TO ncp_db;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON SEQUENCES TO ncp_db;
@@ -4,6 +4,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
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;
@@ -15,16 +18,16 @@ import ru.soune.nocopy.dto.BaseRequest;
import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode; import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.RegAnswer; import ru.soune.nocopy.dto.RegAnswer;
import ru.soune.nocopy.dto.file.ChunkUploadResponse; import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.dto.file.CompleteUploadResponse;
import ru.soune.nocopy.dto.file.FileEntityResponse;
import ru.soune.nocopy.dto.file.UploadProgress;
import ru.soune.nocopy.entity.AuthToken; import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus; import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.UploadStatus; import ru.soune.nocopy.entity.file.UploadStatus;
import ru.soune.nocopy.exception.*; import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.handler.*; import ru.soune.nocopy.handler.*;
import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.file.FileEntityService; import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService; import ru.soune.nocopy.service.file.FileUploadService;
@@ -35,6 +38,7 @@ import java.nio.file.Paths;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@@ -50,6 +54,10 @@ public class ApiController {
private final AuthTokenRepository authTokenRepository; private final AuthTokenRepository authTokenRepository;
private final FileSimilarityService fileSimilarityService;
private final FileEntityRepository fileEntityRepository;
@PostMapping("/v{version}/data") @PostMapping("/v{version}/data")
public ResponseEntity<?> handlePostRequest(@RequestBody BaseRequest request, public ResponseEntity<?> handlePostRequest(@RequestBody BaseRequest request,
@PathVariable("version") int version) { @PathVariable("version") int version) {
@@ -93,54 +101,67 @@ public class ApiController {
@RequestParam(value = "chunk", required = false) MultipartFile chunk) { @RequestParam(value = "chunk", required = false) MultipartFile chunk) {
try { try {
if (chunk == null || chunk.isEmpty()) { if (chunk == null || chunk.isEmpty()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), return buildErrorResponse(uploadId, chunkNumber, "Chunk file null or empty");
"Chunk file null or empty", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
} }
if (uploadId == null || uploadId.isBlank()) { if (uploadId == null || uploadId.isBlank()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), return buildErrorResponse(uploadId, chunkNumber, "Upload ID is required");
"Upload ID is required", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
} }
if (chunkNumber == null || chunkNumber < 0) { if (chunkNumber == null || chunkNumber < 0) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), return buildErrorResponse(uploadId, chunkNumber, "Valid chunk number is required");
"Valid chunk number is required", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
} }
fileUploadService.uploadChunk(uploadId, chunkNumber, chunk); UploadProgressResponse uploadProgressResponse = fileUploadService.uploadChunk(uploadId, chunkNumber, chunk);
ChunkUploadResponse responseBody = ChunkUploadResponse.builder() if (uploadProgressResponse.getStatus().equals(UploadStatus.COMPLETED)) {
.uploadId(uploadId) ResponseEntity<BaseResponse> duplicateCheckResult = checkForDuplicates(uploadProgressResponse.getUploadId());
.chunkNumber(chunkNumber) if (duplicateCheckResult != null) {
.chunkSize(chunk.getSize()) return duplicateCheckResult;
.message("Chunk uploaded successfully") }
.build(); }
return buildSuccessResponse(uploadId, chunkNumber, chunk);
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(),
"Chunk uploaded successfully", responseBody));
} catch (Exception e) { } catch (Exception e) {
log.error("Error uploading chunk", e); log.error("Error uploading chunk", e);
return buildErrorResponse(uploadId, chunkNumber, "Failed to upload chunk: " + e.getMessage());
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build();
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to upload chunk: " + e.getMessage(), responseBody));
} }
} }
@GetMapping("/v{version}/files/{fileId}/similar")
public ResponseEntity<BaseResponse> findSimilarFiles(
@PathVariable("version") int version,
@PathVariable String fileId,
@RequestParam(required = false) List<String> similarityLevels,
@PageableDefault(size = 20, sort = "hammingDistance") Pageable pageable) {
SimilarityFilter filter = SimilarityFilter.builder()
.similarityLevels(similarityLevels)
.build();
Page<SimilarFileResponse> similarFiles = fileSimilarityService.findSimilarFiles(fileId, filter, pageable);
String messageDesc;
MessageCode success;
if (similarFiles.isEmpty()) {
messageDesc = MessageCode.FILE_NOT_FOUND.getDescription();
success = MessageCode.FILE_NOT_FOUND;
} else {
messageDesc = MessageCode.SIMILAR_FILES_FOUND.getDescription();
success = MessageCode.SIMILAR_FILES_FOUND;
}
Map<String, Object> responseData = new HashMap<>();
responseData.put("content", similarFiles.getContent());
responseData.put("page", similarFiles.getNumber());
responseData.put("size", similarFiles.getSize());
responseData.put("totalElements", similarFiles.getTotalElements());
responseData.put("totalPages", similarFiles.getTotalPages());
responseData.put("hasNext", similarFiles.hasNext());
responseData.put("hasPrevious", similarFiles.hasPrevious());
return ResponseEntity.ok()
.body(new BaseResponse(20004, success.getCode(), messageDesc, responseData));
}
@GetMapping("/v{version}/files/progress/{uploadId}") @GetMapping("/v{version}/files/progress/{uploadId}")
public ResponseEntity<BaseResponse> getUploadProgress( public ResponseEntity<BaseResponse> getUploadProgress(
@PathVariable("version") int version, @PathVariable("version") int version,
@@ -164,7 +185,6 @@ public class ApiController {
return ResponseEntity.ok().body(new BaseResponse( return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), responseBody)); 20004, MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), responseBody));
} catch (Exception e) { } catch (Exception e) {
log.error("Error getting progress for upload: {}", uploadId, e); log.error("Error getting progress for upload: {}", uploadId, e);
@@ -172,8 +192,7 @@ public class ApiController {
.uploadId(uploadId) .uploadId(uploadId)
.build(); .build();
return ResponseEntity.ok().body(new BaseResponse( return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get upload progress: " + e.getMessage(), responseBody)); "Failed to get upload progress: " + e.getMessage(), responseBody));
} }
} }
@@ -332,6 +351,78 @@ public class ApiController {
} }
} }
private ResponseEntity<BaseResponse> checkForDuplicates(String uploadId) throws IOException {
Optional<FileEntity> uploadedFile = fileEntityRepository.findByUploadSessionId(uploadId);
if (uploadedFile.isEmpty() || !uploadedFile.get().getMimeType().equals("image")) {
return null;
}
FileEntity fileEntity = uploadedFile.get();
List<SimilarFileResponse> similarFiles = fileSimilarityService.findSimilarFiles(fileEntity.getId());
if (hasDuplicate(similarFiles)) {
return handleDuplicate(fileEntity, similarFiles);
}
return null;
}
private boolean hasDuplicate(List<SimilarFileResponse> similarFiles) {
return similarFiles.stream().anyMatch(f -> f.getHammingDistance() <= 5);
}
private ResponseEntity<BaseResponse> handleDuplicate(FileEntity fileEntity, List<SimilarFileResponse> similarFiles)
throws IOException {
fileEntityService.deleteFromDisk(fileEntity);
fileEntityService.markAsDeleted(fileEntity);
Optional<FileEntity> originalFile = fileEntityRepository.findById(similarFiles.get(0).getFileId());
if (originalFile.isPresent()) {
Map<String, String> duplicateInfo = Map.of(
"duplicate_file_id", originalFile.get().getId(),
"owner_user_id", String.valueOf(originalFile.get().getUserId()));
return ResponseEntity.ok().body(new BaseResponse(
20004,
MessageCode.DUPLICATE_FILE_UPLOAD.getCode(),
"Failed to upload chunk, duplicate",
duplicateInfo));
}
return null;
}
private ResponseEntity<BaseResponse> buildSuccessResponse(String uploadId, Integer chunkNumber, MultipartFile chunk) {
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.chunkSize(chunk.getSize())
.message("Chunk uploaded successfully")
.build();
return ResponseEntity.ok().body(new BaseResponse(
20000,
MessageCode.SUCCESS.getCode(),
"Chunk uploaded successfully",
responseBody));
}
private ResponseEntity<BaseResponse> buildErrorResponse(String uploadId, Integer chunkNumber, String errorMessage) {
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build();
return ResponseEntity.ok().body(new BaseResponse(
20004,
MessageCode.FILE_UPLOAD_ERROR.getCode(),
errorMessage,
responseBody));
}
private ResponseEntity<BaseResponse> createValidationErrorResponse(BindingResult bindingResult, Integer msgId) { private ResponseEntity<BaseResponse> createValidationErrorResponse(BindingResult bindingResult, Integer msgId) {
List<Map<String, String>> fieldErrors = bindingResult.getFieldErrors() List<Map<String, String>> fieldErrors = bindingResult.getFieldErrors()
.stream() .stream()
@@ -1,9 +1,11 @@
package ru.soune.nocopy.dto; package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
@Data @Data
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class BaseRequest { public class BaseRequest {
@JsonProperty("version") @JsonProperty("version")
Integer version; Integer version;
@@ -1,11 +1,13 @@
package ru.soune.nocopy.dto; package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class BaseResponse { public class BaseResponse {
@JsonProperty("msg_id") @JsonProperty("msg_id")
private Integer msgId; private Integer msgId;
@@ -1,5 +1,6 @@
package ru.soune.nocopy.dto; package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -10,6 +11,7 @@ import lombok.NoArgsConstructor;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class LoginRequest { public class LoginRequest {
@NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128) @NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128)
@@ -8,6 +8,7 @@ public enum MessageCode {
INVALID_TOKEN(2, "Invalid token"), INVALID_TOKEN(2, "Invalid token"),
INVALID_ACTION(2, "Invalid action"), INVALID_ACTION(2, "Invalid action"),
FILE_UPLOAD_ERROR(2, "File upload error"), FILE_UPLOAD_ERROR(2, "File upload error"),
DUPLICATE_FILE_UPLOAD(2, "Duplicate file upload"),
FILE_DOWNLOAD_ERROR(2, "File download error"), FILE_DOWNLOAD_ERROR(2, "File download error"),
FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD(2, "Not correct field"), FILE_DOWNLOAD_ERROR_NOT_CORRECT_FIELD(2, "Not correct field"),
IMAGE_FOUND_ERROR(2, "Image found error"), IMAGE_FOUND_ERROR(2, "Image found error"),
@@ -20,7 +21,8 @@ public enum MessageCode {
AUTH_EMAIL_OR_TOKEN_NOT_FOUND(4, "Email or Token not found "), AUTH_EMAIL_OR_TOKEN_NOT_FOUND(4, "Email or Token not found "),
AUTH_TOKEN_MISMATCH(4, "Token mismatch"), AUTH_TOKEN_MISMATCH(4, "Token mismatch"),
FILE_NOT_FOUND(4, "File not found"), FILE_NOT_FOUND(4, "File not found"),
AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match"); AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match"),
SIMILAR_FILES_FOUND(0, "Similar files found");
private final Integer code; private final Integer code;
@@ -1,10 +1,12 @@
package ru.soune.nocopy.dto; package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import lombok.Data; import lombok.Data;
@Data @Data
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class RegRequest { public class RegRequest {
@NotEmpty(message = "Full name is required") @NotEmpty(message = "Full name is required")
private String fullName; private String fullName;
@@ -10,6 +10,7 @@ import ru.soune.nocopy.entity.GenderType;
import java.time.LocalDate; import java.time.LocalDate;
@Data @Data
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class UserRequest { public class UserRequest {
@Size(min = 2) @Size(min = 2)
private String fullName; private String fullName;
@@ -0,0 +1,14 @@
package ru.soune.nocopy.dto.file;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class SimilarFileResponse {
String fileId;
String originalFileName;
Long fileSize;
Integer hammingDistance;
String similarityLevel;
}
@@ -0,0 +1,18 @@
package ru.soune.nocopy.dto.file;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SimilarityFilter {
private List<String> similarityLevels;
private Integer page;
private Integer size;
}
@@ -1,5 +1,6 @@
package ru.soune.nocopy.entity.file; package ru.soune.nocopy.entity.file;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
@@ -60,6 +61,10 @@ public class FileEntity {
@Column(name = "updated_at") @Column(name = "updated_at")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@OneToOne(mappedBy = "file", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
private ImageHashEntity imageHash;
@PrePersist @PrePersist
public void prePersist() { public void prePersist() {
if (this.status == null) { if (this.status == null) {
@@ -69,6 +69,7 @@ public class FileUploadSession {
private String extension; private String extension;
@Column(name = "retry_count") @Column(name = "retry_count")
@Builder.Default
private Integer retryCount = 0; private Integer retryCount = 0;
@Column(name = "completed_at") @Column(name = "completed_at")
@@ -81,6 +82,7 @@ public class FileUploadSession {
) )
@MapKeyColumn(name = "chunk_number") @MapKeyColumn(name = "chunk_number")
@Column(name = "chunk_path") @Column(name = "chunk_path")
@Builder.Default
private Map<Integer, String> chunkPaths = new HashMap<>(); private Map<Integer, String> chunkPaths = new HashMap<>();
@PrePersist @PrePersist
@@ -0,0 +1,39 @@
package ru.soune.nocopy.entity.file;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "image_hashes")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ImageHashEntity {
@Id
@Column(name = "file_id")
private String fileId;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "file_id")
private FileEntity file;
@Column(name = "hash64_hi")
private Integer hash64Hi;
@Column(name = "hash64_lo")
private Integer hash64Lo;
@Column(name = "hash_algorithm", nullable = false)
private String hashAlgorithm;
@Column(name = "created_at")
private LocalDateTime createdAt;
}
@@ -0,0 +1,7 @@
package ru.soune.nocopy.exception;
public class DuplicateImageException extends RuntimeException {
public DuplicateImageException(String message) {
super(message);
}
}
@@ -62,7 +62,8 @@ public class FileEntityHandler implements RequestHandler {
.action(action) .action(action)
.availableActions(Arrays.asList( .availableActions(Arrays.asList(
"file_info", "file_by_session", "user_files", "file_info", "file_by_session", "user_files",
"search_files", "storage_usage", "delete_file")) "search_files", "storage_usage", "delete_file",
"user_files_info"))
.build(); .build();
return new BaseResponse(request.getMsgId(), return new BaseResponse(request.getMsgId(),
@@ -209,6 +209,7 @@ public class FileUploadHandler implements RequestHandler {
.orElseThrow(() -> new UploadSessionNotFoundException(fileRequest.getUploadId())); .orElseThrow(() -> new UploadSessionNotFoundException(fileRequest.getUploadId()));
Map<String, Boolean> chunkStatus = new HashMap<>(); Map<String, Boolean> chunkStatus = new HashMap<>();
for (int i = 0; i < session.getTotalChunks(); i++) { for (int i = 0; i < session.getTotalChunks(); i++) {
chunkStatus.put("chunk_" + i, session.getChunkPaths().containsKey(i)); chunkStatus.put("chunk_" + i, session.getChunkPaths().containsKey(i));
} }
@@ -223,7 +224,6 @@ public class FileUploadHandler implements RequestHandler {
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), response); MessageCode.SUCCESS.getDescription(), response);
} catch (UploadSessionNotFoundException e) { } catch (UploadSessionNotFoundException e) {
return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(),
"Upload session not found", null); "Upload session not found", null);
@@ -5,15 +5,16 @@ import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.ResponseStatus; import ru.soune.nocopy.dto.BaseResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice; import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.exception.*; import ru.soune.nocopy.exception.*;
import java.util.Map; import java.util.Map;
@RestControllerAdvice @RestControllerAdvice
@AllArgsConstructor @AllArgsConstructor
@ControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
@@ -81,4 +82,12 @@ public class GlobalExceptionHandler {
"message" ,ex.getMessage() "message" ,ex.getMessage()
)); ));
} }
@ExceptionHandler(DuplicateImageException.class)
@ResponseBody
public ResponseEntity<BaseResponse> handleDuplicateImage(DuplicateImageException e) {
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.DUPLICATE_FILE_UPLOAD.getCode(),
"Duplicate image detected", e.getMessage()));
}
} }
@@ -19,7 +19,6 @@ public class ImageFoundRequestHandler implements RequestHandler {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Autowired
private final YandexSearchService yandexSearchService; private final YandexSearchService yandexSearchService;
@Override @Override
@@ -13,7 +13,7 @@ import java.util.*;
@Component @Component
public class RegRequestValidator implements Validator { public class RegRequestValidator implements Validator {
private static final String COMPANY_REGEX = "^[a-zA-Zа-яА-ЯёЁ0-9\\s\\-&.,'()]{0,200}$"; private static final String COMPANY_REGEX = "^[a-zA-Zа-яА-ЯёЁ0-9\\s\\-&.,'()\"\"«»„‟”“”‘'’‹›❛❜❝❞〝〞〟]{1,200}$";
private static final String NAME_REGEX = "^[a-zA-Zа-яА-ЯёЁ\\s\\-'.]{2,100}$"; private static final String NAME_REGEX = "^[a-zA-Zа-яА-ЯёЁ\\s\\-'.]{2,100}$";
@@ -74,21 +74,6 @@ public class RegRequestValidator implements Validator {
errors.rejectValue("companyName", "companyName.invalid.chars", errors.rejectValue("companyName", "companyName.invalid.chars",
"Company name contains invalid characters"); "Company name contains invalid characters");
} }
validateForbiddenWords(trimmedCompany, "companyName", errors,
Arrays.asList("admin", "root", "system", "test"));
}
private void validateForbiddenWords(String text, String fieldName,
Errors errors, List<String> forbiddenWords) {
String lowerText = text.toLowerCase();
for (String word : forbiddenWords) {
if (lowerText.contains(word)) {
errors.rejectValue(fieldName, fieldName + ".forbidden.word",
"Contains forbidden word: " + word);
break;
}
}
} }
private void validateEmail(String email, Errors errors) { private void validateEmail(String email, Errors errors) {
@@ -30,5 +30,8 @@ public interface FileEntityRepository extends JpaRepository<FileEntity, String>
@Query("SELECT f FROM FileEntity f WHERE f.userId = :userId AND f.originalFileName LIKE %:keyword%") @Query("SELECT f FROM FileEntity f WHERE f.userId = :userId AND f.originalFileName LIKE %:keyword%")
List<FileEntity> searchByFileName(@Param("userId") Long userId, @Param("keyword") String keyword); List<FileEntity> searchByFileName(@Param("userId") Long userId, @Param("keyword") String keyword);
@Query("SELECT f FROM FileEntity f WHERE f.status = :status")
List<FileEntity> searchFileEntityByStatus(@Param("status") FileStatus status);
long countByUserId(Long userId); long countByUserId(Long userId);
} }
@@ -0,0 +1,10 @@
package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.soune.nocopy.entity.file.ImageHashEntity;
@Repository
public interface ImageHashRepository extends JpaRepository<ImageHashEntity, String> {
void deleteByFileId(String fileId);
}
@@ -0,0 +1,35 @@
package ru.soune.nocopy.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import ru.soune.nocopy.entity.file.FileEntity;
import java.util.List;
@Repository
public interface ImageSimilarityRepository
extends JpaRepository<FileEntity, String> {
@Query(value = """
SELECT
f.id AS similarFileId,
f.original_file_name AS originalFileName,
f.file_size AS fileSize,
h.hash64_hi AS hash64Hi,
h.hash64_lo AS hash64Lo
FROM image_hashes ref
JOIN image_hashes h
ON ref.file_id <> h.file_id
JOIN file_entities f
ON f.id = h.file_id
WHERE ref.file_id = :fileId
""",
nativeQuery = true)
List<SimilarImageProjection> findCandidates(
@Param("fileId") String fileId
);
}
@@ -0,0 +1,14 @@
package ru.soune.nocopy.repository;
public interface SimilarImageProjection {
String getSimilarFileId();
String getOriginalFileName();
Long getFileSize();
Integer getHash64Hi();
Integer getHash64Lo();
}
@@ -0,0 +1,124 @@
package ru.soune.nocopy.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.dto.file.SimilarFileResponse;
import ru.soune.nocopy.dto.file.SimilarityFilter;
import ru.soune.nocopy.repository.ImageHashRepository;
import ru.soune.nocopy.repository.ImageSimilarityRepository;
import ru.soune.nocopy.repository.SimilarImageProjection;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class FileSimilarityService {
private final ImageSimilarityRepository repository;
private final ImageHashRepository hashRepository;
public List<SimilarFileResponse> findSimilarFiles(String fileId) {
var imageHashEntity = hashRepository.findById(fileId)
.orElseThrow(() -> new RuntimeException("Hash not found"));
Integer hash64Hi = imageHashEntity.getHash64Hi();
Integer hash64Lo = imageHashEntity.getHash64Lo();
List<SimilarImageProjection> candidates =
repository.findCandidates(fileId);
return candidates.stream()
.map(c -> {
Integer cHi = c.getHash64Hi();
Integer cLo = c.getHash64Lo();
int hamming = hamming64(hash64Hi, hash64Lo, cHi, cLo);
String level;
if (hamming <= 5) {
level = "DUPLICATE";
} else if (hamming <= 12) {
level = "SIMILAR";
} else {
level = "DIFFERENT";
}
return SimilarFileResponse.builder()
.fileId(c.getSimilarFileId())
.originalFileName(c.getOriginalFileName())
.fileSize(c.getFileSize())
.hammingDistance(hamming)
.similarityLevel(level)
.build();
})
.sorted((a, b) ->
Integer.compare(a.getHammingDistance(), b.getHammingDistance()))
.toList();
}
public Page<SimilarFileResponse> findSimilarFiles(String fileId, SimilarityFilter filter, Pageable pageable) {
var imageHashEntity = hashRepository.findById(fileId)
.orElseThrow(() -> new RuntimeException("Hash not found"));
Integer hash64Hi = imageHashEntity.getHash64Hi();
Integer hash64Lo = imageHashEntity.getHash64Lo();
List<SimilarImageProjection> candidates = repository.findCandidates(fileId);
List<String> similarityLevels = (filter != null && filter.getSimilarityLevels() != null)
? filter.getSimilarityLevels()
: List.of("DUPLICATE", "SIMILAR", "DIFFERENT");
List<SimilarFileResponse> allResults = candidates.stream()
.map(c -> createSimilarFileResponse(c, hash64Hi, hash64Lo))
.filter(response -> similarityLevels.contains(response.getSimilarityLevel()))
.sorted(Comparator.comparingInt(SimilarFileResponse::getHammingDistance))
.collect(Collectors.toList());
int total = allResults.size();
int page = (pageable != null) ? pageable.getPageNumber() : 0;
int size = (pageable != null) ? pageable.getPageSize() : 20;
int fromIndex = Math.min(page * size, total);
int toIndex = Math.min(fromIndex + size, total);
List<SimilarFileResponse> pageContent = allResults.subList(fromIndex, toIndex);
return new PageImpl<>(pageContent, pageable, total);
}
private SimilarFileResponse createSimilarFileResponse(SimilarImageProjection similarImageProjection,
Integer hash64Hi, Integer hash64Lo) {
Integer imageProjectionHash64Hi = similarImageProjection.getHash64Hi();
Integer similarImageProjectionHash64Lo = similarImageProjection.getHash64Lo();
int hamming = hamming64(hash64Hi, hash64Lo, imageProjectionHash64Hi, similarImageProjectionHash64Lo);
String level;
if (hamming <= 5) {
level = "DUPLICATE";
} else if (hamming <= 12) {
level = "SIMILAR";
} else {
level = "DIFFERENT";
}
return SimilarFileResponse.builder()
.fileId(similarImageProjection.getSimilarFileId())
.originalFileName(similarImageProjection.getOriginalFileName())
.fileSize(similarImageProjection.getFileSize())
.hammingDistance(hamming)
.similarityLevel(level)
.build();
}
private int hamming64(int aHi, int aLo, int bHi, int bLo) {
return Integer.bitCount(aHi ^ bHi)
+ Integer.bitCount(aLo ^ bLo);
}
}
@@ -0,0 +1,154 @@
package ru.soune.nocopy.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.ImageHashEntity;
import ru.soune.nocopy.repository.ImageHashRepository;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
@Service
@RequiredArgsConstructor
@Slf4j
public class ImageHashService {
private final ImageHashRepository repository;
public void create(FileEntity file, Path imagePath) {
try {
long hash64 = computePhash64(imagePath);
int hi = high32(hash64);
int lo = low32(hash64);
ImageHashEntity entity = ImageHashEntity.builder()
.file(file)
.hash64Hi(hi)
.hash64Lo(lo)
.hashAlgorithm("PHASH64")
.createdAt(LocalDateTime.now())
.build();
repository.save(entity);
} catch (IOException e) {
throw new RuntimeException("Failed to compute image hash", e);
}
}
private long computePhash64(Path path) throws IOException {
try (InputStream is = Files.newInputStream(path)) {
BufferedImage image = ImageIO.read(is);
if (image == null) throw new IOException("Cannot read image file: " + path);
BufferedImage gray = toGrayScale(image);
BufferedImage resized = resizeImage(gray, 32, 32);
double[][] dct = applyDCT(resized);
double[][] topLeft = extractTopLeft8x8(dct);
double median = calculateMedian(topLeft, true);
return create64BitHashLong(topLeft, median);
}
}
private BufferedImage toGrayScale(BufferedImage image) {
BufferedImage gray = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = gray.createGraphics();
g.drawImage(image, 0, 0, null);
g.dispose();
return gray;
}
private BufferedImage resizeImage(BufferedImage image, int width, int height) {
BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = resized.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
return resized;
}
private double[][] applyDCT(BufferedImage image) {
int w = image.getWidth();
int h = image.getHeight();
double[][] pixels = new double[h][w];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int gray = image.getRGB(x, y) & 0xFF;
pixels[y][x] = gray;
}
}
double[][] dct = new double[h][w];
for (int u = 0; u < h; u++) {
double au = u == 0 ? 1.0 / Math.sqrt(h) : Math.sqrt(2.0 / h);
for (int v = 0; v < w; v++) {
double av = v == 0 ? 1.0 / Math.sqrt(w) : Math.sqrt(2.0 / w);
double sum = 0.0;
for (int x = 0; x < h; x++) {
for (int y = 0; y < w; y++) {
sum += pixels[x][y] *
Math.cos((2 * x + 1) * u * Math.PI / (2.0 * h)) *
Math.cos((2 * y + 1) * v * Math.PI / (2.0 * w));
}
}
dct[u][v] = au * av * sum;
}
}
return dct;
}
private double[][] extractTopLeft8x8(double[][] dct) {
double[][] block = new double[8][8];
for (int i = 0; i < 8; i++) System.arraycopy(dct[i], 0, block[i], 0, 8);
return block;
}
private double calculateMedian(double[][] block, boolean excludeDC) {
double[] vals = new double[63];
int idx = 0;
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 8; j++) {
if (i == 0 && j == 0 && excludeDC) continue;
vals[idx++] = block[i][j];
}
}
Arrays.sort(vals);
return (vals.length % 2 == 0) ?
(vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2.0 :
vals[vals.length / 2];
}
private long create64BitHashLong(double[][] block, double median) {
long hash = 0L;
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 8; j++) {
if (i == 0 && j == 0) continue;
hash <<= 1;
if (block[i][j] > median) hash |= 1;
}
}
return hash;
}
private int high32(long hash64) {
return (int) (hash64 >>> 32);
}
private int low32(long hash64) {
return (int) hash64;
}
}
@@ -28,17 +28,17 @@ import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class YandexSearchService { public class YandexSearchService {
private final FileEntityRepository fileEntityRepository; private final FileEntityRepository fileEntityRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Value("${YANDEX_API_KEY}") @Value("${yandex.api-key}")
private String apiKey; private String apiKey;
@Value("${YANDEX_FOLDER_ID}") @Value("${yandex.folder-id}")
private String folderId; private String folderId;
@Value("${YANDEX_SEARCH_URL}") @Value("${yandex.search-url}")
private String searchUrl; private String searchUrl;
@PostConstruct @PostConstruct
@@ -11,13 +11,14 @@ import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.FileUploadSession; import ru.soune.nocopy.entity.file.FileUploadSession;
import ru.soune.nocopy.exception.FileEntityNotFoundException; import ru.soune.nocopy.exception.FileEntityNotFoundException;
import ru.soune.nocopy.repository.FileEntityRepository; import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -28,6 +29,10 @@ public class FileEntityService {
private final FileEntityRepository fileEntityRepository; private final FileEntityRepository fileEntityRepository;
private final ImageHashService imageHashService;
private final FileSimilarityService fileSimilarityService;
@Transactional @Transactional
public FileEntity createFromUploadSession(FileUploadSession session, String checksum) { public FileEntity createFromUploadSession(FileUploadSession session, String checksum) {
log.info("Creating FileEntity for upload session: {}", session.getUploadId()); log.info("Creating FileEntity for upload session: {}", session.getUploadId());
@@ -57,11 +62,12 @@ public class FileEntityService {
.build(); .build();
FileEntity saved = fileEntityRepository.save(fileEntity); FileEntity saved = fileEntityRepository.save(fileEntity);
log.info("FileEntity created successfully: {} (size: {} bytes)",
saved.getId(), fileSize); if (saved.getMimeType().startsWith("image")) {
imageHashService.create(saved, Path.of(saved.getFilePath()));
}
return saved; return saved;
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to create FileEntity for session {}: {}", log.error("Failed to create FileEntity for session {}: {}",
session.getUploadId(), e.getMessage(), e); session.getUploadId(), e.getMessage(), e);
@@ -69,15 +75,6 @@ public class FileEntityService {
} }
} }
private String extractFileExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
return fileName.substring(dotIndex + 1).toLowerCase();
}
return "";
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileEntityResponse getById(String fileId, int version) { public FileEntityResponse getById(String fileId, int version) {
FileEntity fileEntity = fileEntityRepository.findById(fileId) FileEntity fileEntity = fileEntityRepository.findById(fileId)
@@ -95,39 +92,6 @@ public class FileEntityService {
return convertToResponse(fileEntity, version); 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 FileResponse getAllUserFiles(Long userId, int version) {
List<FileEntity> fileEntities = fileEntityRepository.findByUserIdAndStatus(
userId, FileStatus.ACTIVE);
List<FileEntityResponse> files = fileEntities.stream()
.map(file -> convertToResponse(file, version))
.collect(Collectors.toList());
long totalSize = fileEntities.stream()
.mapToLong(FileEntity::getFileSize)
.sum();
return FileResponse.builder()
.files(files)
.totalCount(files.size())
.totalSize(totalSize)
.formattedTotalSize(formatFileSize(totalSize))
.page(1)
.pageSize(files.size())
.build();
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FileResponse getUserFiles(Long userId, int page, int pageSize, int version) { public FileResponse getUserFiles(Long userId, int page, int pageSize, int version) {
List<FileEntity> allFiles = fileEntityRepository.findByUserIdAndStatus( List<FileEntity> allFiles = fileEntityRepository.findByUserIdAndStatus(
@@ -185,6 +149,8 @@ public class FileEntityService {
Files.delete(path); Files.delete(path);
markAsDeleted(fileEntity);
return true; return true;
} }
@@ -402,7 +402,7 @@ public class FileUploadServiceImpl implements FileUploadService {
session.getUploadId(), session.getFileName()); session.getUploadId(), session.getFileName());
Path finalFilePath = null; Path finalFilePath = null;
String checksum = null; String checksum;
try { try {
finalFilePath = prepareFinalFile(session); finalFilePath = prepareFinalFile(session);
@@ -591,44 +591,10 @@ public class FileUploadServiceImpl implements FileUploadService {
private void handleAssemblyFailure(FileUploadSession session, Exception e) { private void handleAssemblyFailure(FileUploadSession session, Exception e) {
session.setStatus(UploadStatus.FAILED); session.setStatus(UploadStatus.FAILED);
session.setLastError(e.getMessage()); session.setLastError(e.getMessage());
sessionRepository.save(session); sessionRepository.save(session);
log.error("File assembly failed for session {}: {}",
session.getUploadId(), e.getMessage());
} }
// @Override
// @Transactional
// public void cleanupExpiredSessions() {
// log.info("Starting cleanup of expired upload sessions");
//
// LocalDateTime expiryThreshold = LocalDateTime.now().minusHours(sessionExpiryHours);
//
// try {
// var expiredSessions = sessionRepository.findByStatusInAndCreatedAtBefore(
// Set.of(UploadStatus.INITIATED, UploadStatus.UPLOADING, UploadStatus.FAILED),
// expiryThreshold
// );
//
// int cleanedCount = 0;
// for (FileUploadSession session : expiredSessions) {
// session.setStatus(UploadStatus.FAILED);
// session.setLastError("Session expired during cleanup");
// sessionRepository.save(session);
//
// CompletableFuture.runAsync(() -> cleanupSessionFiles(session));
// cleanedCount++;
// }
//
// log.info("Cleaned up {} expired upload sessions", cleanedCount);
//
// } catch (Exception e) {
// log.error("Error during session cleanup", e);
// }
// }
private Path getChunkDirectory(Long userId, String uploadId) { private Path getChunkDirectory(Long userId, String uploadId) {
return storageRoot.resolve("temp") return storageRoot.resolve("temp")
.resolve("user") .resolve("user")
@@ -0,0 +1,35 @@
package ru.soune.nocopy.service.file;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.repository.FileEntityRepository;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
@Service
@RequiredArgsConstructor
public class NotExistFilesCleanupService {
@Autowired
private FileEntityRepository fileEntityRepository;
@Transactional
@Scheduled(fixedDelay = 30000)
public void cleanUpNotExistFiles() {
List<FileEntity> fileEntities = fileEntityRepository.searchFileEntityByStatus(FileStatus.DELETED);
for (FileEntity fileEntity : fileEntities) {
boolean exists = Files.exists(Paths.get(fileEntity.getFilePath()));
if (!exists) {
fileEntityRepository.delete(fileEntity);
}
}
}
}
+10 -1
View File
@@ -1,4 +1,8 @@
spring: spring:
flyway:
enabled: true
baseline-on-migrate: true
datasource: datasource:
url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
username: ${POSTGRES_USER} username: ${POSTGRES_USER}
@@ -51,4 +55,9 @@ logging:
file: file:
name: logs/application.log name: logs/application.log
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
yandex:
api-key: ${YANDEX_API_KEY:AQVNyaVaUmgUb1GMCtf5zSEqFxy0woXrcMOOB43q}
folder-id: ${YANDEX_FOLDER_ID:b1gokpdbm6qfpsou8pcd}
search-url: ${YANDEX_SEARCH_URL:https://searchapi.api.cloud.yandex.net/v2/image/search_by_image}
@@ -0,0 +1,2 @@
CREATE INDEX idx_image_hash_prefix
ON image_hashes ((hash_value >> 48));
@@ -1,101 +0,0 @@
package ru.soune.nocopy.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import ru.soune.nocopy.dto.LoginRequest;
import ru.soune.nocopy.entity.AuthToken;
import ru.soune.nocopy.entity.User;
import ru.soune.nocopy.exception.NotValidationPasswordException;
import ru.soune.nocopy.exception.UserNotFoundException;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.UserRepository;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class AuthServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private AuthTokenRepository authTokenRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private MessageSource messageSource;
@InjectMocks
private AuthService authService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void login_success() {
LoginRequest request = new LoginRequest("test@mail.com", "password");
User user = new User();
user.setId(1L);
user.setEmail("test@mail.com");
user.setPassword("encoded_pass");
when(userRepository.findByEmail("test@mail.com"))
.thenReturn(Optional.of(user));
when(passwordEncoder.matches("password", "encoded_pass"))
.thenReturn(true);
when(authTokenRepository.save(any(AuthToken.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AuthToken token = authService.login(request);
assertNotNull(token);
assertNotNull(token.getToken());
// assertEquals(user.getTokens().get(0), token.getUser());
ArgumentCaptor<AuthToken> captor = ArgumentCaptor.forClass(AuthToken.class);
verify(authTokenRepository).save(captor.capture());
// assertEquals(user, captor.getValue().getUser());
assertNotNull(captor.getValue().getToken());
}
@Test
void login_userNotFound() {
LoginRequest request = new LoginRequest("missing@mail.com", "pwd");
when(userRepository.findByEmail("missing@mail.com")).thenReturn(Optional.empty());
assertThrows(UserNotFoundException.class, () -> authService.login(request));
}
@Test
void login_invalidPassword() {
LoginRequest request = new LoginRequest("test@mail.com", "wrong");
User user = new User();
user.setEmail("test@mail.com");
user.setPassword("encoded_pass");
when(userRepository.findByEmail("test@mail.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "encoded_pass")).thenReturn(false);
assertThrows(NotValidationPasswordException.class, () -> authService.login(request));
}
}