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
51 changes: 51 additions & 0 deletions gradox2/src/main/java/com/example/gradox2/init/BackfillRunner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.example.gradox2.init;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import com.example.gradox2.persistence.entities.File;
import com.example.gradox2.persistence.repository.FileRepository;
import com.example.gradox2.service.implementation.S3StorageService;

import lombok.RequiredArgsConstructor;

/**
* One-off: rellena el tamaño (sizeBytes) de archivos publicados antes de que
* la columna existiera, consultando S3 (headObject). Es idempotente: solo
* procesa registros con tamaño nulo y tolera objetos inaccesibles.
*/
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
@RequiredArgsConstructor
public class BackfillRunner implements CommandLineRunner {

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

private final FileRepository fileRepository;
private final S3StorageService s3StorageService;

@Override
@Transactional
public void run(String... args) {
var files = fileRepository.findBySizeBytesIsNull();
if (files.isEmpty()) {
return;
}

int updated = 0;
for (File file : files) {
Long size = s3StorageService.sizeOf(file.getObjectKey());
if (size != null) {
file.setSizeBytes(size);
updated++;
}
}
fileRepository.saveAll(files);
log.info("Backfill de tamaños de archivos completado: {} actualizados de {} pendientes.", updated, files.size());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ public class File {
private String objectKey;
private String fileHash;

@Column(name = "size_bytes")
private Long sizeBytes;

@Builder.Default
@Column(name = "download_count", nullable = false)
private Long downloadCount = 0L;

@Builder.Default
private Instant uploadAt = Instant.now();

Expand Down Expand Up @@ -70,6 +77,13 @@ public void removeScore(Score score) {
this.score = calculateTotalScore();
}

public void incrementDownloadCount() {
if (this.downloadCount == null) {
this.downloadCount = 0L;
}
this.downloadCount++;
}

// El score total ahora se puede calcular dinámicamente
public Double calculateTotalScore() {
return scores.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public class TempFile {
private String objectKey;
private String fileHash;

@Column(name = "size_bytes")
private Long sizeBytes;

@Builder.Default
private Instant uploadAt = Instant.now();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import com.example.gradox2.persistence.entities.File;
import com.example.gradox2.persistence.entities.enums.FileType;
Expand All @@ -14,4 +15,35 @@ public interface FileRepository extends JpaRepository<File, Long> {
List<File> findBySubjectId(Long subjectId);
List<File> findByUploaderId(Long uploaderId);
List<File> findByUploaderIdAndSubjectId(Long uploaderId, Long subjectId);
List<File> findBySizeBytesIsNull();

//Stats
@Query("SELECT COALESCE(SUM(f.sizeBytes), 0) FROM File f")
Long sumSizeBytes();

@Query("SELECT COALESCE(SUM(f.downloadCount), 0) FROM File f")
Long sumDownloadCount();

@Query("SELECT f.type AS type, COUNT(f) AS count FROM File f GROUP BY f.type ORDER BY COUNT(f) DESC")
List<FileTypeCount> countByType();

@Query("SELECT s.code AS code, s.name AS name, c.name AS courseName, COUNT(f) AS count "
+ "FROM Subject s "
+ "LEFT JOIN s.resources f "
+ "LEFT JOIN s.course c "
+ "GROUP BY s.id, s.code, s.name, c.name "
+ "ORDER BY COUNT(f) DESC")
List<SubjectFileCount> countBySubject();

interface FileTypeCount {
FileType getType();
long getCount();
}

interface SubjectFileCount {
String getCode();
String getName();
String getCourseName();
long getCount();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.gradox2.presentation.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.gradox2.presentation.dto.stats.PlatformStatsResponse;
import com.example.gradox2.service.interfaces.IStatsService;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

@RestController
@RequestMapping("/stats")
@Tag(name = "Estadísticas", description = "Métricas colectivas de la plataforma para la comunidad")
public class StatsController {

private final IStatsService statsService;

public StatsController(IStatsService statsService) {
this.statsService = statsService;
}

@GetMapping
@Operation(summary = "Estadísticas de la plataforma", description = "Métricas globales: total de archivos, usuarios, almacenamiento y descargas, con desglose por tipo y asignatura")
@ApiResponse(responseCode = "200", description = "Estadísticas devueltas")
public ResponseEntity<PlatformStatsResponse> getPlatformStats() {
return ResponseEntity.ok(statsService.getPlatformStats());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.example.gradox2.presentation.dto.stats;

import java.util.List;

import com.example.gradox2.persistence.entities.enums.FileType;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Métricas colectivas de la plataforma para la comunidad")
public class PlatformStatsResponse {

@Schema(description = "Número total de archivos publicados", example = "124")
private long totalFiles;

@Schema(description = "Número total de usuarios registrados", example = "532")
private long totalUsers;

@Schema(description = "Tamaño total almacenado en bytes", example = "1536000000")
private long totalStorageBytes;

@Schema(description = "Número total de descargas realizadas", example = "8421")
private long totalDownloads;

@Schema(description = "Desglose de archivos por tipo")
private List<TypeStat> byType;

@Schema(description = "Desglose de archivos por asignatura")
private List<SubjectStat> bySubject;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Archivos por tipo")
public static class TypeStat {
@Schema(description = "Tipo de archivo", example = "APUNTES")
private FileType type;

@Schema(description = "Número de archivos de este tipo", example = "80")
private long count;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Archivos por asignatura")
public static class SubjectStat {
@Schema(description = "Código de la asignatura", example = "MAT1")
private String code;

@Schema(description = "Nombre de la asignatura", example = "Matemáticas I")
private String name;

@Schema(description = "Nombre del curso al que pertenece", example = "Primer curso")
private String courseName;

@Schema(description = "Número de archivos de esta asignatura", example = "12")
private long count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(HttpMethod.GET, "/files/**").permitAll()
.requestMatchers(HttpMethod.GET, "/subjects/**").permitAll()
.requestMatchers(HttpMethod.GET, "/badges/**").permitAll()
.requestMatchers(HttpMethod.GET, "/stats/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/health**", "/actuator/health/**", "/actuator/info").permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public FileProposalResponse uploadFileProposal(UploadFileProposalRequest dto) {
.type(dto.getType())
.objectKey(objectKey)
.fileHash(generateFileHash(fileData)) // Hash seguro
.sizeBytes((long) fileData.length)
.subject(subject)
.uploader(uploader)
.visibilityLevel(dto.getVisibilityLevel() != null ? dto.getVisibilityLevel() : FileVisibility.PUBLIC)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public FileServiceImpl(FileRepository fileRepository,
this.fileUrlSigner = fileUrlSigner;
}

@Transactional
public FileDownloadResponse downloadFile(Long id) {
File file = fileRepository.findById(id)
.orElseThrow(() -> new NotFoundException("File not found"));
Expand All @@ -69,6 +70,9 @@ public FileDownloadResponse downloadFile(Long id) {
throw new NotFoundException("File not found");
}

file.incrementDownloadCount();
fileRepository.save(file);

String url = fileUrlSigner.presignedGetUrl(file.getObjectKey(),
ContentDisposition.attachmentOf(file.getTitle()));
return FileDownloadResponse.builder().url(url).build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
Expand Down Expand Up @@ -60,4 +61,21 @@ public void delete(String key) {
.key(key)
.build());
}

/**
* Tamaño en bytes del objeto, o {@code null} si no se puede determinar.
*/
public Long sizeOf(String key) {
if (key == null || key.isBlank()) {
return null;
}
try {
return s3Client.headObject(HeadObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build()).contentLength();
} catch (S3Exception e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.gradox2.service.implementation;

import java.util.stream.Collectors;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.gradox2.persistence.repository.FileRepository;
import com.example.gradox2.persistence.repository.UserRepository;
import com.example.gradox2.presentation.dto.stats.PlatformStatsResponse;
import com.example.gradox2.service.interfaces.IStatsService;

@Service
public class StatsServiceImpl implements IStatsService {

private final FileRepository fileRepository;
private final UserRepository userRepository;

public StatsServiceImpl(FileRepository fileRepository, UserRepository userRepository) {
this.fileRepository = fileRepository;
this.userRepository = userRepository;
}

@Override
@Transactional(readOnly = true)
public PlatformStatsResponse getPlatformStats() {
return PlatformStatsResponse.builder()
.totalFiles(fileRepository.count())
.totalUsers(userRepository.count())
.totalStorageBytes(fileRepository.sumSizeBytes())
.totalDownloads(fileRepository.sumDownloadCount())
.byType(fileRepository.countByType().stream()
.map(stat -> PlatformStatsResponse.TypeStat.builder()
.type(stat.getType())
.count(stat.getCount())
.build())
.collect(Collectors.toList()))
.bySubject(fileRepository.countBySubject().stream()
.map(stat -> PlatformStatsResponse.SubjectStat.builder()
.code(stat.getCode())
.name(stat.getName())
.courseName(stat.getCourseName())
.count(stat.getCount())
.build())
.collect(Collectors.toList()))
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ private void applyFileProposal(FileProposal fileProposal) {
.type(tempFile.getType())
.objectKey(tempFile.getObjectKey())
.fileHash(tempFile.getFileHash())
.sizeBytes(tempFile.getSizeBytes())
.uploader(tempFile.getUploader())
.subject(tempFile.getSubject())
.visibilityLevel(tempFile.getVisibilityLevel())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.gradox2.service.interfaces;

import com.example.gradox2.presentation.dto.stats.PlatformStatsResponse;

public interface IStatsService {

PlatformStatsResponse getPlatformStats();
}
7 changes: 7 additions & 0 deletions gradox2/src/main/resources/db/migration/V4__file_stats.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- V4: métricas comunitarias de archivos.
-- Guardamos el tamaño (bytes) y el contador de descargas para las estadísticas /stats.

alter table files add column size_bytes bigint;
alter table files add column download_count bigint not null default 0;

alter table temp_files add column size_bytes bigint;
Loading
Loading