feat: implement reading progress tracking with Redis caching and API endpoints
This commit is contained in:
parent
1a41ae2a8d
commit
375bc4843b
@ -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
|
||||
|
||||
4
pom.xml
4
pom.xml
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
30
src/main/java/com/magamochi/common/config/OpenApiConfig.java
Normal file
30
src/main/java/com/magamochi/common/config/OpenApiConfig.java
Normal 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")));
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/magamochi/common/config/RedisConfig.java
Normal file
24
src/main/java/com/magamochi/common/config/RedisConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.magamochi.userinteraction.model.dto;
|
||||
|
||||
public record ProgressUpdateDTO(Long mangaId, Long chapterId, Integer pageNumber) {}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
Loading…
x
Reference in New Issue
Block a user