From 375bc4843bd752104340bacffdea5edc174b05cb Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Thu, 16 Apr 2026 13:13:00 -0300 Subject: [PATCH] feat: implement reading progress tracking with Redis caching and API endpoints --- docker-compose.yml | 13 ++ pom.xml | 4 + .../com/magamochi/MangamochiApplication.java | 2 + .../common/config/OpenApiConfig.java | 30 +++ .../magamochi/common/config/RedisConfig.java | 24 +++ .../controller/ReadingProgressController.java | 66 +++++++ .../model/dto/ProgressUpdateDTO.java | 3 + .../model/dto/ReadingProgressDTO.java | 9 + .../model/entity/UserReadingProgress.java | 41 ++++ .../model/entity/UserReadingProgressId.java | 21 ++ .../UserReadingProgressRepository.java | 14 ++ .../service/ReadingProgressService.java | 181 ++++++++++++++++++ .../task/ReadingProgressSyncTask.java | 20 ++ src/main/resources/application.yml | 6 + .../V0012__CREATE_USER_READING_PROGRESS.sql | 16 ++ 15 files changed, 450 insertions(+) create mode 100644 src/main/java/com/magamochi/common/config/OpenApiConfig.java create mode 100644 src/main/java/com/magamochi/common/config/RedisConfig.java create mode 100644 src/main/java/com/magamochi/userinteraction/controller/ReadingProgressController.java create mode 100644 src/main/java/com/magamochi/userinteraction/model/dto/ProgressUpdateDTO.java create mode 100644 src/main/java/com/magamochi/userinteraction/model/dto/ReadingProgressDTO.java create mode 100644 src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgress.java create mode 100644 src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgressId.java create mode 100644 src/main/java/com/magamochi/userinteraction/model/repository/UserReadingProgressRepository.java create mode 100644 src/main/java/com/magamochi/userinteraction/service/ReadingProgressService.java create mode 100644 src/main/java/com/magamochi/userinteraction/task/ReadingProgressSyncTask.java create mode 100644 src/main/resources/db/migration/V0012__CREATE_USER_READING_PROGRESS.sql diff --git a/docker-compose.yml b/docker-compose.yml index c98a5e5..862c7bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: - MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000 - MINIO_USER=admin - MINIO_PASS=!E9v4i0v3 + - REDIS_HOST=redis + - REDIS_PORT=6379 db: image: 'postgres:15' @@ -50,6 +52,17 @@ services: networks: - mangamochi-network + redis: + image: 'redis:7-alpine' + container_name: mangamochi_redis + profiles: + - all + - minimal + ports: + - "6379:6379" + networks: + - mangamochi-network + networks: mangamochi-network: driver: bridge diff --git a/pom.xml b/pom.xml index 8d22cc0..bea9041 100644 --- a/pom.xml +++ b/pom.xml @@ -136,6 +136,10 @@ 7.5.8 compile + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/src/main/java/com/magamochi/MangamochiApplication.java b/src/main/java/com/magamochi/MangamochiApplication.java index da8ab13..a82d4b6 100644 --- a/src/main/java/com/magamochi/MangamochiApplication.java +++ b/src/main/java/com/magamochi/MangamochiApplication.java @@ -5,11 +5,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.resilience.annotation.EnableResilientMethods; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableFeignClients @EnableScheduling +@EnableAsync @EnableRabbit @EnableResilientMethods public class MangamochiApplication { diff --git a/src/main/java/com/magamochi/common/config/OpenApiConfig.java b/src/main/java/com/magamochi/common/config/OpenApiConfig.java new file mode 100644 index 0000000..e8eb3fc --- /dev/null +++ b/src/main/java/com/magamochi/common/config/OpenApiConfig.java @@ -0,0 +1,30 @@ +package com.magamochi.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + final String securitySchemeName = "bearerAuth"; + return new OpenAPI() + .info(new Info().title("MangaMochi API").version("1.0")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components( + new Components() + .addSecuritySchemes( + securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/src/main/java/com/magamochi/common/config/RedisConfig.java b/src/main/java/com/magamochi/common/config/RedisConfig.java new file mode 100644 index 0000000..bc76507 --- /dev/null +++ b/src/main/java/com/magamochi/common/config/RedisConfig.java @@ -0,0 +1,24 @@ +package com.magamochi.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(RedisSerializer.string()); + template.setHashKeySerializer(RedisSerializer.string()); + template.setValueSerializer(RedisSerializer.json()); + template.setHashValueSerializer(RedisSerializer.json()); + + return template; + } +} diff --git a/src/main/java/com/magamochi/userinteraction/controller/ReadingProgressController.java b/src/main/java/com/magamochi/userinteraction/controller/ReadingProgressController.java new file mode 100644 index 0000000..5540efd --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/controller/ReadingProgressController.java @@ -0,0 +1,66 @@ +package com.magamochi.userinteraction.controller; + +import com.magamochi.user.service.UserService; +import com.magamochi.userinteraction.model.dto.ProgressUpdateDTO; +import com.magamochi.userinteraction.model.dto.ReadingProgressDTO; +import com.magamochi.userinteraction.service.ReadingProgressService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/progress") +@RequiredArgsConstructor +@Tag(name = "Reading Progress", description = "Endpoints for tracking user reading progress") +public class ReadingProgressController { + private final ReadingProgressService readingProgressService; + private final UserService userService; + + @PostMapping + @Operation( + summary = "Update reading progress", + description = "Stores current chapter and page for a manga in cache") + public ResponseEntity updateProgress(@RequestBody ProgressUpdateDTO request) { + var user = userService.getLoggedUserThrowIfNotFound(); + + var dto = + ReadingProgressDTO.builder() + .mangaId(request.mangaId()) + .chapterId(request.chapterId()) + .pageNumber(request.pageNumber()) + .build(); + + readingProgressService.saveProgressAsync(user.getId(), dto); + + return ResponseEntity.accepted().build(); + } + + @GetMapping("/{mangaId}") + @Operation( + summary = "Get reading progress", + description = "Retrieves the current reading progress for a specific manga") + public ResponseEntity getProgress(@PathVariable Long mangaId) { + var user = userService.getLoggedUserThrowIfNotFound(); + + return readingProgressService + .getProgress(user.getId(), mangaId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/{mangaId}/{chapterId}") + @Operation( + summary = "Get reading progress for chapter", + description = "Retrieves the reading progress for a specific chapter of a manga") + public ResponseEntity getProgress( + @PathVariable Long mangaId, @PathVariable Long chapterId) { + var user = userService.getLoggedUserThrowIfNotFound(); + + return readingProgressService + .getProgress(user.getId(), mangaId, chapterId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/com/magamochi/userinteraction/model/dto/ProgressUpdateDTO.java b/src/main/java/com/magamochi/userinteraction/model/dto/ProgressUpdateDTO.java new file mode 100644 index 0000000..eb917b4 --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/model/dto/ProgressUpdateDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.userinteraction.model.dto; + +public record ProgressUpdateDTO(Long mangaId, Long chapterId, Integer pageNumber) {} diff --git a/src/main/java/com/magamochi/userinteraction/model/dto/ReadingProgressDTO.java b/src/main/java/com/magamochi/userinteraction/model/dto/ReadingProgressDTO.java new file mode 100644 index 0000000..9a8979e --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/model/dto/ReadingProgressDTO.java @@ -0,0 +1,9 @@ +package com.magamochi.userinteraction.model.dto; + +import java.io.Serializable; +import java.time.Instant; +import lombok.Builder; + +@Builder +public record ReadingProgressDTO( + Long mangaId, Long chapterId, Integer pageNumber, Instant updatedAt) implements Serializable {} diff --git a/src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgress.java b/src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgress.java new file mode 100644 index 0000000..aae0f7d --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgress.java @@ -0,0 +1,41 @@ +package com.magamochi.userinteraction.model.entity; + +import com.magamochi.catalog.model.entity.Manga; +import com.magamochi.content.model.entity.MangaContent; +import com.magamochi.user.model.entity.User; +import jakarta.persistence.*; +import java.time.Instant; +import lombok.*; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "user_reading_progress") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserReadingProgress { + @EmbeddedId private UserReadingProgressId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("userId") + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "manga_id") + private Manga manga; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("mangaContentId") + @JoinColumn(name = "manga_content_id") + private MangaContent mangaContent; + + @Column(name = "page_number", nullable = false) + private Integer pageNumber; + + @UpdateTimestamp + @Column(name = "last_read_at", nullable = false) + private Instant lastReadAt; +} diff --git a/src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgressId.java b/src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgressId.java new file mode 100644 index 0000000..1bd29cb --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/model/entity/UserReadingProgressId.java @@ -0,0 +1,21 @@ +package com.magamochi.userinteraction.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.*; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +@Builder +public class UserReadingProgressId implements Serializable { + @Column(name = "user_id") + private Long userId; + + @Column(name = "manga_content_id") + private Long mangaContentId; +} diff --git a/src/main/java/com/magamochi/userinteraction/model/repository/UserReadingProgressRepository.java b/src/main/java/com/magamochi/userinteraction/model/repository/UserReadingProgressRepository.java new file mode 100644 index 0000000..1249e62 --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/model/repository/UserReadingProgressRepository.java @@ -0,0 +1,14 @@ +package com.magamochi.userinteraction.model.repository; + +import com.magamochi.userinteraction.model.entity.UserReadingProgress; +import com.magamochi.userinteraction.model.entity.UserReadingProgressId; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserReadingProgressRepository + extends JpaRepository { + Optional findFirstByIdUserIdAndMangaIdOrderByLastReadAtDesc( + Long userId, Long mangaId); +} diff --git a/src/main/java/com/magamochi/userinteraction/service/ReadingProgressService.java b/src/main/java/com/magamochi/userinteraction/service/ReadingProgressService.java new file mode 100644 index 0000000..2193b43 --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/service/ReadingProgressService.java @@ -0,0 +1,181 @@ +package com.magamochi.userinteraction.service; + +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + +import com.magamochi.catalog.model.repository.MangaRepository; +import com.magamochi.content.model.repository.MangaContentRepository; +import com.magamochi.user.model.entity.User; +import com.magamochi.user.repository.UserRepository; +import com.magamochi.userinteraction.model.dto.ReadingProgressDTO; +import com.magamochi.userinteraction.model.entity.UserReadingProgress; +import com.magamochi.userinteraction.model.entity.UserReadingProgressId; +import com.magamochi.userinteraction.model.repository.UserReadingProgressRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class ReadingProgressService { + private final UserReadingProgressRepository progressRepository; + private final UserRepository userRepository; + private final MangaRepository mangaRepository; + private final MangaContentRepository contentRepository; + + private final RedisTemplate redisTemplate; + + private static final String REDIS_KEY_PATTERN = "user:%d:progress:%d"; + private static final String REDIS_SEARCH_PATTERN = "user:*:progress:*"; + private static final String LATEST_FIELD = "_latest"; + + @Async + public void saveProgressAsync(Long userId, ReadingProgressDTO dto) { + log.debug( + "Saving progress to Redis for user {}: manga {}, chapter {}", + userId, + dto.mangaId(), + dto.chapterId()); + + var updatedDto = + ReadingProgressDTO.builder() + .mangaId(dto.mangaId()) + .chapterId(dto.chapterId()) + .pageNumber(dto.pageNumber()) + .updatedAt(Instant.now()) + .build(); + + var key = String.format(REDIS_KEY_PATTERN, userId, updatedDto.mangaId()); + + // Store per-chapter progress + redisTemplate.opsForHash().put(key, updatedDto.chapterId().toString(), updatedDto); + + // Track latest chapter read for this manga + redisTemplate.opsForHash().put(key, LATEST_FIELD, updatedDto.chapterId().toString()); + } + + public Optional getProgress(Long userId, Long mangaId) { + var key = String.format(REDIS_KEY_PATTERN, userId, mangaId); + + // Check latest chapter field + var latestChapterIdStr = (String) redisTemplate.opsForHash().get(key, LATEST_FIELD); + if (nonNull(latestChapterIdStr)) { + return getProgress(userId, mangaId, Long.parseLong(latestChapterIdStr)); + } + + // Fallback to SQL (find the most recently read chapter for this manga) + log.debug("No latest chapter in Redis, checking SQL for user {}: manga {}", userId, mangaId); + return progressRepository + .findFirstByIdUserIdAndMangaIdOrderByLastReadAtDesc(userId, mangaId) + .map(this::mapToDto); + } + + public Optional getProgress(Long userId, Long mangaId, Long chapterId) { + // Check Redis first + var key = String.format(REDIS_KEY_PATTERN, userId, mangaId); + var redisProgress = + (ReadingProgressDTO) redisTemplate.opsForHash().get(key, chapterId.toString()); + + if (nonNull(redisProgress)) { + log.debug( + "Found progress in Redis for user {}: manga {}, chapter {}", userId, mangaId, chapterId); + return Optional.of(redisProgress); + } + + // Fallback to SQL + log.debug( + "Progress not in Redis, checking SQL for user {}: manga {}, chapter {}", + userId, + mangaId, + chapterId); + return progressRepository + .findById(new UserReadingProgressId(userId, chapterId)) + .map(this::mapToDto); + } + + @Transactional + public void flushDirtyRecords() { + log.info("Flushing reading progress from Redis to SQL"); + + var keys = redisTemplate.keys(REDIS_SEARCH_PATTERN); + if (isNull(keys) || keys.isEmpty()) { + log.debug("No reading progress records to flush"); + return; + } + + List entitiesToSave = new ArrayList<>(); + for (var key : keys) { + try { + var userId = Long.parseLong(key.split(":")[1]); + var progressMap = redisTemplate.opsForHash().entries(key); + + var user = userRepository.findById(userId).orElse(null); + if (isNull(user)) { + log.warn("User {} not found, skipping progress flush", userId); + redisTemplate.delete(key); + continue; + } + + for (var entry : progressMap.entrySet()) { + if (entry.getKey().equals(LATEST_FIELD)) continue; + + var dto = (ReadingProgressDTO) entry.getValue(); + var entity = mapToEntity(user, dto); + if (nonNull(entity)) { + entitiesToSave.add(entity); + } + } + + // After processing, we delete from Redis to consider them flushed + // TODO: this should be improved + redisTemplate.delete(key); + } catch (Exception e) { + log.error("Error flushing progress for key {}: {}", key, e.getMessage(), e); + } + } + + if (!entitiesToSave.isEmpty()) { + progressRepository.saveAll(entitiesToSave); + log.info("Successfully flushed {} progress records to SQL", entitiesToSave.size()); + } + } + + private ReadingProgressDTO mapToDto(UserReadingProgress entity) { + return ReadingProgressDTO.builder() + .mangaId(entity.getManga().getId()) + .chapterId(entity.getMangaContent().getId()) + .pageNumber(entity.getPageNumber()) + .updatedAt(entity.getLastReadAt()) + .build(); + } + + private UserReadingProgress mapToEntity(User user, ReadingProgressDTO dto) { + var manga = mangaRepository.findById(dto.mangaId()).orElse(null); + var content = contentRepository.findById(dto.chapterId()).orElse(null); + + if (isNull(manga) || isNull(content)) { + log.warn( + "Manga {} or Content {} not found, skipping entity mapping", + dto.mangaId(), + dto.chapterId()); + return null; + } + + return UserReadingProgress.builder() + .id(new UserReadingProgressId(user.getId(), content.getId())) + .user(user) + .manga(manga) + .mangaContent(content) + .pageNumber(dto.pageNumber()) + .lastReadAt(dto.updatedAt()) + .build(); + } +} diff --git a/src/main/java/com/magamochi/userinteraction/task/ReadingProgressSyncTask.java b/src/main/java/com/magamochi/userinteraction/task/ReadingProgressSyncTask.java new file mode 100644 index 0000000..1a73cba --- /dev/null +++ b/src/main/java/com/magamochi/userinteraction/task/ReadingProgressSyncTask.java @@ -0,0 +1,20 @@ +package com.magamochi.userinteraction.task; + +import com.magamochi.userinteraction.service.ReadingProgressService; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class ReadingProgressSyncTask { + private final ReadingProgressService readingProgressService; + + @Scheduled(fixedRateString = "5m") + public void syncProgress() { + log.debug("Triggering scheduled reading progress sync"); + readingProgressService.flushDirtyRecords(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b2e3a9..a5204dd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,12 @@ spring: listener: simple: default-requeue-rejected: false + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + repositories: + enabled: false springdoc: api-docs: diff --git a/src/main/resources/db/migration/V0012__CREATE_USER_READING_PROGRESS.sql b/src/main/resources/db/migration/V0012__CREATE_USER_READING_PROGRESS.sql new file mode 100644 index 0000000..fa6cdb3 --- /dev/null +++ b/src/main/resources/db/migration/V0012__CREATE_USER_READING_PROGRESS.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS user_reading_progress +( + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + manga_id BIGINT NOT NULL REFERENCES mangas (id) ON DELETE CASCADE, + manga_content_id BIGINT NOT NULL REFERENCES manga_contents (id) ON DELETE CASCADE, + page_number INT NOT NULL, + last_read_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, manga_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_reading_progress_user_id ON user_reading_progress (user_id); + +ALTER TABLE user_reading_progress + DROP CONSTRAINT IF EXISTS user_reading_progress_pkey; +ALTER TABLE user_reading_progress + ADD PRIMARY KEY (user_id, manga_content_id);