Merge pull request 'feat: implement reading progress tracking with Redis caching and API endpoints' (#51) from feat/redis into main
Reviewed-on: #51
This commit is contained in:
commit
1ed1d380db
@ -20,6 +20,8 @@ services:
|
|||||||
- MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000
|
- MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000
|
||||||
- MINIO_USER=admin
|
- MINIO_USER=admin
|
||||||
- MINIO_PASS=!E9v4i0v3
|
- MINIO_PASS=!E9v4i0v3
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: 'postgres:15'
|
image: 'postgres:15'
|
||||||
@ -50,6 +52,17 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mangamochi-network
|
- mangamochi-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: 'redis:7-alpine'
|
||||||
|
container_name: mangamochi_redis
|
||||||
|
profiles:
|
||||||
|
- all
|
||||||
|
- minimal
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- mangamochi-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mangamochi-network:
|
mangamochi-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
4
pom.xml
4
pom.xml
@ -136,6 +136,10 @@
|
|||||||
<version>7.5.8</version>
|
<version>7.5.8</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import org.springframework.boot.SpringApplication;
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
import org.springframework.resilience.annotation.EnableResilientMethods;
|
import org.springframework.resilience.annotation.EnableResilientMethods;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableFeignClients
|
@EnableFeignClients
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@EnableAsync
|
||||||
@EnableRabbit
|
@EnableRabbit
|
||||||
@EnableResilientMethods
|
@EnableResilientMethods
|
||||||
public class MangamochiApplication {
|
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:
|
listener:
|
||||||
simple:
|
simple:
|
||||||
default-requeue-rejected: false
|
default-requeue-rejected: false
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
repositories:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
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