feat: 点赞/收藏/关注幂等互动与同事务计数——PUT/DELETE 权威终态、我的收藏游标列表、follow-stats(T3-06/T3-08,ADR-018/019)

- PUT/DELETE like|bookmark:复合主键即幂等键(ON CONFLICT DO NOTHING /
  条件 DELETE),计数列按关系写实际变更行数同事务增减,响应回
  {liked,likeCount}/{bookmarked,bookmarkCount} 权威终态;并发重复施加
  恰计 1、PUT+DELETE 竞态终态列值与关系表恒一致(真并发测试锚定)
- 互动门禁定型:只认帖子公开面(published 且未删)——作者本人草稿、
  hidden/archived、软删、不存在合并逐字节一致 404/40403
- GET /api/v1/me/bookmarks:按 (bookmarks.created_at DESC, post_id DESC)
  keyset 游标,卡片复用 FeedCard 装配;失效帖在页查询内静默剔除,
  游标键在关系行上、分页正确性不受剔除影响
- PUT/DELETE /api/v1/users/{userId}/follow + GET follow-stats:
  自关注 422/42204(ck_user_follows_self 库层兜底);目标不存在/注销
  合并 404/40406,存在性走同库只读 identity.users(ADR-017 例外,
  写门禁不适用 Feign 降级语义);计数实时 COUNT 双向索引
- ErrorCode 新增 40406 TARGET_USER_NOT_FOUND、42204 FOLLOW_RULE_VIOLATION
- 集成测试 14 例(LikeBookmark 9 + Follow 5):六类路径、并发幂等专项、
  计数对账专项、收藏列表静默剔除与分页

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 10:50:06 +08:00
parent 99a3c1f8ab
commit 19e8cba59f
16 changed files with 1113 additions and 0 deletions
@@ -26,9 +26,11 @@ public enum ErrorCode {
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"), VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"), IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"),
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"), MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"), VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"), REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"), MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"),
FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"),
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"), MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"), INTERNAL_ERROR(50000, 500, "服务器内部错误"),
@@ -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();
}
}
@@ -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<FollowStateResponse> 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<FollowStateResponse> 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<FollowStatsResponse> stats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.stats(callerId, userId));
}
}
@@ -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<LikeStateResponse> 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<LikeStateResponse> 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<BookmarkStateResponse> 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<BookmarkStateResponse> 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<CursorPage<FeedCardResponse>> 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));
}
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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();
}
}
@@ -1,5 +1,6 @@
package com.patbond.patbond.community.repository; 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.FeedCursor;
import com.patbond.patbond.community.support.PostCursor; import com.patbond.patbond.community.support.PostCursor;
import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.jdbc.core.simple.JdbcClient;
@@ -229,6 +230,46 @@ public class PostRepository {
return spec.query(PostRepository::mapPost).list(); 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<BookmarkedPostRow> 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) { public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
jdbcClient.sql(""" jdbcClient.sql("""
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption) INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
@@ -350,4 +391,8 @@ public class PostRepository {
Integer widthPx, Integer widthPx,
Integer heightPx) { Integer heightPx) {
} }
/** A bookmarked post plus the relation row's timestamp (the page key). */
public record BookmarkedPostRow(PostRow post, OffsetDateTime bookmarkedAt) {
}
} }
@@ -7,8 +7,10 @@ import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.dto.PostMediaItemResponse; import com.patbond.patbond.community.dto.PostMediaItemResponse;
import com.patbond.patbond.community.media.MediaUrlSigner; import com.patbond.patbond.community.media.MediaUrlSigner;
import com.patbond.patbond.community.repository.PostRepository; 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.PostMediaRow;
import com.patbond.patbond.community.repository.PostRepository.PostRow; import com.patbond.patbond.community.repository.PostRepository.PostRow;
import com.patbond.patbond.community.support.BookmarkCursor;
import com.patbond.patbond.community.support.FeedCursor; import com.patbond.patbond.community.support.FeedCursor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -56,6 +58,28 @@ public class FeedService {
return new CursorPage<>(assembleCards(page), nextCursor, hasMore); 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<FeedCardResponse> listBookmarked(UUID userId, int limit, String cursor) {
BookmarkCursor after = cursor == null ? null : BookmarkCursor.decode(cursor);
List<BookmarkedPostRow> rows = postRepository.pageBookmarked(userId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<BookmarkedPostRow> 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<FeedCardResponse> assembleCards(List<PostRow> rows) { private List<FeedCardResponse> assembleCards(List<PostRow> rows) {
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
.findMediaByPostIds(rows.stream().map(PostRow::id).toList()) .findMediaByPostIds(rows.stream().map(PostRow::id).toList())
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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 无效");
}
}
}
@@ -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<Future<Integer>> 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<Integer> 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();
}
}
@@ -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<String> 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<Future<Integer>> 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<Integer> 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<Integer> putting = pool.submit(() -> {
start.await();
return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
});
Future<Integer> 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<String> 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<String> 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();
}
}