diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index bc8256b..ae987d8 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -26,9 +26,11 @@ public enum ErrorCode { VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"), IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"), MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"), + TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"), VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"), REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"), MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"), + FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"), MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), INTERNAL_ERROR(50000, 500, "服务器内部错误"), diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/access/UserExistenceGateway.java b/patbond-community/src/main/java/com/patbond/patbond/community/access/UserExistenceGateway.java new file mode 100644 index 0000000..291c5be --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/access/UserExistenceGateway.java @@ -0,0 +1,34 @@ +package com.patbond.patbond.community.access; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Existence probe into identity.users for write gates that reference a + * user (follow target, comment @-reply target). Same-database read-only + * access under the ADR-017 exception — the user_follows/comments foreign + * keys already bind these schemas together, and a WRITE gate cannot ride + * the Feign profile path, whose degradation deliberately cannot tell + * "absent" from "unreachable". A soft-deleted (注销) user counts as absent. + */ +@Component +public class UserExistenceGateway { + + private final JdbcClient jdbcClient; + + public UserExistenceGateway(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + public boolean existsActive(UUID userId) { + return jdbcClient.sql(""" + SELECT EXISTS (SELECT 1 FROM identity.users + WHERE id = :id AND deleted_at IS NULL) + """) + .param("id", userId) + .query(Boolean.class) + .single(); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/FollowController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/FollowController.java new file mode 100644 index 0000000..8b0e606 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/FollowController.java @@ -0,0 +1,51 @@ +package com.patbond.patbond.community.controller; + +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.community.dto.FollowStateResponse; +import com.patbond.patbond.community.dto.FollowStatsResponse; +import com.patbond.patbond.community.security.BearerAuthFilter; +import com.patbond.patbond.community.service.FollowService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * The ADR-018 minimal follow surface (T3-07): idempotent follow/unfollow + * plus the numbers endpoint. Follower/following LISTS are deliberately not + * in M3. + */ +@RestController +public class FollowController { + + private final FollowService followService; + + public FollowController(FollowService followService) { + this.followService = followService; + } + + @PutMapping("/api/v1/users/{userId}/follow") + public ApiResponse follow( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId, + @PathVariable UUID userId) { + return ApiResponse.success(followService.follow(callerId, userId)); + } + + @DeleteMapping("/api/v1/users/{userId}/follow") + public ApiResponse unfollow( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId, + @PathVariable UUID userId) { + return ApiResponse.success(followService.unfollow(callerId, userId)); + } + + @GetMapping("/api/v1/users/{userId}/follow-stats") + public ApiResponse stats( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId, + @PathVariable UUID userId) { + return ApiResponse.success(followService.stats(callerId, userId)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/InteractionController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/InteractionController.java new file mode 100644 index 0000000..2d76d8d --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/InteractionController.java @@ -0,0 +1,79 @@ +package com.patbond.patbond.community.controller; + +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.community.dto.BookmarkStateResponse; +import com.patbond.patbond.community.dto.CursorPage; +import com.patbond.patbond.community.dto.FeedCardResponse; +import com.patbond.patbond.community.dto.LikeStateResponse; +import com.patbond.patbond.community.security.BearerAuthFilter; +import com.patbond.patbond.community.service.FeedService; +import com.patbond.patbond.community.service.InteractionService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * Binary post interactions (T3-06): PUT/DELETE idempotent like and + * bookmark, each answering the authoritative terminal state, plus the + * my-bookmarks list whose items reuse the feed card shape. + */ +@RestController +@Validated +public class InteractionController { + + private final InteractionService interactionService; + private final FeedService feedService; + + public InteractionController(InteractionService interactionService, FeedService feedService) { + this.interactionService = interactionService; + this.feedService = feedService; + } + + @PutMapping("/api/v1/posts/{postId}/like") + public ApiResponse like( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID postId) { + return ApiResponse.success(interactionService.like(userId, postId)); + } + + @DeleteMapping("/api/v1/posts/{postId}/like") + public ApiResponse unlike( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID postId) { + return ApiResponse.success(interactionService.unlike(userId, postId)); + } + + @PutMapping("/api/v1/posts/{postId}/bookmark") + public ApiResponse bookmark( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID postId) { + return ApiResponse.success(interactionService.bookmark(userId, postId)); + } + + @DeleteMapping("/api/v1/posts/{postId}/bookmark") + public ApiResponse unbookmark( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID postId) { + return ApiResponse.success(interactionService.unbookmark(userId, postId)); + } + + @GetMapping("/api/v1/me/bookmarks") + public ApiResponse> myBookmarks( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @RequestParam(defaultValue = "20") + @Min(value = 1, message = "limit 最小为 1") + @Max(value = 100, message = "limit 最大为 100") + int limit, + @RequestParam(required = false) String cursor) { + return ApiResponse.success(feedService.listBookmarked(userId, limit, cursor)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/BookmarkStateResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/BookmarkStateResponse.java new file mode 100644 index 0000000..d13b00f --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/BookmarkStateResponse.java @@ -0,0 +1,5 @@ +package com.patbond.patbond.community.dto; + +/** Authoritative post-write bookmark state — isomorphic to {@link LikeStateResponse}. */ +public record BookmarkStateResponse(boolean bookmarked, long bookmarkCount) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/FollowStateResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/FollowStateResponse.java new file mode 100644 index 0000000..a70a90d --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/FollowStateResponse.java @@ -0,0 +1,10 @@ +package com.patbond.patbond.community.dto; + +/** + * Authoritative post-write follow state; {@code followerCount} is the + * TARGET user's follower count (real-time COUNT — user_follows has no + * denormalized counter column, and the double index keeps both directions + * cheap). + */ +public record FollowStateResponse(boolean following, long followerCount) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/FollowStatsResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/FollowStatsResponse.java new file mode 100644 index 0000000..2457891 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/FollowStatsResponse.java @@ -0,0 +1,9 @@ +package com.patbond.patbond.community.dto; + +/** + * GET /api/v1/users/{userId}/follow-stats — the ADR-018 minimal "numbers" + * endpoint. {@code followedByMe} is the caller's view; asking about oneself + * yields false (a self-follow row cannot exist, ck_user_follows_self). + */ +public record FollowStatsResponse(long followerCount, long followingCount, boolean followedByMe) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/LikeStateResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/LikeStateResponse.java new file mode 100644 index 0000000..d418ca3 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/LikeStateResponse.java @@ -0,0 +1,10 @@ +package com.patbond.patbond.community.dto; + +/** + * Authoritative post-write like state (草案定型): a PUT answers + * {@code liked=true} and a DELETE {@code liked=false} regardless of whether + * the call changed anything; {@code likeCount} is the count as of this + * write's transaction, the value optimistic clients reconcile against. + */ +public record LikeStateResponse(boolean liked, long likeCount) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/InteractionRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/InteractionRepository.java new file mode 100644 index 0000000..7507b9f --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/InteractionRepository.java @@ -0,0 +1,202 @@ +package com.patbond.patbond.community.repository; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +/** + * community.post_likes / post_bookmarks / user_follows access, plus the + * denormalized counter writes on community.posts. The invariant every + * caller must hold (工单验收硬项): a counter column moves IN THE SAME + * TRANSACTION as its relation row, and only by the number of rows the + * relation write actually changed — {@code ON CONFLICT DO NOTHING} inserts + * and conditional deletes report that number, so concurrent duplicates + * converge on the composite primary key and never double-count. + */ +@Repository +public class InteractionRepository { + + private final JdbcClient jdbcClient; + + public InteractionRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + /** + * The interaction gate: likes, bookmarks and comments attach to the + * PUBLIC face of a post only — published and live. Drafts (the + * author's own included), hidden/archived and soft-deleted posts all + * fail this probe and answer the byte-identical 404/40403. + */ + public boolean isInteractable(UUID postId) { + return jdbcClient.sql(""" + SELECT EXISTS (SELECT 1 FROM community.posts + WHERE id = :id AND status = 'published' + AND deleted_at IS NULL) + """) + .param("id", postId) + .query(Boolean.class) + .single(); + } + + /** @return rows inserted — 0 when the like already existed */ + public int insertLike(UUID postId, UUID userId) { + return jdbcClient.sql(""" + INSERT INTO community.post_likes (post_id, user_id) + VALUES (:postId, :userId) + ON CONFLICT (post_id, user_id) DO NOTHING + """) + .param("postId", postId) + .param("userId", userId) + .update(); + } + + /** @return rows deleted — 0 when there was nothing to cancel */ + public int deleteLike(UUID postId, UUID userId) { + return jdbcClient.sql(""" + DELETE FROM community.post_likes + WHERE post_id = :postId AND user_id = :userId + """) + .param("postId", postId) + .param("userId", userId) + .update(); + } + + /** @return rows inserted — 0 when the bookmark already existed */ + public int insertBookmark(UUID postId, UUID userId) { + return jdbcClient.sql(""" + INSERT INTO community.post_bookmarks (post_id, user_id) + VALUES (:postId, :userId) + ON CONFLICT (post_id, user_id) DO NOTHING + """) + .param("postId", postId) + .param("userId", userId) + .update(); + } + + /** @return rows deleted — 0 when there was nothing to cancel */ + public int deleteBookmark(UUID postId, UUID userId) { + return jdbcClient.sql(""" + DELETE FROM community.post_bookmarks + WHERE post_id = :postId AND user_id = :userId + """) + .param("postId", postId) + .param("userId", userId) + .update(); + } + + /** + * Moves like_count by delta and returns the resulting value — the + * authoritative count the write response carries. Callers pass the row + * count their relation write reported; a zero delta must instead read + * via {@link #likeCount} so a no-op replay takes no row lock and does + * not touch updated_at. + */ + public long bumpLikeCount(UUID postId, int delta) { + return jdbcClient.sql(""" + UPDATE community.posts SET like_count = like_count + :delta + WHERE id = :id + RETURNING like_count + """) + .param("id", postId) + .param("delta", delta) + .query(Long.class) + .single(); + } + + public long bumpBookmarkCount(UUID postId, int delta) { + return jdbcClient.sql(""" + UPDATE community.posts SET bookmark_count = bookmark_count + :delta + WHERE id = :id + RETURNING bookmark_count + """) + .param("id", postId) + .param("delta", delta) + .query(Long.class) + .single(); + } + + public long bumpCommentCount(UUID postId, int delta) { + return jdbcClient.sql(""" + UPDATE community.posts SET comment_count = comment_count + :delta + WHERE id = :id + RETURNING comment_count + """) + .param("id", postId) + .param("delta", delta) + .query(Long.class) + .single(); + } + + public long likeCount(UUID postId) { + return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id") + .param("id", postId) + .query(Long.class) + .single(); + } + + public long bookmarkCount(UUID postId) { + return jdbcClient.sql("SELECT bookmark_count FROM community.posts WHERE id = :id") + .param("id", postId) + .query(Long.class) + .single(); + } + + /** @return rows inserted — 0 when the follow already existed */ + public int insertFollow(UUID followerUserId, UUID followeeUserId) { + return jdbcClient.sql(""" + INSERT INTO community.user_follows (follower_user_id, followee_user_id) + VALUES (:follower, :followee) + ON CONFLICT (follower_user_id, followee_user_id) DO NOTHING + """) + .param("follower", followerUserId) + .param("followee", followeeUserId) + .update(); + } + + /** @return rows deleted — 0 when there was nothing to cancel */ + public int deleteFollow(UUID followerUserId, UUID followeeUserId) { + return jdbcClient.sql(""" + DELETE FROM community.user_follows + WHERE follower_user_id = :follower AND followee_user_id = :followee + """) + .param("follower", followerUserId) + .param("followee", followeeUserId) + .update(); + } + + /** Real-time follower count of a user — ix_user_follows_followee. */ + public long countFollowers(UUID userId) { + return jdbcClient.sql(""" + SELECT count(*) FROM community.user_follows + WHERE followee_user_id = :userId + """) + .param("userId", userId) + .query(Long.class) + .single(); + } + + /** Real-time following count of a user — the primary key prefix. */ + public long countFollowing(UUID userId) { + return jdbcClient.sql(""" + SELECT count(*) FROM community.user_follows + WHERE follower_user_id = :userId + """) + .param("userId", userId) + .query(Long.class) + .single(); + } + + public boolean followExists(UUID followerUserId, UUID followeeUserId) { + return jdbcClient.sql(""" + SELECT EXISTS (SELECT 1 FROM community.user_follows + WHERE follower_user_id = :follower + AND followee_user_id = :followee) + """) + .param("follower", followerUserId) + .param("followee", followeeUserId) + .query(Boolean.class) + .single(); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java index 31c77ba..b5a4e78 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.patbond.patbond.community.repository; +import com.patbond.patbond.community.support.BookmarkCursor; import com.patbond.patbond.community.support.FeedCursor; import com.patbond.patbond.community.support.PostCursor; import org.springframework.jdbc.core.simple.JdbcClient; @@ -229,6 +230,46 @@ public class PostRepository { return spec.query(PostRepository::mapPost).list(); } + /** + * One my-bookmarks page in (bookmarks.created_at DESC, post_id DESC) — + * the exact key of ix_post_bookmarks_user_created. Bookmarked posts + * that turned invisible (deleted, hidden/archived, non-public) are + * filtered INSIDE the keyset query(草案「静默剔除」定型): the cursor + * keys on the relation row, so dropped posts cost nothing to + * pagination correctness. The caller asks for limit+1 rows to learn + * whether more exist. + */ + public List pageBookmarked(UUID userId, BookmarkCursor after, + int limitPlusOne) { + String sql = """ + SELECT p.id, p.author_user_id, p.pet_id, p.category, p.title, p.content, + p.status, p.visibility, p.like_count, p.comment_count, p.bookmark_count, + p.created_at, p.updated_at, p.published_at, p.deleted_at, p.version, p.request_hash, + EXISTS (SELECT 1 FROM community.post_likes pl + WHERE pl.post_id = p.id AND pl.user_id = :viewerId) AS liked_by_me, + true AS bookmarked_by_me, + b.created_at AS bookmarked_at + FROM community.post_bookmarks b + JOIN community.posts p ON p.id = b.post_id + WHERE b.user_id = :viewerId + AND p.status = 'published' AND p.visibility = 'public' AND p.deleted_at IS NULL + """; + if (after != null) { + sql += " AND (b.created_at, b.post_id) < (:cursorBookmarkedAt, :cursorPostId)"; + } + sql += " ORDER BY b.created_at DESC, b.post_id DESC LIMIT :limit"; + var spec = jdbcClient.sql(sql) + .param("viewerId", userId) + .param("limit", limitPlusOne); + if (after != null) { + spec = spec.param("cursorBookmarkedAt", after.bookmarkedAt()) + .param("cursorPostId", after.postId()); + } + return spec.query((rs, rowNum) -> new BookmarkedPostRow( + mapPost(rs, rowNum), + rs.getObject("bookmarked_at", OffsetDateTime.class))).list(); + } + public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) { jdbcClient.sql(""" INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption) @@ -350,4 +391,8 @@ public class PostRepository { Integer widthPx, Integer heightPx) { } + + /** A bookmarked post plus the relation row's timestamp (the page key). */ + public record BookmarkedPostRow(PostRow post, OffsetDateTime bookmarkedAt) { + } } diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java index 134aac4..7c5a60d 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java @@ -7,8 +7,10 @@ import com.patbond.patbond.community.dto.FeedCardResponse; import com.patbond.patbond.community.dto.PostMediaItemResponse; import com.patbond.patbond.community.media.MediaUrlSigner; import com.patbond.patbond.community.repository.PostRepository; +import com.patbond.patbond.community.repository.PostRepository.BookmarkedPostRow; import com.patbond.patbond.community.repository.PostRepository.PostMediaRow; import com.patbond.patbond.community.repository.PostRepository.PostRow; +import com.patbond.patbond.community.support.BookmarkCursor; import com.patbond.patbond.community.support.FeedCursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -56,6 +58,28 @@ public class FeedService { return new CursorPage<>(assembleCards(page), nextCursor, hasMore); } + /** + * My-bookmarks page (T3-07): the item IS the feed card(草案定型:项 + * 形态复用 Feed 卡片), the order and cursor key on the bookmark + * relation row, and posts that turned invisible since bookmarking are + * silently dropped inside the page query — the same public-face + * predicate the feed uses, so a card here never breaks the + * publishedAt-non-null invariant. + */ + @Transactional(readOnly = true) + public CursorPage listBookmarked(UUID userId, int limit, String cursor) { + BookmarkCursor after = cursor == null ? null : BookmarkCursor.decode(cursor); + List rows = postRepository.pageBookmarked(userId, after, limit + 1); + boolean hasMore = rows.size() > limit; + List page = hasMore ? rows.subList(0, limit) : rows; + String nextCursor = hasMore + ? new BookmarkCursor(page.get(limit - 1).bookmarkedAt(), + page.get(limit - 1).post().id()).encode() + : null; + return new CursorPage<>(assembleCards(page.stream().map(BookmarkedPostRow::post).toList()), + nextCursor, hasMore); + } + private List assembleCards(List rows) { Map> mediaByPost = postRepository .findMediaByPostIds(rows.stream().map(PostRow::id).toList()) diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/FollowService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/FollowService.java new file mode 100644 index 0000000..10e3fe2 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/FollowService.java @@ -0,0 +1,67 @@ +package com.patbond.patbond.community.service; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.community.access.UserExistenceGateway; +import com.patbond.patbond.community.dto.FollowStateResponse; +import com.patbond.patbond.community.dto.FollowStatsResponse; +import com.patbond.patbond.community.repository.InteractionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * The ADR-018 minimal follow surface: follow/unfollow (PUT/DELETE + * idempotent on the composite primary key, ADR-019) plus the follow-stats + * numbers. Counts are real-time COUNTs — user_follows carries no + * denormalized counters, and both directions ride an index. The target + * must be an existing active user (404/40406, absent and 注销 merged); + * following oneself is 422/42204 on PUT (ck_user_follows_self is the + * database backstop), while DELETE stays a plain idempotent no-op — a + * self-follow row cannot exist, so the authoritative false is the truth. + */ +@Service +public class FollowService { + + private final InteractionRepository interactionRepository; + private final UserExistenceGateway userExistenceGateway; + + public FollowService(InteractionRepository interactionRepository, + UserExistenceGateway userExistenceGateway) { + this.interactionRepository = interactionRepository; + this.userExistenceGateway = userExistenceGateway; + } + + @Transactional + public FollowStateResponse follow(UUID userId, UUID targetUserId) { + if (userId.equals(targetUserId)) { + throw new BusinessException(ErrorCode.FOLLOW_RULE_VIOLATION); + } + requireActive(targetUserId); + interactionRepository.insertFollow(userId, targetUserId); + return new FollowStateResponse(true, interactionRepository.countFollowers(targetUserId)); + } + + @Transactional + public FollowStateResponse unfollow(UUID userId, UUID targetUserId) { + requireActive(targetUserId); + interactionRepository.deleteFollow(userId, targetUserId); + return new FollowStateResponse(false, interactionRepository.countFollowers(targetUserId)); + } + + @Transactional(readOnly = true) + public FollowStatsResponse stats(UUID viewerId, UUID targetUserId) { + requireActive(targetUserId); + return new FollowStatsResponse( + interactionRepository.countFollowers(targetUserId), + interactionRepository.countFollowing(targetUserId), + interactionRepository.followExists(viewerId, targetUserId)); + } + + private void requireActive(UUID targetUserId) { + if (!userExistenceGateway.existsActive(targetUserId)) { + throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/InteractionService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/InteractionService.java new file mode 100644 index 0000000..4853210 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/InteractionService.java @@ -0,0 +1,78 @@ +package com.patbond.patbond.community.service; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.community.dto.BookmarkStateResponse; +import com.patbond.patbond.community.dto.LikeStateResponse; +import com.patbond.patbond.community.repository.InteractionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Binary post interactions (T3-06, ADR-019): PUT/DELETE are idempotent by + * construction — the relation row's composite primary key is the + * idempotency key, the counter column moves in the same transaction and + * only by the number of rows the relation write actually changed, so + * concurrent duplicates converge (N concurrent PUTs land exactly one row + * and exactly +1) and every response carries the authoritative terminal + * state. The interaction gate is the post's public face: anything not + * published-and-live answers the byte-identical 404/40403 on PUT and + * DELETE alike. + */ +@Service +public class InteractionService { + + private final InteractionRepository interactionRepository; + + public InteractionService(InteractionRepository interactionRepository) { + this.interactionRepository = interactionRepository; + } + + @Transactional + public LikeStateResponse like(UUID userId, UUID postId) { + requireInteractable(postId); + int inserted = interactionRepository.insertLike(postId, userId); + long count = inserted > 0 + ? interactionRepository.bumpLikeCount(postId, inserted) + : interactionRepository.likeCount(postId); + return new LikeStateResponse(true, count); + } + + @Transactional + public LikeStateResponse unlike(UUID userId, UUID postId) { + requireInteractable(postId); + int deleted = interactionRepository.deleteLike(postId, userId); + long count = deleted > 0 + ? interactionRepository.bumpLikeCount(postId, -deleted) + : interactionRepository.likeCount(postId); + return new LikeStateResponse(false, count); + } + + @Transactional + public BookmarkStateResponse bookmark(UUID userId, UUID postId) { + requireInteractable(postId); + int inserted = interactionRepository.insertBookmark(postId, userId); + long count = inserted > 0 + ? interactionRepository.bumpBookmarkCount(postId, inserted) + : interactionRepository.bookmarkCount(postId); + return new BookmarkStateResponse(true, count); + } + + @Transactional + public BookmarkStateResponse unbookmark(UUID userId, UUID postId) { + requireInteractable(postId); + int deleted = interactionRepository.deleteBookmark(postId, userId); + long count = deleted > 0 + ? interactionRepository.bumpBookmarkCount(postId, -deleted) + : interactionRepository.bookmarkCount(postId); + return new BookmarkStateResponse(false, count); + } + + private void requireInteractable(UUID postId) { + if (!interactionRepository.isInteractable(postId)) { + throw new BusinessException(ErrorCode.POST_NOT_FOUND); + } + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/support/BookmarkCursor.java b/patbond-community/src/main/java/com/patbond/patbond/community/support/BookmarkCursor.java new file mode 100644 index 0000000..f269a15 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/support/BookmarkCursor.java @@ -0,0 +1,46 @@ +package com.patbond.patbond.community.support; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.UUID; + +/** + * Opaque cursor of the my-bookmarks list (bookmarks.created_at DESC, + * post_id DESC — the exact key of ix_post_bookmarks_user_created). The key + * lives on the RELATION row, not the post: a bookmarked post that later + * turns invisible is filtered inside the same keyset query, so pages stay + * complete and the cursor never points at a value the client saw filtered. + * Encoding is the shared base64url("epochMicros:id") shape. + */ +public record BookmarkCursor(OffsetDateTime bookmarkedAt, UUID postId) { + + public String encode() { + long micros = Math.multiplyExact(bookmarkedAt.toInstant().getEpochSecond(), 1_000_000L) + + bookmarkedAt.getNano() / 1_000L; + return Base64.getUrlEncoder().withoutPadding() + .encodeToString((micros + ":" + postId).getBytes(StandardCharsets.UTF_8)); + } + + /** @throws BusinessException 40000 when the cursor is not one we issued */ + public static BookmarkCursor decode(String cursor) { + try { + String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + int sep = raw.indexOf(':'); + long micros = Long.parseLong(raw.substring(0, sep)); + UUID postId = UUID.fromString(raw.substring(sep + 1)); + OffsetDateTime bookmarkedAt = Instant.ofEpochSecond( + Math.floorDiv(micros, 1_000_000L), + Math.floorMod(micros, 1_000_000L) * 1_000L) + .atOffset(ZoneOffset.UTC); + return new BookmarkCursor(bookmarkedAt, postId); + } catch (RuntimeException e) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效"); + } + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/interaction/FollowIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/interaction/FollowIntegrationTest.java new file mode 100644 index 0000000..56143af --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/interaction/FollowIntegrationTest.java @@ -0,0 +1,156 @@ +package com.patbond.patbond.community.interaction; + +import com.patbond.patbond.community.post.PostApiTestBase; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * T3-07 minimal follow surface on the real database: idempotent + * follow/unfollow with authoritative state, the 42204 self-follow gate, + * the 40406 target gate (absent and 注销 merged), the numbers endpoint + * and true concurrent convergence on the composite primary key. + */ +class FollowIntegrationTest extends PostApiTestBase { + + @Test + void followLifecycleIsIdempotentWithAuthoritativeState() throws Exception { + UUID follower = newUser(); + UUID target = newUser(); + + mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), follower)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.following").value(true)) + .andExpect(jsonPath("$.data.followerCount").value(1)); + mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), follower)) + .andExpect(jsonPath("$.data.following").value(true)) + .andExpect(jsonPath("$.data.followerCount").value(1)); + + mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), follower)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.following").value(false)) + .andExpect(jsonPath("$.data.followerCount").value(0)); + mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), follower)) + .andExpect(jsonPath("$.data.following").value(false)) + .andExpect(jsonPath("$.data.followerCount").value(0)); + assertThat(followRows(target)).isEqualTo(0); + } + + @Test + void selfFollowIsRejectedWith42204() throws Exception { + UUID user = newUser(); + mockMvc.perform(authed(put("/api/v1/users/" + user + "/follow"), user)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(42204)) + .andExpect(jsonPath("$.message").value("不能关注自己")); + // DELETE stays a plain idempotent no-op — the row cannot exist + mockMvc.perform(authed(delete("/api/v1/users/" + user + "/follow"), user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.following").value(false)); + } + + @Test + void absentOrCancelledTargetAnswers40406OnEveryPath() throws Exception { + UUID caller = newUser(); + UUID ghost = UUID.randomUUID(); + UUID cancelled = newUser(); + jdbcClient.sql("UPDATE identity.users SET status = 'deleted', deleted_at = now()" + + " WHERE id = :id") + .param("id", cancelled) + .update(); + + for (UUID target : List.of(ghost, cancelled)) { + mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), caller)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40406)); + mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), caller)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40406)); + mockMvc.perform(authed(get("/api/v1/users/" + target + "/follow-stats"), caller)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40406)); + } + mockMvc.perform(authed(put("/api/v1/users/不是UUID/follow"), caller)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + @Test + void followStatsCountBothDirectionsWithViewerFlag() throws Exception { + UUID alice = newUser(); + UUID bob = newUser(); + UUID carol = newUser(); + // alice→bob, carol→bob, bob→alice + mockMvc.perform(authed(put("/api/v1/users/" + bob + "/follow"), alice)) + .andExpect(status().isOk()); + mockMvc.perform(authed(put("/api/v1/users/" + bob + "/follow"), carol)) + .andExpect(status().isOk()); + mockMvc.perform(authed(put("/api/v1/users/" + alice + "/follow"), bob)) + .andExpect(status().isOk()); + + mockMvc.perform(authed(get("/api/v1/users/" + bob + "/follow-stats"), alice)) + .andExpect(jsonPath("$.data.followerCount").value(2)) + .andExpect(jsonPath("$.data.followingCount").value(1)) + .andExpect(jsonPath("$.data.followedByMe").value(true)); + mockMvc.perform(authed(get("/api/v1/users/" + alice + "/follow-stats"), carol)) + .andExpect(jsonPath("$.data.followerCount").value(1)) + .andExpect(jsonPath("$.data.followingCount").value(1)) + .andExpect(jsonPath("$.data.followedByMe").value(false)); + // asking about oneself: followedByMe is definitionally false + mockMvc.perform(authed(get("/api/v1/users/" + bob + "/follow-stats"), bob)) + .andExpect(jsonPath("$.data.followerCount").value(2)) + .andExpect(jsonPath("$.data.followedByMe").value(false)); + } + + @Test + void concurrentDuplicateFollowsLandExactlyOneRow() throws Exception { + UUID follower = newUser(); + UUID target = newUser(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(3); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + results.add(pool.submit(() -> { + start.await(); + return mockMvc.perform( + authed(put("/api/v1/users/" + target + "/follow"), follower)) + .andReturn().getResponse().getStatus(); + })); + } + start.countDown(); + for (Future result : results) { + assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(200); + } + } finally { + pool.shutdownNow(); + } + assertThat(followRows(target)).isEqualTo(1); + mockMvc.perform(authed(get("/api/v1/users/" + target + "/follow-stats"), follower)) + .andExpect(jsonPath("$.data.followerCount").value(1)); + } + + private long followRows(UUID followee) { + return jdbcClient.sql(""" + SELECT count(*) FROM community.user_follows + WHERE followee_user_id = :id + """) + .param("id", followee) + .query(Long.class) + .single(); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/interaction/LikeBookmarkIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/interaction/LikeBookmarkIntegrationTest.java new file mode 100644 index 0000000..5805e6c --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/interaction/LikeBookmarkIntegrationTest.java @@ -0,0 +1,295 @@ +package com.patbond.patbond.community.interaction; + +import com.fasterxml.jackson.databind.JsonNode; +import com.patbond.patbond.community.post.PostApiTestBase; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * T3-06 idempotent like/bookmark on the real database: the authoritative + * terminal-state responses, TRUE concurrent convergence on the composite + * primary key (the M3 acceptance criterion: N concurrent PUTs count + * exactly 1), the 40403 interaction gate, the my-bookmarks keyset list + * with silent removal, and column-vs-relation reconciliation. + */ +class LikeBookmarkIntegrationTest extends PostApiTestBase { + + @Test + void likeLifecycleIsIdempotentWithAuthoritativeState() throws Exception { + UUID user = newUser(); + String postId = publishPost(user); + + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.liked").value(true)) + .andExpect(jsonPath("$.data.likeCount").value(1)); + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.liked").value(true)) + .andExpect(jsonPath("$.data.likeCount").value(1)); + assertThat(likeRows(postId)).isEqualTo(1); + + mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.liked").value(false)) + .andExpect(jsonPath("$.data.likeCount").value(0)); + // cancelling a like that does not exist neither errors nor + // decrements (工单验收) + mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.liked").value(false)) + .andExpect(jsonPath("$.data.likeCount").value(0)); + assertThat(likeRows(postId)).isEqualTo(0); + } + + @Test + void bookmarkLifecycleIsIdempotentWithAuthoritativeState() throws Exception { + UUID user = newUser(); + String postId = publishPost(user); + + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookmarked").value(true)) + .andExpect(jsonPath("$.data.bookmarkCount").value(1)); + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user)) + .andExpect(jsonPath("$.data.bookmarkCount").value(1)); + mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), user)) + .andExpect(jsonPath("$.data.bookmarked").value(false)) + .andExpect(jsonPath("$.data.bookmarkCount").value(0)); + mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), user)) + .andExpect(jsonPath("$.data.bookmarkCount").value(0)); + } + + @Test + void distinctUsersAccumulateAndSurfaceInDetail() throws Exception { + UUID author = newUser(); + UUID other = newUser(); + String postId = publishPost(author); + + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), author)) + .andExpect(jsonPath("$.data.likeCount").value(1)); + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), other)) + .andExpect(jsonPath("$.data.likeCount").value(2)); + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), other)) + .andExpect(jsonPath("$.data.bookmarkCount").value(1)); + + mockMvc.perform(authed(get("/api/v1/posts/" + postId), other)) + .andExpect(jsonPath("$.data.likeCount").value(2)) + .andExpect(jsonPath("$.data.bookmarkCount").value(1)) + .andExpect(jsonPath("$.data.likedByMe").value(true)) + .andExpect(jsonPath("$.data.bookmarkedByMe").value(true)); + mockMvc.perform(authed(get("/api/v1/posts/" + postId), author)) + .andExpect(jsonPath("$.data.likedByMe").value(true)) + .andExpect(jsonPath("$.data.bookmarkedByMe").value(false)); + } + + @Test + void interactionsOnInvisiblePostsAnswerIdentical40403() throws Exception { + UUID author = newUser(); + String ownDraft = createPost(author, "{\"content\": \"草稿\"}").get("id").asText(); + String hidden = publishPost(author); + jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id") + .param("id", UUID.fromString(hidden)) + .update(); + String deleted = publishPost(author); + mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author)) + .andExpect(status().isOk()); + + Set bodies = new LinkedHashSet<>(); + for (String target : List.of(ownDraft, hidden, deleted, UUID.randomUUID().toString())) { + for (String action : List.of("like", "bookmark")) { + MvcResult puts = mockMvc.perform( + authed(put("/api/v1/posts/" + target + "/" + action), author)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40403)) + .andReturn(); + MvcResult deletes = mockMvc.perform( + authed(delete("/api/v1/posts/" + target + "/" + action), author)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40403)) + .andReturn(); + bodies.add(puts.getResponse().getContentAsString()); + bodies.add(deletes.getResponse().getContentAsString()); + } + } + assertThat(bodies).hasSize(1); + } + + @Test + void concurrentDuplicatePutsCountExactlyOne() throws Exception { + UUID user = newUser(); + String postId = publishPost(user); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + results.add(pool.submit(() -> { + start.await(); + return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user)) + .andReturn().getResponse().getStatus(); + })); + } + start.countDown(); + for (Future result : results) { + assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(200); + } + } finally { + pool.shutdownNow(); + } + assertThat(likeRows(postId)).isEqualTo(1); + assertThat(likeColumn(postId)).isEqualTo(1); + } + + @Test + void concurrentPutAndDeleteConvergeOnConsistentTerminalState() throws Exception { + UUID user = newUser(); + String postId = publishPost(user); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future putting = pool.submit(() -> { + start.await(); + return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user)) + .andReturn().getResponse().getStatus(); + }); + Future deleting = pool.submit(() -> { + start.await(); + return mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user)) + .andReturn().getResponse().getStatus(); + }); + start.countDown(); + assertThat(putting.get(30, TimeUnit.SECONDS)).isEqualTo(200); + assertThat(deleting.get(30, TimeUnit.SECONDS)).isEqualTo(200); + } finally { + pool.shutdownNow(); + } + // whichever order the race resolved in, the column agrees with the + // relation table — never a phantom count + assertThat(likeColumn(postId)).isEqualTo(likeRows(postId)); + } + + @Test + void countColumnsReconcileWithRelationRowsAfterMixedOps() throws Exception { + UUID author = newUser(); + UUID second = newUser(); + UUID third = newUser(); + String postId = publishPost(author); + for (UUID user : List.of(author, second, third)) { + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user)) + .andExpect(status().isOk()); + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user)) + .andExpect(status().isOk()); + } + mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), second)) + .andExpect(status().isOk()); + mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), third)) + .andExpect(status().isOk()); + + assertThat(likeColumn(postId)).isEqualTo(2).isEqualTo(likeRows(postId)); + long bookmarkColumn = jdbcClient.sql( + "SELECT bookmark_count FROM community.posts WHERE id = :id") + .param("id", UUID.fromString(postId)) + .query(Long.class) + .single(); + long bookmarkRows = jdbcClient.sql( + "SELECT count(*) FROM community.post_bookmarks WHERE post_id = :id") + .param("id", UUID.fromString(postId)) + .query(Long.class) + .single(); + assertThat(bookmarkColumn).isEqualTo(2).isEqualTo(bookmarkRows); + } + + @Test + void myBookmarksPagesByBookmarkTimeAndDropsInvisible() throws Exception { + UUID author = newUser(); + UUID reader = newUser(); + List posts = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + posts.add(publishPost(author)); + } + for (String postId : posts) { + mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), reader)) + .andExpect(status().isOk()); + } + // one bookmarked post soft-deleted, one hidden → silently dropped + mockMvc.perform(authed(delete("/api/v1/posts/" + posts.get(1)), author)) + .andExpect(status().isOk()); + jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id") + .param("id", UUID.fromString(posts.get(3))) + .update(); + + List seen = new ArrayList<>(); + String cursor = null; + for (int page = 0; page < 3; page++) { + String url = "/api/v1/me/bookmarks?limit=2" + + (cursor == null ? "" : "&cursor=" + cursor); + JsonNode body = data(mockMvc.perform(authed(get(url), reader)) + .andExpect(status().isOk()) + .andReturn()); + for (JsonNode item : body.get("items")) { + seen.add(item.get("id").asText()); + // the item IS the feed card: cover-less text post, counts, + // viewer flags, non-null publishedAt + assertThat(item.get("bookmarkedByMe").asBoolean()).isTrue(); + assertThat(item.get("publishedAt").isNull()).isFalse(); + assertThat(item.has("contentPreview")).isTrue(); + assertThat(item.has("content")).isFalse(); + } + if (!body.get("hasMore").asBoolean()) { + break; + } + cursor = body.get("nextCursor").asText(); + } + // bookmark order DESC (posts were bookmarked 0→4), invisible dropped + assertThat(seen).containsExactly(posts.get(4), posts.get(2), posts.get(0)); + } + + @Test + void myBookmarksRejectsBadPagingInput() throws Exception { + UUID user = newUser(); + mockMvc.perform(authed(get("/api/v1/me/bookmarks?limit=101"), user)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + mockMvc.perform(authed(get("/api/v1/me/bookmarks?cursor=损坏"), user)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + private String publishPost(UUID author) throws Exception { + return createPost(author, "{\"content\": \"被互动的帖子\", \"status\": \"published\"}") + .get("id").asText(); + } + + private long likeRows(String postId) { + return jdbcClient.sql("SELECT count(*) FROM community.post_likes WHERE post_id = :id") + .param("id", UUID.fromString(postId)) + .query(Long.class) + .single(); + } + + private long likeColumn(String postId) { + return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id") + .param("id", UUID.fromString(postId)) + .query(Long.class) + .single(); + } +}