Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ build/
# REST Docs
build/generated-snippets/

# Static resources
src/main/resources/static/docs/*.html

/src/main/resources/application-secret.properties

db/postgres
Expand Down
29 changes: 25 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dependencies {

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'

// lombok
compileOnly 'org.projectlombok:lombok'
Expand Down Expand Up @@ -169,19 +170,39 @@ test {
jacocoTestCoverageVerification {
violationRules {
rule {
element = 'CLASS'
includes = ['app.nook.*.service.*']
excludes = [
'app.nook.*.service.*Test*',
'app.nook.user.service.CustomUserDetailService',
'app.nook.**.exception.**',
'app.nook.**.*ErrorCode*',
'app.nook.**.enums.**'
]
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.60
}
}
rule {
element = 'CLASS'
includes = ['app.nook.*.controller.*']
excludes = [
'app.nook.*.controller.*Test*',
'app.nook.**.exception.**',
'app.nook.**.*ErrorCode*',
'app.nook.**.enums.**'
]
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.40
}
}
}
}

jacocoTestCoverageVerification {
enabled = false // 임시
}


sourceSets {
main {
Expand Down
16 changes: 16 additions & 0 deletions src/docs/asciidoc/library.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,19 @@ include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/h
include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/request-headers.adoc[]
include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/http-response.adoc[]
include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/response-fields.adoc[]

=== 서재 월별 포커스 통계 조회

include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/http-request.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/request-headers.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/query-parameters.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/http-response.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/response-fields.adoc[]

=== 서재 월별 포커스 시간 통계 조회

include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/http-request.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/request-headers.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/query-parameters.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/http-response.adoc[]
include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/response-fields.adoc[]
8 changes: 7 additions & 1 deletion src/main/generated/app/nook/library/domain/QLibrary.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,24 @@ public class QLibrary extends EntityPathBase<Library> {

public final app.nook.book.domain.QBook book;

public final ListPath<app.nook.timeline.domain.BookTimeLine, app.nook.timeline.domain.QBookTimeLine> bookTimeLines = this.<app.nook.timeline.domain.BookTimeLine, app.nook.timeline.domain.QBookTimeLine>createList("bookTimeLines", app.nook.timeline.domain.BookTimeLine.class, app.nook.timeline.domain.QBookTimeLine.class, PathInits.DIRECT2);

//inherited
public final DateTimePath<java.time.LocalDateTime> createdDate = _super.createdDate;

public final DatePath<java.time.LocalDate> endedAt = createDate("endedAt", java.time.LocalDate.class);

public final NumberPath<Long> focusMin = createNumber("focusMin", Long.class);
public final ListPath<app.nook.focus.domain.Focus, app.nook.focus.domain.QFocus> focuses = this.<app.nook.focus.domain.Focus, app.nook.focus.domain.QFocus>createList("focuses", app.nook.focus.domain.Focus.class, app.nook.focus.domain.QFocus.class, PathInits.DIRECT2);

public final NumberPath<Long> focusSec = createNumber("focusSec", Long.class);

public final NumberPath<Long> id = createNumber("id", Long.class);

//inherited
public final DateTimePath<java.time.LocalDateTime> modifiedDate = _super.modifiedDate;

public final NumberPath<Integer> page = createNumber("page", Integer.class);

public final EnumPath<app.nook.library.domain.enums.ReadingStatus> readingStatus = createEnum("readingStatus", app.nook.library.domain.enums.ReadingStatus.class);

public final DatePath<java.time.LocalDate> startedAt = createDate("startedAt", java.time.LocalDate.class);
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/app/nook/NookApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
public class NookApplication {

public static void main(String[] args) {
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/app/nook/focus/domain/Focus.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
name = "focuses",
indexes = {
@Index(
name = "idx_focus_library",
columnList = "library_id"
name = "idx_focus_library_started",
columnList = "library_id, started_at"
)
}
)
Expand Down
138 changes: 138 additions & 0 deletions src/main/java/app/nook/focus/repository/FocusRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package app.nook.focus.repository;

import app.nook.focus.domain.Focus;
import app.nook.user.domain.User;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.util.List;

public interface FocusRepository extends JpaRepository<Focus, Long> {

interface FocusYearMonthProjection {
Integer getYearValue();
Integer getMonthValue();
}

interface MonthlyFocusStatsProjection {
Integer getYearValue();
Integer getMonthValue();
Integer getDayValue();
Long getBookId();
String getCoverImageUrl();
Long getTotalSec();
}

interface FocusTimeStatsProjection {
Integer getYearValue();
Integer getMonthValue();
Integer getDayValue();
Long getTotalSec();
}

interface FocusRangeProjection {
LocalDateTime getStartedAt();
LocalDateTime getEndedAt();
}

@Query("""
select
EXTRACT(YEAR FROM f.startedAt) as yearValue,
EXTRACT(MONTH FROM f.startedAt) as monthValue,
EXTRACT(DAY FROM f.startedAt) as dayValue,
f.library.book.id as bookId,
f.library.book.coverImageUrl as coverImageUrl,
sum(f.durationSec) as totalSec
from Focus f
join f.library l
where l.user.id = :userId
and f.startedAt >= :start
and f.startedAt < :end
group by
EXTRACT(YEAR FROM f.startedAt),
EXTRACT(MONTH FROM f.startedAt),
EXTRACT(DAY FROM f.startedAt),
f.library.book.id,
f.library.book.coverImageUrl
order by sum(f.durationSec) desc
""")
List<MonthlyFocusStatsProjection> findMonthlyFocusStats(
@Param("userId") Long userId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);

@Query("""
select distinct
EXTRACT(YEAR FROM f.startedAt) as yearValue,
EXTRACT(MONTH FROM f.startedAt) as monthValue
from Focus f
where f.library.id = :libraryId
and f.library.user.id = :userId
""")
List<FocusYearMonthProjection> findDistinctFocusYearMonthsByLibraryAndUser(
@Param("libraryId") Long libraryId,
@Param("userId") Long userId
);


// 포커스 통계 조회
@Query("""
select
EXTRACT(YEAR FROM f.startedAt) as yearValue,
EXTRACT(MONTH FROM f.startedAt) as monthValue,
EXTRACT(DAY FROM f.startedAt) as dayValue,
sum(f.durationSec) as totalSec
from Focus f
join f.library l
where l.user.id = :userId
and f.startedAt >= :start
and f.startedAt < :end
group by
EXTRACT(YEAR FROM f.startedAt),
EXTRACT(MONTH FROM f.startedAt),
EXTRACT(DAY FROM f.startedAt)
""")
List<FocusTimeStatsProjection> findFocusTimeStats(
@Param("userId") Long userId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);

@Query("""
select
f.startedAt as startedAt,
f.endedAt as endedAt
from Focus f
join f.library l
where l.user.id = :userId
and f.endedAt is not null
and f.startedAt < :end
and f.endedAt > :start
""")
List<FocusRangeProjection> findOverlappingFocusRanges(
@Param("userId") Long userId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);

@Query("""
select f
from Focus f
where f.library.user = :user
and (:cursor is null or f.id < :cursor)
and f.startedAt >= :start
and f.startedAt < :end
order by f.id desc
""")
Slice<Focus> findByLibraryWithCursorByDate(
@Param("user") User user,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("cursor") Long cursor,
Pageable pageable);
}
49 changes: 49 additions & 0 deletions src/main/java/app/nook/global/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package app.nook.global.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {

RedisCacheConfiguration defaultConfig =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();

cacheConfigs.put("libraryMonthlyCurrent",
defaultConfig.entryTtl(Duration.ofMinutes(3)));

cacheConfigs.put("libraryMonthlyPast",
defaultConfig.entryTtl(Duration.ofHours(24)));

cacheConfigs.put("focusMonthlyCurrent",
defaultConfig.entryTtl(Duration.ofMinutes(3)));

cacheConfigs.put("libraryStatusFirstPage",
defaultConfig.entryTtl(Duration.ofMinutes(2)));

return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
30 changes: 30 additions & 0 deletions src/main/java/app/nook/library/controller/LibraryController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import app.nook.global.response.ApiResponse;
import app.nook.global.response.SuccessCode;
import app.nook.global.dto.CursorResponse;
import app.nook.library.domain.enums.ReadingStatus;
import app.nook.library.dto.ReadingStatusRequestDto;
import app.nook.library.dto.LibraryViewDto;
Expand All @@ -11,10 +12,13 @@
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/library")
Expand Down Expand Up @@ -65,4 +69,30 @@ public ApiResponse<LibraryViewDto.StatusBookResponseDto> viewBooksByStatus(
libraryService.viewBooksByStatus(userDetails.getUser(), status, cursor, size);
return ApiResponse.onSuccess(response, SuccessCode.OK);
}

// 서재 책 개수 조회
@GetMapping("/count")
public ApiResponse<LibraryViewDto.BookCountResponseDto> viewBookCount(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
LibraryViewDto.BookCountResponseDto response =
libraryService.countBooks(userDetails.getUser());
return ApiResponse.onSuccess(response, SuccessCode.OK);
}

// 해당 날짜의 포커스 기록 반환
@GetMapping("/focus-records")
public ApiResponse<CursorResponse<LibraryViewDto.UserBookResponseDto>> viewFocusRecordByDate(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate date,
@RequestParam(required = false) @Min(0) Long cursor,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
) {
CursorResponse<LibraryViewDto.UserBookResponseDto> response =
libraryService.viewFocusRecordByDate(userDetails.getUser(), date, cursor, size);
return ApiResponse.onSuccess(response, SuccessCode.OK);
}
}
Loading