feat: implement reading progress tracking with Redis caching and API endpoints

This commit is contained in:
Rodrigo Verdiani 2026-04-16 13:13:00 -03:00
parent 1a41ae2a8d
commit 375bc4843b
15 changed files with 450 additions and 0 deletions

View File

@ -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

View File

@ -136,6 +136,10 @@
<version>7.5.8</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
<build>

View File

@ -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 {

View File

@ -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")));
}
}

View File

@ -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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
template.setValueSerializer(RedisSerializer.json());
template.setHashValueSerializer(RedisSerializer.json());
return template;
}
}

View File

@ -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<Void> 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<ReadingProgressDTO> 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<ReadingProgressDTO> 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());
}
}

View File

@ -0,0 +1,3 @@
package com.magamochi.userinteraction.model.dto;
public record ProgressUpdateDTO(Long mangaId, Long chapterId, Integer pageNumber) {}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<UserReadingProgress, UserReadingProgressId> {
Optional<UserReadingProgress> findFirstByIdUserIdAndMangaIdOrderByLastReadAtDesc(
Long userId, Long mangaId);
}

View File

@ -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<String, Object> 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<ReadingProgressDTO> 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<ReadingProgressDTO> 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<UserReadingProgress> 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();
}
}

View File

@ -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();
}
}

View File

@ -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:

View File

@ -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);