8 Commits
Author SHA1 Message Date
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
15 changed files with 264 additions and 151 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
POSTGRES_DB=no_copy_
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_USER=ncp_db
POSTGRES_PASSWORD=ncpDbApp
POSTGRES_PORT=5432
POSTGRES_HOST=postgres
+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;
+14 -5
View File
@@ -18,7 +18,7 @@ services:
restart: unless-stopped
db:
image: postgres:17
image: postgres:17.7
restart: always
deploy:
resources:
@@ -30,14 +30,17 @@ services:
memory: 1G
environment:
POSTGRES_DB: no_copy_
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# POSTGRES_USER: postgres
# POSTGRES_PASSWORD: postgres
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_SHARED_BUFFERS: 512MB
POSTGRES_EFFECTIVE_CACHE_SIZE: 1536MB
ports:
- "54320:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
container_name: postgres
networks:
app-network:
@@ -61,8 +64,10 @@ services:
# FILE_CHUNK_SIZE: 1048576
FILE_CHUNK_SIZE: 1000000
POSTGRES_DB: no_copy_
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# POSTGRES_USER: postgres
# POSTGRES_PASSWORD: postgres
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PORT: 5432
POSTGRES_HOST: db
STORAGE_SERVICE_URL: http://storage:8081
@@ -82,6 +87,10 @@ services:
- api
volumes:
- uploads_data:/data/uploads:rw
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d no_copy_" ]
interval: 10s
retries: 5
grafana:
image: grafana/grafana:10.3.1
+6 -3
View File
@@ -30,6 +30,7 @@ pipeline {
}
}
stage('Deploy with docker-compose') {
steps {
script {
@@ -38,7 +39,9 @@ pipeline {
credentialsId: 'server-root-password',
usernameVariable: 'SSH_USER',
passwordVariable: 'SSH_PASS'
)
),
string(credentialsId: 'DB_USER', variable: 'DB_USER'),
string(credentialsId: 'DB_PASSWORD', variable: 'DB_PASSWORD')
]) {
sh """
echo "Deploying branch: ${params.BRANCH}"
@@ -77,8 +80,8 @@ pipeline {
-p 80:8080 \\
-v uploads_data:/data/uploads:rw \\
-e POSTGRES_DB=no_copy_ \\
-e POSTGRES_USER=postgres \\
-e POSTGRES_PASSWORD=postgres \\
-e POSTGRES_USER=$DB_USER \\
-e POSTGRES_PASSWORD=$DB_PASSWORD \\
-e POSTGRES_PORT=5432 \\
-e POSTGRES_HOST=db \\
--restart unless-stopped \\
+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;
@@ -1,7 +1,10 @@
package ru.soune.nocopy.controller;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
@@ -17,12 +20,15 @@ import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.RegAnswer;
import ru.soune.nocopy.dto.file.*;
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.UploadStatus;
import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.handler.*;
import ru.soune.nocopy.repository.AuthTokenRepository;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService;
@@ -33,6 +39,7 @@ import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@@ -50,6 +57,8 @@ public class ApiController {
private final FileSimilarityService fileSimilarityService;
private final FileEntityRepository fileEntityRepository;
@PostMapping("/v{version}/data")
public ResponseEntity<?> handlePostRequest(@RequestBody BaseRequest request,
@PathVariable("version") int version) {
@@ -93,62 +102,72 @@ public class ApiController {
@RequestParam(value = "chunk", required = false) MultipartFile chunk) {
try {
if (chunk == null || chunk.isEmpty()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Chunk file null or empty", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
return buildErrorResponse(uploadId, chunkNumber, "Chunk file null or empty");
}
if (uploadId == null || uploadId.isBlank()) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Upload ID is required", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
return buildErrorResponse(uploadId, chunkNumber, "Upload ID is required");
}
if (chunkNumber == null || chunkNumber < 0) {
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Valid chunk number is required", ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build()));
return buildErrorResponse(uploadId, chunkNumber, "Valid chunk number is required");
}
fileUploadService.uploadChunk(uploadId, chunkNumber, chunk);
UploadProgressResponse uploadProgressResponse = fileUploadService.uploadChunk(uploadId, chunkNumber, chunk);
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.chunkSize(chunk.getSize())
.message("Chunk uploaded successfully")
.build();
if (uploadProgressResponse.getStatus().equals(UploadStatus.COMPLETED)) {
ResponseEntity<BaseResponse> duplicateCheckResult = checkForDuplicates(uploadProgressResponse.getUploadId());
if (duplicateCheckResult != null) {
return duplicateCheckResult;
}
}
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(),
"Chunk uploaded successfully", responseBody));
} catch (DuplicateImageException e) {
log.error("Error uploading chunk", e);
ChunkUploadResponse responseBody = ChunkUploadResponse.builder()
.uploadId(uploadId)
.chunkNumber(chunkNumber)
.build();
return buildSuccessResponse(uploadId, chunkNumber, chunk);
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.DUPLICATE_FILE_UPLOAD.getCode(),
"Failed to upload chunk: " + e.getMessage(), responseBody));
} catch (Exception 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();
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);
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.FILE_UPLOAD_ERROR.getCode(),
"Failed to upload chunk: " + e.getMessage(), responseBody));
20004,
MessageCode.DUPLICATE_FILE_UPLOAD.getCode(),
"Failed to upload chunk, duplicate",
duplicateInfo));
}
return null;
}
@GetMapping("/files/{fileId}/similar")
@@ -179,7 +198,6 @@ public class ApiController {
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), responseBody));
} catch (Exception e) {
log.error("Error getting progress for upload: {}", uploadId, e);
@@ -187,8 +205,7 @@ public class ApiController {
.uploadId(uploadId)
.build();
return ResponseEntity.ok().body(new BaseResponse(
20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(),
"Failed to get upload progress: " + e.getMessage(), responseBody));
}
}
@@ -347,6 +364,34 @@ public class ApiController {
}
}
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) {
List<Map<String, String>> fieldErrors = bindingResult.getFieldErrors()
.stream()
@@ -1,9 +1,11 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class BaseRequest {
@JsonProperty("version")
Integer version;
@@ -1,11 +1,13 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class BaseResponse {
@JsonProperty("msg_id")
private Integer msgId;
@@ -1,5 +1,6 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@@ -10,6 +11,7 @@ import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class LoginRequest {
@NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128)
@@ -1,10 +1,12 @@
package ru.soune.nocopy.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class RegRequest {
@NotEmpty(message = "Full name is required")
private String fullName;
@@ -10,6 +10,7 @@ import ru.soune.nocopy.entity.GenderType;
import java.time.LocalDate;
@Data
@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
public class UserRequest {
@Size(min = 2)
private String fullName;
@@ -5,15 +5,16 @@ import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.annotation.*;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.exception.*;
import java.util.Map;
@RestControllerAdvice
@AllArgsConstructor
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@@ -81,4 +82,12 @@ public class GlobalExceptionHandler {
"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()));
}
}
@@ -13,7 +13,7 @@ import java.util.*;
@Component
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}$";
@@ -74,21 +74,6 @@ public class RegRequestValidator implements Validator {
errors.rejectValue("companyName", "companyName.invalid.chars",
"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) {
@@ -6,4 +6,5 @@ import ru.soune.nocopy.entity.file.ImageHashEntity;
@Repository
public interface ImageHashRepository extends JpaRepository<ImageHashEntity, String> {
void deleteByFileId(String fileId);
}
@@ -9,7 +9,6 @@ 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.exception.DuplicateImageException;
import ru.soune.nocopy.exception.FileEntityNotFoundException;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.FileSimilarityService;
@@ -66,22 +65,6 @@ public class FileEntityService {
if (saved.getMimeType().startsWith("image")) {
imageHashService.create(saved, Path.of(saved.getFilePath()));
var similarFiles = fileSimilarityService.findSimilarFiles(saved.getId());
boolean hasDuplicate = similarFiles.stream()
.anyMatch(f -> f.getHammingDistance() <= 5);
if (hasDuplicate) {
log.warn("Duplicate image detected for upload session {}. Rolling back.",
session.getUploadId());
throw new DuplicateImageException("Duplicate image detected for upload session " + session.getUploadId());
}
similarFiles.stream()
.filter(f -> f.getHammingDistance() > 5)
.forEach(f -> log.info("Similar image found: {} (distance {})",
f.getFileId(), f.getHammingDistance()));
}
return saved;
@@ -166,6 +149,8 @@ public class FileEntityService {
Files.delete(path);
markAsDeleted(fileEntity);
return true;
}