diff --git a/api/src/main/java/com/pinback/api/article/controller/ArticleControllerV2.java b/api/src/main/java/com/pinback/api/article/controller/ArticleControllerV2.java new file mode 100644 index 0000000..f7370f8 --- /dev/null +++ b/api/src/main/java/com/pinback/api/article/controller/ArticleControllerV2.java @@ -0,0 +1,60 @@ +package com.pinback.api.article.controller; + +import java.time.LocalDateTime; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.pinback.application.article.dto.query.PageQuery; +import com.pinback.application.article.dto.response.ReadRemindArticleResponse; +import com.pinback.application.article.dto.response.TodayRemindResponseV2; +import com.pinback.application.article.port.in.GetArticlePort; +import com.pinback.application.article.port.in.UpdateArticleStatusPort; +import com.pinback.domain.user.entity.User; +import com.pinback.shared.annotation.CurrentUser; +import com.pinback.shared.dto.ResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/v2/articles") +@RequiredArgsConstructor +@Tag(name = "ArticleV2", description = "아티클 관리 API V2") +public class ArticleControllerV2 { + private final GetArticlePort getArticlePort; + private final UpdateArticleStatusPort updateArticleStatusPort; + + @Operation(summary = "리마인드 아티클 조회 v2", description = "오늘 리마인드할 아티클을 읽음/안읽음 상태별로 조회합니다.") + @GetMapping("/remind") + public ResponseDto getRemindArticlesV2( + @Parameter(hidden = true) @CurrentUser User user, + @Parameter(description = "현재 시간", example = "2025-09-03T10:00:00") @RequestParam LocalDateTime now, + @Parameter(description = "읽음 상태 (true: 읽음, false: 안읽음)", example = "true") @RequestParam(name = "read-status") boolean readStatus, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "8") int size + ) { + PageQuery query = new PageQuery(page, size); + TodayRemindResponseV2 response = getArticlePort.getRemindArticlesV2(user, now, readStatus, query); + return ResponseDto.ok(response); + } + + @Operation(summary = "리마인드 아티클 읽음 상태 변경 v2", description = "리마인드 아티클의 읽음 상태를 변경합니다") + @PatchMapping("/remind/{articleId}/read-status") + public ResponseDto updateRemindArticleStatus( + @Parameter(hidden = true) @CurrentUser User user, + @Parameter(description = "아티클 ID") @PathVariable Long articleId + ) { + ReadRemindArticleResponse response = updateArticleStatusPort.updateRemindArticleStatus(user, articleId); + return ResponseDto.ok(response); + } + +} diff --git a/application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDtoV2.java b/application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDtoV2.java new file mode 100644 index 0000000..4f32272 --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDtoV2.java @@ -0,0 +1,14 @@ +package com.pinback.application.article.dto; + +import org.springframework.data.domain.Page; + +import com.pinback.domain.article.entity.Article; + +public record RemindArticlesWithCountDtoV2( + boolean hasNext, + long readCount, + long unreadCount, + long totalCount, + Page
articles +) { +} diff --git a/application/src/main/java/com/pinback/application/article/dto/response/ReadRemindArticleResponse.java b/application/src/main/java/com/pinback/application/article/dto/response/ReadRemindArticleResponse.java new file mode 100644 index 0000000..032a101 --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/response/ReadRemindArticleResponse.java @@ -0,0 +1,11 @@ +package com.pinback.application.article.dto.response; + +public record ReadRemindArticleResponse( + boolean isReadAfterRemind, + int finalAcornCount, + boolean isCollected +) { + public static ReadRemindArticleResponse of(boolean readAfterRemind, int finalAcornCount, boolean isCollected) { + return new ReadRemindArticleResponse(readAfterRemind, finalAcornCount, isCollected); + } +} diff --git a/application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponseV2.java b/application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponseV2.java new file mode 100644 index 0000000..8585fec --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponseV2.java @@ -0,0 +1,30 @@ +package com.pinback.application.article.dto.response; + +import java.time.LocalDateTime; + +import com.pinback.application.category.dto.response.CategoryResponse; +import com.pinback.domain.article.entity.Article; + +public record RemindArticleResponseV2( + long articleId, + String url, + String memo, + LocalDateTime createdAt, + boolean isRead, + boolean isReadAfterRemind, + LocalDateTime remindAt, + CategoryResponse category +) { + public static RemindArticleResponseV2 from(Article article) { + return new RemindArticleResponseV2( + article.getId(), + article.getUrl(), + article.getMemo(), + article.getCreatedAt(), + article.isRead(), + article.isReadAfterRemind(), + article.getRemindAt(), + CategoryResponse.from(article.getCategory()) + ); + } +} diff --git a/application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponseV2.java b/application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponseV2.java new file mode 100644 index 0000000..ea16573 --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponseV2.java @@ -0,0 +1,21 @@ +package com.pinback.application.article.dto.response; + +import java.util.List; + +public record TodayRemindResponseV2( + boolean hasNext, + long totalArticleCount, + long readArticleCount, + long unreadArticleCount, + List articles +) { + public static TodayRemindResponseV2 of( + boolean hasNext, + long totalArticleCount, + long readArticleCount, + long unreadArticleCount, + List articles + ) { + return new TodayRemindResponseV2(hasNext, totalArticleCount, readArticleCount, unreadArticleCount, articles); + } +} diff --git a/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java b/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java index 66b128d..8d6d7f4 100644 --- a/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java +++ b/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java @@ -7,6 +7,7 @@ import com.pinback.application.article.dto.response.ArticlesPageResponse; import com.pinback.application.article.dto.response.GetAllArticlesResponse; import com.pinback.application.article.dto.response.TodayRemindResponse; +import com.pinback.application.article.dto.response.TodayRemindResponseV2; import com.pinback.domain.user.entity.User; public interface GetArticlePort { @@ -21,4 +22,6 @@ public interface GetArticlePort { ArticlesPageResponse getUnreadArticles(User user, PageQuery query); TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boolean readStatus, PageQuery query); + + TodayRemindResponseV2 getRemindArticlesV2(User user, LocalDateTime now, boolean readStatus, PageQuery query); } diff --git a/application/src/main/java/com/pinback/application/article/port/in/UpdateArticleStatusPort.java b/application/src/main/java/com/pinback/application/article/port/in/UpdateArticleStatusPort.java index 3a55631..4898499 100644 --- a/application/src/main/java/com/pinback/application/article/port/in/UpdateArticleStatusPort.java +++ b/application/src/main/java/com/pinback/application/article/port/in/UpdateArticleStatusPort.java @@ -1,8 +1,11 @@ package com.pinback.application.article.port.in; import com.pinback.application.article.dto.response.ReadArticleResponse; +import com.pinback.application.article.dto.response.ReadRemindArticleResponse; import com.pinback.domain.user.entity.User; public interface UpdateArticleStatusPort { ReadArticleResponse updateArticleStatus(User user, long articleId); + + ReadRemindArticleResponse updateRemindArticleStatus(User user, long articleId); } diff --git a/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java b/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java index ca9e427..688203f 100644 --- a/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java +++ b/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java @@ -9,6 +9,7 @@ import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; import com.pinback.application.article.dto.RemindArticlesWithCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDtoV2; import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; import com.pinback.domain.user.entity.User; @@ -32,6 +33,10 @@ public interface ArticleGetServicePort { Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead); - RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable, + RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime startDateTime, + LocalDateTime endDateTime, Pageable pageable, Boolean isRead); + + RemindArticlesWithCountDtoV2 findTodayRemindWithCountV2(User user, LocalDateTime startDateTime, + LocalDateTime endDateTime, Pageable pageable, Boolean isReadAfterRemind); } diff --git a/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleStatusUsecase.java b/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleStatusUsecase.java index a8b028e..eaafddf 100644 --- a/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleStatusUsecase.java +++ b/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleStatusUsecase.java @@ -5,6 +5,7 @@ import com.pinback.application.article.dto.AcornCollectResult; import com.pinback.application.article.dto.response.ReadArticleResponse; +import com.pinback.application.article.dto.response.ReadRemindArticleResponse; import com.pinback.application.article.port.in.UpdateArticleStatusPort; import com.pinback.application.article.port.out.ArticleGetServicePort; import com.pinback.application.user.port.in.ManageAcornPort; @@ -38,4 +39,19 @@ public ReadArticleResponse updateArticleStatus(User user, long articleId) { return ReadArticleResponse.of(currentAcorns, false); } + + @Override + public ReadRemindArticleResponse updateRemindArticleStatus(User user, long articleId) { + Article article = articleGetService.findByUserAndId(user, articleId); + int currentAcorns = manageAcornPort.getCurrentAcorns(user.getId()); + log.info("수집하기 전 도토리 수: {}", currentAcorns); + + if (!article.isReadAfterRemind()) { + article.markAsReadAfterRemind(); + AcornCollectResult result = manageAcornPort.tryCollectAcorns(user); + return ReadRemindArticleResponse.of(article.isReadAfterRemind(), result.finalAcornCount(), + result.isCollected()); + } + return ReadRemindArticleResponse.of(article.isReadAfterRemind(), currentAcorns, false); + } } diff --git a/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java b/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java index 2f1b43e..778e460 100644 --- a/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java +++ b/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -11,13 +12,16 @@ import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; import com.pinback.application.article.dto.RemindArticlesWithCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDtoV2; import com.pinback.application.article.dto.query.PageQuery; import com.pinback.application.article.dto.response.ArticleDetailResponse; import com.pinback.application.article.dto.response.ArticleResponse; import com.pinback.application.article.dto.response.ArticlesPageResponse; import com.pinback.application.article.dto.response.GetAllArticlesResponse; import com.pinback.application.article.dto.response.RemindArticleResponse; +import com.pinback.application.article.dto.response.RemindArticleResponseV2; import com.pinback.application.article.dto.response.TodayRemindResponse; +import com.pinback.application.article.dto.response.TodayRemindResponseV2; import com.pinback.application.article.port.in.GetArticlePort; import com.pinback.application.article.port.out.ArticleGetServicePort; import com.pinback.application.category.port.in.GetCategoryPort; @@ -105,7 +109,6 @@ public TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boole LocalDateTime startOfDay = now.toLocalDate().atStartOfDay(); LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59, 999999999); - RemindArticlesWithCountDto result = articleGetServicePort.findTodayRemindWithCount( user, startOfDay, endOfDay, PageRequest.of(query.pageNumber(), query.pageSize()), readStatus); @@ -120,6 +123,40 @@ public TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boole ); } + @Override + public TodayRemindResponseV2 getRemindArticlesV2( + User user, + LocalDateTime now, + boolean readStatus, + PageQuery query + ) { + LocalDateTime endBound = now; + LocalDateTime startBound = now.minusHours(24); + + RemindArticlesWithCountDtoV2 result = articleGetServicePort.findTodayRemindWithCountV2( + user, + startBound, + endBound, + PageRequest.of(query.pageNumber(), query.pageSize()), + readStatus + ); + + List articleResponses = + result.articles() != null ? + result.articles().stream() + .map(RemindArticleResponseV2::from) + .toList() : + Collections.emptyList(); + + return TodayRemindResponseV2.of( + result.hasNext(), + result.totalCount(), + result.readCount(), + result.unreadCount(), + articleResponses + ); + } + private LocalDateTime getRemindDateTime(LocalDateTime now, LocalTime remindDefault) { return LocalDateTime.of( now.getYear(), diff --git a/domain/src/main/java/com/pinback/domain/article/entity/Article.java b/domain/src/main/java/com/pinback/domain/article/entity/Article.java index 6f7675d..4b27770 100644 --- a/domain/src/main/java/com/pinback/domain/article/entity/Article.java +++ b/domain/src/main/java/com/pinback/domain/article/entity/Article.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; +import org.hibernate.annotations.ColumnDefault; + import com.pinback.domain.category.entity.Category; import com.pinback.domain.common.BaseEntity; import com.pinback.domain.user.entity.User; @@ -56,6 +58,10 @@ public class Article extends BaseEntity { @Column(name = "is_read", nullable = false) private Boolean isRead; + @Column(name = "is_read_after_remind", nullable = false) + @ColumnDefault("false") + private Boolean isReadAfterRemind; + public static Article create(String url, String memo, User user, Category category, LocalDateTime remindAt) { validateMemo(memo); @@ -66,6 +72,7 @@ public static Article create(String url, String memo, User user, Category catego .category(category) .isRead(false) .remindAt(remindAt) + .isReadAfterRemind(false) .build(); } @@ -80,10 +87,18 @@ public boolean isRead() { return isRead; } + public boolean isReadAfterRemind() { + return isReadAfterRemind; + } + public void markAsRead() { this.isRead = true; } + public void markAsReadAfterRemind() { + this.isReadAfterRemind = true; + } + public void update(String memo, Category category, LocalDateTime remindAt) { validateMemo(memo); this.memo = memo; diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java index d4aeaf9..0344b74 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java @@ -9,6 +9,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.infrastructure.article.repository.dto.ArticlesWithUnreadCount; import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCount; +import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCountV2; public interface ArticleRepositoryCustom { ArticlesWithUnreadCount findAllCustom(UUID userId, Pageable pageable); @@ -24,4 +25,7 @@ RemindArticlesWithCount findTodayRemindWithCount(UUID userId, Pageable pageable, ArticlesWithUnreadCount findAllByIsReadFalse(UUID userId, Pageable pageable); void deleteArticlesByUserIdAndCategoryId(UUID userId, long categoryId); + + RemindArticlesWithCountV2 findTodayRemindWithCountV2(UUID userId, Pageable pageable, LocalDateTime startAt, + LocalDateTime endAt, Boolean isReadAfterRemind); } diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java index a742bd6..6cd220e 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java @@ -17,6 +17,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.infrastructure.article.repository.dto.ArticlesWithUnreadCount; import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCount; +import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCountV2; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -209,4 +210,57 @@ public void deleteArticlesByUserIdAndCategoryId(UUID userId, long categoryId) { .where(conditions) .execute(); } + + @Override + public RemindArticlesWithCountV2 findTodayRemindWithCountV2( + UUID userId, + Pageable pageable, + LocalDateTime startBound, + LocalDateTime endBound, + Boolean isReadAfterRemind + ) { + BooleanExpression baseConditions = article.user.id.eq(userId) + .and(article.remindAt.gt(startBound).and(article.remindAt.loe(endBound))); + + BooleanExpression conditions = baseConditions.and(article.isReadAfterRemind.eq(isReadAfterRemind)); + + List
articles = queryFactory + .selectFrom(article) + .join(article.user, user).fetchJoin() + .where(conditions) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(article.remindAt.asc()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(article.count()) + .from(article) + .where(conditions); + + Long readCount = queryFactory + .select(article.count()) + .from(article) + .where(baseConditions.and(article.isReadAfterRemind.isTrue())) + .fetchOne(); + + Long unreadCount = queryFactory + .select(article.count()) + .from(article) + .where(baseConditions.and(article.isReadAfterRemind.isFalse())) + .fetchOne(); + + Long totalCount = queryFactory + .select(article.count()) + .from(article) + .where(baseConditions) + .fetchOne(); + + return new RemindArticlesWithCountV2( + readCount, + unreadCount, + totalCount, + PageableExecutionUtils.getPage(articles, pageable, countQuery::fetchOne) + ); + } } diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCountV2.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCountV2.java new file mode 100644 index 0000000..270781c --- /dev/null +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCountV2.java @@ -0,0 +1,13 @@ +package com.pinback.infrastructure.article.repository.dto; + +import org.springframework.data.domain.Page; + +import com.pinback.domain.article.entity.Article; + +public record RemindArticlesWithCountV2( + long readCount, + long unreadCount, + long totalCount, + Page
articles +) { +} diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java index 022d082..f51963d 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java @@ -10,6 +10,7 @@ import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; import com.pinback.application.article.dto.RemindArticlesWithCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDtoV2; import com.pinback.application.article.port.out.ArticleGetServicePort; import com.pinback.application.common.exception.ArticleNotFoundException; import com.pinback.domain.article.entity.Article; @@ -18,6 +19,7 @@ import com.pinback.infrastructure.article.repository.ArticleRepository; import com.pinback.infrastructure.article.repository.dto.ArticlesWithUnreadCount; import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCount; +import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCountV2; import lombok.RequiredArgsConstructor; @@ -80,7 +82,8 @@ public Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pa } @Override - public RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime startDateTime, LocalDateTime endDateTime, + public RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime startDateTime, + LocalDateTime endDateTime, Pageable pageable, Boolean isRead) { RemindArticlesWithCount infraResult = articleRepository.findTodayRemindWithCount(user.getId(), pageable, startDateTime, endDateTime, isRead); @@ -91,6 +94,30 @@ public RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateT ); } + @Override + public RemindArticlesWithCountDtoV2 findTodayRemindWithCountV2( + User user, + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable, + Boolean isReadAtferRemind + ) { + RemindArticlesWithCountV2 infraResult = articleRepository.findTodayRemindWithCountV2( + user.getId(), + pageable, + startDateTime, + endDateTime, + isReadAtferRemind + ); + return new RemindArticlesWithCountDtoV2( + infraResult.articles().hasNext(), + infraResult.readCount(), + infraResult.unreadCount(), + infraResult.totalCount(), + infraResult.articles() + ); + } + private ArticlesWithUnreadCountDto convertToDto(ArticlesWithUnreadCount infraResult) { return new ArticlesWithUnreadCountDto( infraResult.unReadCount(),