Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/backend_spring/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class BackendSpringApplication {
public static void main(String[] args) {
SpringApplication.run(BackendSpringApplication.class, args);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public static class Detection {
private long aiRetryDelayMs = 500;
private long maxFileSizeBytes = 20L * 1024L * 1024L;
private int maxRawResultJsonBytes = 2 * 1024 * 1024;
private int uploadRetentionDays = 7;
private long uploadCleanupIntervalMs = 60L * 60L * 1000L;

public int getQueueCapacity() {
return queueCapacity;
Expand Down Expand Up @@ -127,5 +129,21 @@ public int getMaxRawResultJsonBytes() {
public void setMaxRawResultJsonBytes(int maxRawResultJsonBytes) {
this.maxRawResultJsonBytes = maxRawResultJsonBytes;
}

public int getUploadRetentionDays() {
return uploadRetentionDays;
}

public void setUploadRetentionDays(int uploadRetentionDays) {
this.uploadRetentionDays = uploadRetentionDays;
}

public long getUploadCleanupIntervalMs() {
return uploadCleanupIntervalMs;
}

public void setUploadCleanupIntervalMs(long uploadCleanupIntervalMs) {
this.uploadCleanupIntervalMs = uploadCleanupIntervalMs;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Lob;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
Expand All @@ -13,7 +14,16 @@
import java.time.LocalDateTime;

@Entity
@Table(name = "detection_request")
@Table(
name = "detection_request",
indexes = {
@Index(name = "idx_detection_request_status", columnList = "status"),
@Index(
name = "idx_detection_request_reuse",
columnList = "file_hash, analysis_mode, status, created_at"
)
}
)
public class DetectionRequestEntity {

@Id
Expand All @@ -28,10 +38,16 @@ public class DetectionRequestEntity {
private String clientType;
private String fileName;
private String filePath;

@Column(length = 64)
private String fileHash;
private String mimeType;
private Long fileSize;

@Column(length = 32)
private String status;

@Column(length = 32)
private String analysisMode;

@Column(length = 1000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class DetectionResultEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private Long requestId;
private boolean isDeepfake;
private double confidence;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import com.example.backend_spring.Entity.DetectionRequestEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
Expand All @@ -12,9 +17,33 @@ public interface DetectionRequestRepository extends JpaRepository<DetectionReque

List<DetectionRequestEntity> findByStatusIn(Collection<String> statuses);

List<DetectionRequestEntity> findByStatusInOrderByCreatedAtAsc(Collection<String> statuses);

List<DetectionRequestEntity> findByStatusInAndUpdatedAtBefore(
Collection<String> statuses,
LocalDateTime updatedBefore
);

Optional<DetectionRequestEntity> findFirstByFileHashAndAnalysisModeAndStatusInOrderByCreatedAtDesc(
String fileHash,
String analysisMode,
Collection<String> statuses
);

@Transactional
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update DetectionRequestEntity request
set request.status = :nextStatus,
request.failureMessage = :failureMessage,
request.updatedAt = CURRENT_TIMESTAMP
where request.id = :requestId
and request.status in :currentStatuses
""")
int transitionStatus(
@Param("requestId") Long requestId,
@Param("nextStatus") String nextStatus,
@Param("failureMessage") String failureMessage,
@Param("currentStatuses") Collection<String> currentStatuses
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,19 +125,35 @@ private void processJob(DetectionJob job) {
long processingStarted = System.currentTimeMillis();
activeProcessingCount.incrementAndGet();
try {
if (!claimQueuedRequest(job.requestId())) {
if (detectionResultRepository.findByRequestId(job.requestId()).isPresent()) {
transitionStatus(
job.requestId(),
DetectionStatus.DONE,
null,
DetectionStatus.pendingValues()
);
}
return;
}

DetectionRequestEntity requestEntity = detectionRequestRepository.findById(job.requestId())
.orElseThrow(() -> new IllegalStateException("Detection request not found: " + job.requestId()));

if (detectionResultRepository.findByRequestId(job.requestId()).isPresent()) {
requestEntity.setStatus(DetectionStatus.DONE.value());
detectionRequestRepository.save(requestEntity);
transitionStatus(
job.requestId(),
DetectionStatus.DONE,
null,
Set.of(DetectionStatus.PROCESSING.value())
);
totalCompletedCount.incrementAndGet();
return;
}

requestEntity.setStatus(DetectionStatus.PROCESSING.value());
requestEntity.setFailureMessage(null);
detectionRequestRepository.save(requestEntity);

AiPredictionDto aiResult = callAiServerWithRetry(Paths.get(job.filePath()), job.analysisMode());

Expand All @@ -154,19 +170,35 @@ private void processJob(DetectionJob job) {
detectionResultRepository.save(resultEntity);

requestEntity.setStatus(DetectionStatus.DONE.value());
detectionRequestRepository.save(requestEntity);
transitionStatus(
requestEntity.getId(),
DetectionStatus.DONE,
null,
Set.of(DetectionStatus.PROCESSING.value())
);
totalCompletedCount.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
detectionRequestRepository.findById(job.requestId()).ifPresent(requestEntity -> {
requestEntity.setStatus(DetectionStatus.QUEUED.value());
detectionRequestRepository.save(requestEntity);
transitionStatus(
job.requestId(),
DetectionStatus.QUEUED,
null,
Set.of(DetectionStatus.PROCESSING.value())
);
});
} catch (Exception e) {
detectionRequestRepository.findById(job.requestId()).ifPresent(requestEntity -> {
String failureMessage = buildFailureMessage(e);
requestEntity.setStatus(DetectionStatus.FAILED.value());
requestEntity.setFailureMessage(buildFailureMessage(e));
detectionRequestRepository.save(requestEntity);
requestEntity.setFailureMessage(failureMessage);
transitionStatus(
job.requestId(),
DetectionStatus.FAILED,
failureMessage,
Set.of(DetectionStatus.QUEUED.value(), DetectionStatus.PROCESSING.value())
);
});
totalFailedCount.incrementAndGet();
} finally {
Expand All @@ -177,23 +209,38 @@ private void processJob(DetectionJob job) {

private void recoverPendingRequests() {
Set<String> pendingStatuses = DetectionStatus.pendingValues();
List<DetectionRequestEntity> pendingRequests = detectionRequestRepository.findByStatusIn(pendingStatuses);
List<DetectionRequestEntity> pendingRequests = detectionRequestRepository.findByStatusInOrderByCreatedAtAsc(pendingStatuses);
for (DetectionRequestEntity requestEntity : pendingRequests) {
if (detectionResultRepository.findByRequestId(requestEntity.getId()).isPresent()) {
requestEntity.setStatus(DetectionStatus.DONE.value());
detectionRequestRepository.save(requestEntity);
transitionStatus(
requestEntity.getId(),
DetectionStatus.DONE,
null,
pendingStatuses
);
continue;
}

Path filePath = Paths.get(requestEntity.getFilePath());
if (!Files.exists(filePath)) {
requestEntity.setStatus(DetectionStatus.FAILED.value());
detectionRequestRepository.save(requestEntity);
transitionStatus(
requestEntity.getId(),
DetectionStatus.FAILED,
"Uploaded file is missing during startup recovery.",
pendingStatuses
);
continue;
}

requestEntity.setStatus(DetectionStatus.QUEUED.value());
detectionRequestRepository.save(requestEntity);
transitionStatus(
requestEntity.getId(),
DetectionStatus.QUEUED,
null,
pendingStatuses
);
if (!queue.offer(new DetectionJob(
requestEntity.getId(),
requestEntity.getFilePath(),
Expand All @@ -205,6 +252,29 @@ private void recoverPendingRequests() {
}
}

private boolean claimQueuedRequest(Long requestId) {
return transitionStatus(
requestId,
DetectionStatus.PROCESSING,
null,
Set.of(DetectionStatus.QUEUED.value())
);
}

private boolean transitionStatus(
Long requestId,
DetectionStatus nextStatus,
String failureMessage,
Set<String> currentStatuses
) {
return detectionRequestRepository.transitionStatus(
requestId,
nextStatus.value(),
failureMessage,
currentStatuses
) > 0;
}

private AiPredictionDto callAiServerWithRetry(Path filePath, String analysisMode) throws InterruptedException {
int attempts = Math.max(1, appProperties.getDetection().getAiRetryCount() + 1);
RuntimeException lastError = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.example.backend_spring.Service;

import com.example.backend_spring.Config.AppProperties;
import com.example.backend_spring.Entity.DetectionRequestEntity;
import com.example.backend_spring.Repository.DetectionRequestRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.Set;

@Service
public class DetectionUploadCleanupService {

private static final Logger log = LoggerFactory.getLogger(DetectionUploadCleanupService.class);

private final DetectionRequestRepository detectionRequestRepository;
private final AppProperties appProperties;

public DetectionUploadCleanupService(
DetectionRequestRepository detectionRequestRepository,
AppProperties appProperties
) {
this.detectionRequestRepository = detectionRequestRepository;
this.appProperties = appProperties;
}

@Scheduled(fixedDelayString = "${app.detection.upload-cleanup-interval-ms:3600000}")
public void cleanupExpiredUploadsOnSchedule() {
cleanupExpiredUploads();
}

public int cleanupExpiredUploads() {
int retentionDays = appProperties.getDetection().getUploadRetentionDays();
if (retentionDays < 0) {
return 0;
}

LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
Set<String> terminalStatuses = Set.of(
DetectionStatus.DONE.value(),
DetectionStatus.FAILED.value()
);
int deletedCount = 0;
for (DetectionRequestEntity request : detectionRequestRepository.findByStatusInAndUpdatedAtBefore(
terminalStatuses,
cutoff
)) {
if (deleteIfUploadFile(request.getFilePath())) {
deletedCount += 1;
}
}
return deletedCount;
}

private boolean deleteIfUploadFile(String rawFilePath) {
if (rawFilePath == null || rawFilePath.isBlank()) {
return false;
}

Path uploadRoot = Paths.get(appProperties.getUploadDir()).toAbsolutePath().normalize();
Path filePath = Paths.get(rawFilePath).toAbsolutePath().normalize();
if (!filePath.startsWith(uploadRoot)) {
log.warn("Skip upload cleanup outside upload directory: {}", filePath);
return false;
}

try {
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.warn("Failed to delete expired upload file: {}", filePath, e);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Development profile: keeps local demo behavior unchanged.
spring.datasource.url=jdbc:h2:mem:deepfake_db
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Loading