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