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
8 changes: 8 additions & 0 deletions src/main/java/io/spring/api/ArticlesApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ public ResponseEntity getFeed(
return ResponseEntity.ok(articleQueryService.findUserFeed(user, new Page(offset, limit)));
}

@GetMapping(path = "bookmarked")
public ResponseEntity getBookmarkedArticles(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "20") int limit,
@AuthenticationPrincipal User user) {
return ResponseEntity.ok(articleQueryService.findUserBookmarks(user, new Page(offset, limit)));
}

@GetMapping
public ResponseEntity getArticles(
@RequestParam(value = "offset", defaultValue = "0") int offset,
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/io/spring/api/security/WebSecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ protected void configure(HttpSecurity http) throws Exception {
.permitAll()
.antMatchers(HttpMethod.GET, "/articles/feed")
.authenticated()
.antMatchers(HttpMethod.GET, "/articles/bookmarked")
.authenticated()
.antMatchers(HttpMethod.POST, "/users", "/users/login")
.permitAll()
.antMatchers(HttpMethod.GET, "/articles/**", "/profiles/**", "/tags")
Expand Down
76 changes: 76 additions & 0 deletions src/main/java/io/spring/application/ArticleQueryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import static java.util.stream.Collectors.toList;

import io.spring.application.data.ArticleBookmarkDate;
import io.spring.application.data.ArticleData;
import io.spring.application.data.ArticleDataList;
import io.spring.application.data.ArticleFavoriteCount;
import io.spring.application.data.BookmarkedArticleData;
import io.spring.core.user.User;
import io.spring.infrastructure.mybatis.readservice.ArticleBookmarksReadService;
import io.spring.infrastructure.mybatis.readservice.ArticleFavoritesReadService;
Expand All @@ -15,10 +17,13 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.springframework.stereotype.Service;

@Service
Expand Down Expand Up @@ -99,6 +104,77 @@ public CursorPager<ArticleData> findUserFeedWithCursor(
}
}

public CursorPager<BookmarkedArticleData> findUserBookmarksWithCursor(
User currentUser, CursorPageParameter<DateTime> page) {
List<String> ids =
articleBookmarksReadService.findUserBookmarkedArticleIdsWithCursor(
currentUser.getId(), toTextCursor(page));
if (ids.size() == 0) {
return new CursorPager<>(new ArrayList<>(), page.getDirection(), false);
}
boolean hasExtra = ids.size() > page.getLimit();
if (hasExtra) {
ids.remove(page.getLimit());
}
if (!page.isNext()) {
Collections.reverse(ids);
}

List<ArticleData> articles = findArticlesInIdOrder(ids);
if (!articles.isEmpty()) {
fillExtraInfo(articles, currentUser);
}

Map<String, DateTime> bookmarkedAt = bookmarkDates(currentUser.getId(), ids);
List<BookmarkedArticleData> data =
articles.stream()
.filter(article -> bookmarkedAt.get(article.getId()) != null)
.map(article -> new BookmarkedArticleData(article, bookmarkedAt.get(article.getId())))
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
.collect(toList());
return new CursorPager<>(data, page.getDirection(), hasExtra);
}

public ArticleDataList findUserBookmarks(User currentUser, Page page) {
List<String> ids =
articleBookmarksReadService.findUserBookmarkedArticleIds(currentUser.getId(), page);
int count = articleBookmarksReadService.countUserBookmarks(currentUser.getId());
if (ids.size() == 0) {
return new ArticleDataList(new ArrayList<>(), count);
}
List<ArticleData> articles = findArticlesInIdOrder(ids);
if (!articles.isEmpty()) {
fillExtraInfo(articles, currentUser);
}
return new ArticleDataList(articles, count);
}
Comment on lines +137 to +149

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Bookmark count includes orphaned bookmarks, creating a mismatch with returned articles

In findUserBookmarks at src/main/java/io/spring/application/ArticleQueryService.java:137, the total count comes from countUserBookmarks which counts raw bookmark rows, while findArticlesInIdOrder at line 141 filters out bookmarks whose articles no longer exist. This means articlesCount in the response can be higher than the number of articles actually returned. The test should_drop_bookmarks_for_deleted_articles at src/test/java/io/spring/application/article/ArticleBookmarkQueryServiceTest.java:237-240 explicitly documents and asserts this behavior (count=2 but only 1 article returned), so it appears intentional. However, clients paginating based on articlesCount may observe gaps or confusion, especially if many bookmarked articles are deleted.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional, as the test documents. articlesCount is the count of the user's bookmark rows (countUserBookmarks), independent of the page slice — matching the REST envelope semantics used elsewhere (articlesCount = total matching, not the page size). Orphaned bookmarks only arise because article_bookmarks has no FK to articles (MTM-6 foundation); in normal operation a deleted article wouldn't leave a dangling bookmark, so the count and result set agree. Reconciling orphan cleanup / FK semantics is a foundation concern outside MTM-3's additive scope, so I've left the count as the true bookmark total here rather than silently under-reporting.


private List<ArticleData> findArticlesInIdOrder(List<String> ids) {
Map<String, ArticleData> byId =
articleReadService.findArticles(ids).stream()
.collect(Collectors.toMap(ArticleData::getId, article -> article));
return ids.stream().map(byId::get).filter(Objects::nonNull).collect(toList());
}

/**
* {@code article_bookmarks.created_at} is persisted as a SQLite text timestamp (the column's
* {@code CURRENT_TIMESTAMP} default), whereas a {@link DateTime} cursor binds as epoch millis.
* Convert the cursor to the same text form so the bookmark-time comparison (D1) is correct; the
* opaque cursor exposed to clients remains the {@link DateTimeCursor} (millis).
*/
private CursorPageParameter<String> toTextCursor(CursorPageParameter<DateTime> page) {
String cursor =
page.getCursor() == null
? null
: page.getCursor().withZone(DateTimeZone.UTC).toString("yyyy-MM-dd HH:mm:ss");
return new CursorPageParameter<>(cursor, page.getLimit(), page.getDirection());
}

private Map<String, DateTime> bookmarkDates(String userId, List<String> ids) {
return articleBookmarksReadService.findBookmarkDates(userId, ids).stream()
.collect(
Collectors.toMap(ArticleBookmarkDate::getArticleId, ArticleBookmarkDate::getCreatedAt));
}

public ArticleDataList findRecentArticles(
String tag, String author, String favoritedBy, Page page, User currentUser) {
List<String> articleIds = articleReadService.queryArticles(tag, author, favoritedBy, page);
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/io/spring/application/data/ArticleBookmarkDate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.spring.application.data;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.joda.time.DateTime;

@Getter
@Setter
@NoArgsConstructor
public class ArticleBookmarkDate {
private String articleId;
private DateTime createdAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.spring.application.data;

import io.spring.application.DateTimeCursor;
import io.spring.application.Node;
import io.spring.application.PageCursor;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.joda.time.DateTime;

/**
* Read-model node for a current user's bookmarked article. Carries the bookmark's {@code
* created_at} so cursor pagination is anchored on bookmark time (newest-bookmarked first) rather
* than the article's own create/update time.
*/
@Getter
@AllArgsConstructor
public class BookmarkedArticleData implements Node {
private ArticleData article;
private DateTime bookmarkedAt;

@Override
public PageCursor getCursor() {
return new DateTimeCursor(bookmarkedAt);
}
}
52 changes: 51 additions & 1 deletion src/main/java/io/spring/graphql/ArticleDatafetcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
import io.spring.application.CursorPager;
import io.spring.application.CursorPager.Direction;
import io.spring.application.DateTimeCursor;
import io.spring.application.Node;
import io.spring.application.data.ArticleData;
import io.spring.application.data.BookmarkedArticleData;
import io.spring.application.data.CommentData;
import io.spring.core.user.User;
import io.spring.core.user.UserRepository;
import io.spring.graphql.DgsConstants.ARTICLEPAYLOAD;
import io.spring.graphql.DgsConstants.COMMENT;
import io.spring.graphql.DgsConstants.PROFILE;
import io.spring.graphql.DgsConstants.QUERY;
import io.spring.graphql.exception.AuthenticationException;
import io.spring.graphql.types.Article;
import io.spring.graphql.types.ArticleEdge;
import io.spring.graphql.types.ArticlesConnection;
Expand Down Expand Up @@ -135,6 +138,53 @@ public DataFetcherResult<ArticlesConnection> userFeed(
.build();
}

@DgsQuery(field = QUERY.BookmarkedArticles)
public DataFetcherResult<ArticlesConnection> getBookmarkedArticles(
@InputArgument("first") Integer first,
@InputArgument("after") String after,
@InputArgument("last") Integer last,
@InputArgument("before") String before,
DgsDataFetchingEnvironment dfe) {
if (first == null && last == null) {
throw new IllegalArgumentException("first 和 last 必须只存在一个");
}

User current = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new);

CursorPager<BookmarkedArticleData> articles;
if (first != null) {
articles =
articleQueryService.findUserBookmarksWithCursor(
current,
new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT));
} else {
articles =
articleQueryService.findUserBookmarksWithCursor(
current,
new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV));
}
graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles);
ArticlesConnection articlesConnection =
ArticlesConnection.newBuilder()
.pageInfo(pageInfo)
.edges(
articles.getData().stream()
.map(
b ->
ArticleEdge.newBuilder()
.cursor(b.getCursor().toString())
.node(buildArticleResult(b.getArticle()))
.build())
.collect(Collectors.toList()))
.build();
return DataFetcherResult.<ArticlesConnection>newResult()
.data(articlesConnection)
.localContext(
articles.getData().stream()
.collect(Collectors.toMap(b -> b.getArticle().getSlug(), b -> b.getArticle())))
.build();
}

@DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Favorites)
public DataFetcherResult<ArticlesConnection> userFavorites(
@InputArgument("first") Integer first,
Expand Down Expand Up @@ -356,7 +406,7 @@ public DataFetcherResult<Article> findArticleBySlug(@InputArgument("slug") Strin
.build();
}

private DefaultPageInfo buildArticlePageInfo(CursorPager<ArticleData> articles) {
private DefaultPageInfo buildArticlePageInfo(CursorPager<? extends Node> articles) {
return new DefaultPageInfo(
articles.getStartCursor() == null
? null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.spring.infrastructure.mybatis.readservice;

import io.spring.application.CursorPageParameter;
import io.spring.application.Page;
import io.spring.application.data.ArticleBookmarkDate;
import io.spring.core.user.User;
import java.util.List;
import java.util.Set;
Expand All @@ -15,4 +17,12 @@ public interface ArticleBookmarksReadService {

List<String> findUserBookmarkedArticleIdsWithCursor(
@Param("userId") String userId, @Param("page") CursorPageParameter page);

List<String> findUserBookmarkedArticleIds(
@Param("userId") String userId, @Param("page") Page page);

int countUserBookmarks(@Param("userId") String userId);

List<ArticleBookmarkDate> findBookmarkDates(
@Param("userId") String userId, @Param("ids") List<String> ids);
}
19 changes: 19 additions & 0 deletions src/main/resources/mapper/ArticleBookmarksReadService.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,23 @@
</if>
limit #{page.queryLimit}
</select>
<select id="findUserBookmarkedArticleIds" resultType="java.lang.String">
select AB.article_id
from article_bookmarks AB
where AB.user_id = #{userId}
order by AB.created_at desc
limit #{page.offset}, #{page.limit}
</select>
<select id="countUserBookmarks" resultType="java.lang.Integer">
select count(1) from article_bookmarks where user_id = #{userId}
</select>
<select id="findBookmarkDates" resultType="io.spring.application.data.ArticleBookmarkDate">
select AB.article_id, AB.created_at
from article_bookmarks AB
where AB.user_id = #{userId}
and AB.article_id in
<foreach collection="ids" item="item" separator="," open="(" close=")">
#{item}
</foreach>
</select>
</mapper>
1 change: 1 addition & 0 deletions src/main/resources/schema/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Query {
): ArticlesConnection
me: User
feed(first: Int, after: String, last: Int, before: String): ArticlesConnection
bookmarkedArticles(first: Int, after: String, last: Int, before: String): ArticlesConnection
profile(username: String!): ProfilePayload
tags: [String]
}
Expand Down
82 changes: 82 additions & 0 deletions src/test/java/io/spring/api/BookmarkedArticlesApiTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package io.spring.api;

import static io.restassured.module.mockmvc.RestAssuredMockMvc.given;
import static io.spring.TestHelper.articleDataFixture;
import static java.util.Arrays.asList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import io.spring.JacksonCustomizations;
import io.spring.api.security.WebSecurityConfig;
import io.spring.application.ArticleQueryService;
import io.spring.application.Page;
import io.spring.application.article.ArticleCommandService;
import io.spring.application.data.ArticleDataList;
import io.spring.core.article.ArticleRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(ArticlesApi.class)
@Import({WebSecurityConfig.class, JacksonCustomizations.class})
public class BookmarkedArticlesApiTest extends TestWithCurrentUser {
@MockBean private ArticleRepository articleRepository;

@MockBean private ArticleQueryService articleQueryService;

@MockBean private ArticleCommandService articleCommandService;

@Autowired private MockMvc mvc;

@Override
@BeforeEach
public void setUp() throws Exception {
super.setUp();
RestAssuredMockMvc.mockMvc(mvc);
}

@Test
public void should_get_401_without_login() throws Exception {
RestAssuredMockMvc.when().get("/articles/bookmarked").prettyPeek().then().statusCode(401);
}

@Test
public void should_get_bookmarked_articles_with_envelope() throws Exception {
ArticleDataList articleDataList =
new ArticleDataList(
asList(articleDataFixture("1", user), articleDataFixture("2", user)), 2);
when(articleQueryService.findUserBookmarks(eq(user), eq(new Page(0, 20))))
.thenReturn(articleDataList);

given()
.header("Authorization", "Token " + token)
.when()
.get("/articles/bookmarked")
.prettyPeek()
.then()
.statusCode(200)
.body("articlesCount", org.hamcrest.Matchers.is(2))
.body("articles.size()", org.hamcrest.Matchers.is(2));
}

@Test
public void should_pass_offset_and_limit_through() throws Exception {
when(articleQueryService.findUserBookmarks(eq(user), eq(new Page(5, 10))))
.thenReturn(new ArticleDataList(asList(articleDataFixture("1", user)), 11));

given()
.header("Authorization", "Token " + token)
.when()
.get("/articles/bookmarked?offset=5&limit=10")
.prettyPeek()
.then()
.statusCode(200)
.body("articlesCount", org.hamcrest.Matchers.is(11))
.body("articles.size()", org.hamcrest.Matchers.is(1));
}
}
Loading
Loading