Compare commits
2 Commits
99a3c1f8ab
...
7f1dd33097
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f1dd33097 | |||
| 19e8cba59f |
@@ -26,9 +26,12 @@ 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, "媒体资源不存在"),
|
||||||
|
COMMENT_NOT_FOUND(40404, 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, "服务器内部错误"),
|
||||||
|
|||||||
+34
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CommentResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CreateCommentRequest;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.CommentService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat comment endpoints (T3-07). Delete rides the top-level short path
|
||||||
|
* (commentId is globally unique — the pets-domain precedent); create
|
||||||
|
* carries a MANDATORY Idempotency-Key (ADR-019). All permission and error
|
||||||
|
* semantics live in CommentService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
private final CommentService commentService;
|
||||||
|
|
||||||
|
public CommentController(CommentService commentService) {
|
||||||
|
this.commentService = commentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/posts/{postId}/comments")
|
||||||
|
public ApiResponse<CursorPage<CommentResponse>> list(
|
||||||
|
@PathVariable UUID postId,
|
||||||
|
@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(commentService.list(postId, limit, cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/v1/posts/{postId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ApiResponse<CommentResponse> create(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId,
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
|
@Valid @RequestBody CreateCommentRequest request) {
|
||||||
|
return ApiResponse.success(commentService.create(userId, postId, idempotencyKey, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/v1/comments/{commentId}")
|
||||||
|
public ApiResponse<Void> delete(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID commentId) {
|
||||||
|
commentService.delete(userId, commentId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -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) {
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One flat comment (T3-07 定型): the author and the optional @-reply target
|
||||||
|
* both travel as the D3-9 AuthorSummary shape, resolved through the same
|
||||||
|
* batch profile gateway as posts, so a degraded profile service renders
|
||||||
|
* id-only summaries here too and never fails the request.
|
||||||
|
*/
|
||||||
|
public record CommentResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID postId,
|
||||||
|
AuthorSummaryResponse author,
|
||||||
|
AuthorSummaryResponse replyToUser,
|
||||||
|
String content,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/posts/{postId}/comments. Content width mirrors
|
||||||
|
* ck_comments_content (1~2000 after trim); {@code replyToUserId} is the
|
||||||
|
* optional flat @-reply target (single level, no parentCommentId — ADR-018
|
||||||
|
* rules out nested threads).
|
||||||
|
*/
|
||||||
|
public class CreateCommentRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "content 不能为空")
|
||||||
|
@Size(max = 2000, message = "content 最长 2000 字符")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** Optional @-reply target; must be an existing active user (40406). */
|
||||||
|
private UUID replyToUserId;
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getReplyToUserId() {
|
||||||
|
return replyToUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReplyToUserId(UUID replyToUserId) {
|
||||||
|
this.replyToUserId = replyToUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -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) {
|
||||||
|
}
|
||||||
+9
@@ -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) {
|
||||||
|
}
|
||||||
+10
@@ -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) {
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package com.patbond.patbond.community.repository;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.support.CommentCursor;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* community.comments access. Post visibility and authorship decisions live
|
||||||
|
* in CommentService; every query here filters on the comment's own state
|
||||||
|
* only (status='visible' is the single liveness predicate — 'hidden' has no
|
||||||
|
* producing endpoint in M3 and 'deleted' pairs with deleted_at,
|
||||||
|
* ck_comments_deleted).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class CommentRepository {
|
||||||
|
|
||||||
|
private static final String SELECT_COMMENT = """
|
||||||
|
SELECT c.id, c.post_id, c.author_user_id, c.reply_to_user_id, c.content,
|
||||||
|
c.status, c.request_hash, c.created_at, c.deleted_at
|
||||||
|
FROM community.comments c
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public CommentRepository(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts one comment; the conflict target is the (author_user_id,
|
||||||
|
* client_request_id) unique constraint, so a keyed replay is a no-op and
|
||||||
|
* the caller settles retry-vs-mismatch on the stored request_hash
|
||||||
|
* (ADR-019, same shape as posts).
|
||||||
|
*
|
||||||
|
* @return rows inserted — 0 means this author already used the key
|
||||||
|
*/
|
||||||
|
public int insertComment(UUID id, UUID postId, UUID authorUserId, UUID replyToUserId,
|
||||||
|
String content, String clientRequestId, byte[] requestHash) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO community.comments
|
||||||
|
(id, post_id, author_user_id, reply_to_user_id, content,
|
||||||
|
client_request_id, request_hash)
|
||||||
|
VALUES (:id, :postId, :authorUserId, :replyToUserId, :content,
|
||||||
|
:clientRequestId, :requestHash)
|
||||||
|
ON CONFLICT (author_user_id, client_request_id) DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("replyToUserId", replyToUserId)
|
||||||
|
.param("content", content)
|
||||||
|
.param("clientRequestId", clientRequestId)
|
||||||
|
.param("requestHash", requestHash)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
|
||||||
|
public Optional<CommentRow> findByAuthorAndClientRequestId(UUID authorUserId,
|
||||||
|
String clientRequestId) {
|
||||||
|
return jdbcClient.sql(SELECT_COMMENT
|
||||||
|
+ " WHERE c.author_user_id = :authorUserId"
|
||||||
|
+ " AND c.client_request_id = :clientRequestId")
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("clientRequestId", clientRequestId)
|
||||||
|
.query(CommentRepository::mapComment)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the visible row for the delete transition: concurrent deletes
|
||||||
|
* of the same comment serialize here, so the status flip — and with it
|
||||||
|
* the comment_count decrement — happens exactly once.
|
||||||
|
*/
|
||||||
|
public Optional<CommentRow> lockVisibleById(UUID id) {
|
||||||
|
return jdbcClient.sql(SELECT_COMMENT + " WHERE c.id = :id AND c.status = 'visible' FOR UPDATE")
|
||||||
|
.param("id", id)
|
||||||
|
.query(CommentRepository::mapComment)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The soft-delete transition; deleted_at pairs with status (ck_comments_deleted). */
|
||||||
|
public int softDelete(UUID id) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.comments
|
||||||
|
SET status = 'deleted', deleted_at = now()
|
||||||
|
WHERE id = :id AND status = 'visible'
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of a post's visible comments in (created_at DESC, id DESC) —
|
||||||
|
* the exact key of ix_comments_post_created. The caller asks for
|
||||||
|
* limit+1 rows to learn whether more exist.
|
||||||
|
*/
|
||||||
|
public List<CommentRow> pageByPost(UUID postId, CommentCursor after, int limitPlusOne) {
|
||||||
|
String sql = SELECT_COMMENT + " WHERE c.post_id = :postId AND c.status = 'visible'";
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (c.created_at, c.id) < (:cursorCreatedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY c.created_at DESC, c.id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorCreatedAt", after.createdAt())
|
||||||
|
.param("cursorId", after.id());
|
||||||
|
}
|
||||||
|
return spec.query(CommentRepository::mapComment).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CommentRow mapComment(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new CommentRow(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("post_id", UUID.class),
|
||||||
|
rs.getObject("author_user_id", UUID.class),
|
||||||
|
rs.getObject("reply_to_user_id", UUID.class),
|
||||||
|
rs.getString("content"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getBytes("request_hash"),
|
||||||
|
rs.getObject("created_at", OffsetDateTime.class),
|
||||||
|
rs.getObject("deleted_at", OffsetDateTime.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One comments row; requestHash carries the ADR-019 replay comparison. */
|
||||||
|
public record CommentRow(
|
||||||
|
UUID id,
|
||||||
|
UUID postId,
|
||||||
|
UUID authorUserId,
|
||||||
|
UUID replyToUserId,
|
||||||
|
String content,
|
||||||
|
String status,
|
||||||
|
byte[] requestHash,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime deletedAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
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.author.AuthorProfileGateway;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CommentResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CreateCommentRequest;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.repository.CommentRepository;
|
||||||
|
import com.patbond.patbond.community.repository.CommentRepository.CommentRow;
|
||||||
|
import com.patbond.patbond.community.repository.InteractionRepository;
|
||||||
|
import com.patbond.patbond.community.support.CommentCursor;
|
||||||
|
import com.patbond.patbond.community.support.RequestHashes;
|
||||||
|
import com.patbond.patbond.community.support.UuidV7;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat comments (T3-07 定型). The semantics fixed here are T3-10 freeze
|
||||||
|
* input:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Interaction surface</b> — comments attach to the PUBLIC face of
|
||||||
|
* a post only: published and live. A draft (its author included),
|
||||||
|
* hidden/archived or soft-deleted post answers the byte-identical
|
||||||
|
* 404/40403 on every comment path — 互动域不区分「作者的草稿」.</li>
|
||||||
|
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory,
|
||||||
|
* stored as client_request_id next to the normalized request hash;
|
||||||
|
* same key + same payload returns the first comment (201 again),
|
||||||
|
* different payload 40905, keys scoped per author. A replay hitting
|
||||||
|
* a since-deleted first comment answers 404/40404 (T3-04 §2.4
|
||||||
|
* 同一先例).</li>
|
||||||
|
* <li><b>Delete</b> — the comment's author only(D3-7 拍板:帖主删他人
|
||||||
|
* 评论首版不做); a non-author on a visible comment gets 403/40301,
|
||||||
|
* everything invisible (absent, deleted, its post invisible) merges
|
||||||
|
* into 404/40404. comment_count moves -1 in the same transaction,
|
||||||
|
* exactly once — the FOR UPDATE lock serializes double deletes.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CommentService {
|
||||||
|
|
||||||
|
private final CommentRepository commentRepository;
|
||||||
|
private final InteractionRepository interactionRepository;
|
||||||
|
private final UserExistenceGateway userExistenceGateway;
|
||||||
|
private final AuthorProfileGateway authorProfileGateway;
|
||||||
|
|
||||||
|
public CommentService(CommentRepository commentRepository,
|
||||||
|
InteractionRepository interactionRepository,
|
||||||
|
UserExistenceGateway userExistenceGateway,
|
||||||
|
AuthorProfileGateway authorProfileGateway) {
|
||||||
|
this.commentRepository = commentRepository;
|
||||||
|
this.interactionRepository = interactionRepository;
|
||||||
|
this.userExistenceGateway = userExistenceGateway;
|
||||||
|
this.authorProfileGateway = authorProfileGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CursorPage<CommentResponse> list(UUID postId, int limit, String cursor) {
|
||||||
|
requireInteractable(postId);
|
||||||
|
CommentCursor after = cursor == null ? null : CommentCursor.decode(cursor);
|
||||||
|
List<CommentRow> rows = commentRepository.pageByPost(postId, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<CommentRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new CommentCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(assemble(page), nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CommentResponse create(UUID userId, UUID postId, String idempotencyKey,
|
||||||
|
CreateCommentRequest request) {
|
||||||
|
String key = normalizeIdempotencyKey(idempotencyKey);
|
||||||
|
String content = requireContent(request.getContent());
|
||||||
|
requireInteractable(postId);
|
||||||
|
if (request.getReplyToUserId() != null
|
||||||
|
&& !userExistenceGateway.existsActive(request.getReplyToUserId())) {
|
||||||
|
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] requestHash = RequestHashes.sha256(
|
||||||
|
canonicalize(postId, content, request.getReplyToUserId()));
|
||||||
|
UUID id = UuidV7.generate();
|
||||||
|
int inserted = commentRepository.insertComment(id, postId, userId,
|
||||||
|
request.getReplyToUserId(), content, key, requestHash);
|
||||||
|
if (inserted == 0) {
|
||||||
|
CommentRow first = commentRepository.findByAuthorAndClientRequestId(userId, key)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
if (!Arrays.equals(first.requestHash(), requestHash)) {
|
||||||
|
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
|
||||||
|
}
|
||||||
|
if (first.deletedAt() != null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return assemble(List.of(first)).get(0);
|
||||||
|
}
|
||||||
|
interactionRepository.bumpCommentCount(postId, 1);
|
||||||
|
CommentRow row = commentRepository.lockVisibleById(id)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
return assemble(List.of(row)).get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID userId, UUID commentId) {
|
||||||
|
CommentRow comment = commentRepository.lockVisibleById(commentId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND));
|
||||||
|
if (!interactionRepository.isInteractable(comment.postId())) {
|
||||||
|
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!comment.authorUserId().equals(userId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
|
||||||
|
}
|
||||||
|
commentRepository.softDelete(commentId);
|
||||||
|
interactionRepository.bumpCommentCount(comment.postId(), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireInteractable(UUID postId) {
|
||||||
|
if (!interactionRepository.isInteractable(postId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeIdempotencyKey(String idempotencyKey) {
|
||||||
|
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
|
||||||
|
if (key.isEmpty() || key.length() > 128) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Idempotency-Key 必带且长度须在 1~128 字符");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireContent(String content) {
|
||||||
|
String trimmed = content == null ? "" : content.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.length() > 2000) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~2000 字符");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
|
||||||
|
private static String canonicalize(UUID postId, String content, UUID replyToUserId) {
|
||||||
|
return "comment.v1\n" + postId + '\n'
|
||||||
|
+ (replyToUserId == null ? "" : replyToUserId) + '\n'
|
||||||
|
+ content + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CommentResponse> assemble(List<CommentRow> rows) {
|
||||||
|
Set<UUID> userIds = new HashSet<>();
|
||||||
|
for (CommentRow row : rows) {
|
||||||
|
userIds.add(row.authorUserId());
|
||||||
|
if (row.replyToUserId() != null) {
|
||||||
|
userIds.add(row.replyToUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<UUID, AuthorSummaryResponse> profiles = authorProfileGateway.summarize(userIds);
|
||||||
|
return rows.stream().map(row -> new CommentResponse(
|
||||||
|
row.id(),
|
||||||
|
row.postId(),
|
||||||
|
profiles.getOrDefault(row.authorUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||||
|
row.replyToUserId() == null ? null
|
||||||
|
: profiles.getOrDefault(row.replyToUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.replyToUserId())),
|
||||||
|
row.content(),
|
||||||
|
row.createdAt())).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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())
|
||||||
|
|||||||
+67
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
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 a post's comment list (created_at DESC, id DESC — the
|
||||||
|
* exact key of ix_comments_post_created), same encoding as
|
||||||
|
* {@link PostCursor}: base64url("epochMicros:id"), next page selects
|
||||||
|
* {@code (created_at, id) < (cursor)} so ties on created_at are broken by
|
||||||
|
* id and rows are neither lost nor repeated across page boundaries.
|
||||||
|
*/
|
||||||
|
public record CommentCursor(OffsetDateTime createdAt, UUID id) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(createdAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ createdAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static CommentCursor 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 id = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime createdAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new CommentCursor(createdAt, id);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+363
@@ -0,0 +1,363 @@
|
|||||||
|
package com.patbond.patbond.community.interaction;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.patbond.patbond.community.post.PostApiTestBase;
|
||||||
|
import com.patbond.patbond.community.support.CommunityTestData;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
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.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T3-07 flat comments on the real database: the six canonical paths, the
|
||||||
|
* ADR-019 keyed-idempotency matrix, the interaction-surface 40403 merge,
|
||||||
|
* DESC keyset pagination and the same-transaction comment_count invariant.
|
||||||
|
* These assertions are T3-10 freeze input for the comment domain.
|
||||||
|
*/
|
||||||
|
class CommentIntegrationTest extends PostApiTestBase {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createCommentReturnsFullShapeAndBumpsCount() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID commenter = newUser();
|
||||||
|
CommunityTestData.setNickname(jdbcClient, commenter, "毛豆妈");
|
||||||
|
String postId = publishPost(author);
|
||||||
|
|
||||||
|
mockMvc.perform(commentRequest(commenter, postId, UUID.randomUUID().toString(),
|
||||||
|
"{\"content\": \" 说得好! \"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.postId").value(postId))
|
||||||
|
.andExpect(jsonPath("$.data.author.userId").value(commenter.toString()))
|
||||||
|
.andExpect(jsonPath("$.data.author.nickname").value("毛豆妈"))
|
||||||
|
.andExpect(jsonPath("$.data.replyToUser").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.content").value("说得好!"))
|
||||||
|
.andExpect(jsonPath("$.data.createdAt").isNotEmpty());
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/v1/posts/" + postId), author))
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createWithReplyToUserCarriesReplySummary() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID replyTarget = newUser();
|
||||||
|
CommunityTestData.setNickname(jdbcClient, replyTarget, "被@的人");
|
||||||
|
String postId = publishPost(author);
|
||||||
|
|
||||||
|
mockMvc.perform(commentRequest(author, postId, UUID.randomUUID().toString(),
|
||||||
|
"{\"content\": \"回复你\", \"replyToUserId\": \"" + replyTarget + "\"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.data.replyToUser.userId").value(replyTarget.toString()))
|
||||||
|
.andExpect(jsonPath("$.data.replyToUser.nickname").value("被@的人"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contentAndKeyValidationAnswer40000() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
|
||||||
|
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
|
||||||
|
"{\"content\": \" \"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
|
||||||
|
"{\"content\": \"" + "长".repeat(2001) + "\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
// Idempotency-Key: missing header, blank, oversized
|
||||||
|
mockMvc.perform(authed(post("/api/v1/posts/" + postId + "/comments"), user)
|
||||||
|
.content("{\"content\": \"没带键\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(commentRequest(user, postId, " ", "{\"content\": \"空白键\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(commentRequest(user, postId, "k".repeat(129), "{\"content\": \"超长键\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToAbsentOrDeletedUserAnswers40406() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
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(commentRequest(user, postId, UUID.randomUUID().toString(),
|
||||||
|
"{\"content\": \"@不存在\", \"replyToUserId\": \"" + target + "\"}"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40406))
|
||||||
|
.andExpect(jsonPath("$.message").value("用户不存在"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commentPathsOnInvisiblePostsAnswerIdentical40403() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID stranger = 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<>();
|
||||||
|
// own draft (the interaction surface is the PUBLIC face — the
|
||||||
|
// author's own draft is not commentable), hidden, deleted, absent
|
||||||
|
for (String target : List.of(ownDraft, hidden, deleted, UUID.randomUUID().toString())) {
|
||||||
|
MvcResult postResult = mockMvc.perform(
|
||||||
|
commentRequest(author, target, UUID.randomUUID().toString(),
|
||||||
|
"{\"content\": \"评一下\"}"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40403))
|
||||||
|
.andReturn();
|
||||||
|
bodies.add(postResult.getResponse().getContentAsString());
|
||||||
|
MvcResult listResult = mockMvc.perform(
|
||||||
|
authed(get("/api/v1/posts/" + target + "/comments"), stranger))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40403))
|
||||||
|
.andReturn();
|
||||||
|
bodies.add(listResult.getResponse().getContentAsString());
|
||||||
|
}
|
||||||
|
// anti-enumeration: every invisible case is byte-identical
|
||||||
|
assertThat(bodies).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyedReplayReturnsFirstCommentWithoutDoubleCounting() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
String key = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
String first = data(mockMvc.perform(commentRequest(user, postId, key,
|
||||||
|
"{\"content\": \"就一条\"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn()).get("id").asText();
|
||||||
|
String replay = data(mockMvc.perform(commentRequest(user, postId, key,
|
||||||
|
"{\"content\": \"就一条\"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn()).get("id").asText();
|
||||||
|
|
||||||
|
assertThat(replay).isEqualTo(first);
|
||||||
|
assertThat(countRows("community.comments", "post_id", postId)).isEqualTo(1);
|
||||||
|
mockMvc.perform(authed(get("/api/v1/posts/" + postId), user))
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameKeyDifferentPayloadAnswers40905() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
String key = UUID.randomUUID().toString();
|
||||||
|
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"甲\"}"))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"乙\"}"))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value(40905));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idempotencyKeysAreScopedPerAuthor() throws Exception {
|
||||||
|
UUID one = newUser();
|
||||||
|
UUID two = newUser();
|
||||||
|
String postId = publishPost(one);
|
||||||
|
String shared = UUID.randomUUID().toString();
|
||||||
|
mockMvc.perform(commentRequest(one, postId, shared, "{\"content\": \"同键\"}"))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
mockMvc.perform(commentRequest(two, postId, shared, "{\"content\": \"同键\"}"))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
assertThat(countRows("community.comments", "post_id", postId)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replayAfterFirstCommentDeletedAnswers40404() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
String key = UUID.randomUUID().toString();
|
||||||
|
String commentId = data(mockMvc.perform(commentRequest(user, postId, key,
|
||||||
|
"{\"content\": \"将被删\"}"))
|
||||||
|
.andReturn()).get("id").asText();
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), user))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"将被删\"}"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40404));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPagesNewestFirstWithoutLossOrOverlap() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID reader = newUser();
|
||||||
|
String postId = publishPost(author);
|
||||||
|
List<String> created = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
created.add(data(mockMvc.perform(commentRequest(author, postId,
|
||||||
|
UUID.randomUUID().toString(), "{\"content\": \"评论" + i + "\"}"))
|
||||||
|
.andReturn()).get("id").asText());
|
||||||
|
}
|
||||||
|
String deletedId = created.get(3);
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + deletedId), author))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
List<String> seen = new ArrayList<>();
|
||||||
|
String cursor = null;
|
||||||
|
for (int page = 0; page < 3; page++) {
|
||||||
|
String url = "/api/v1/posts/" + postId + "/comments?limit=3"
|
||||||
|
+ (cursor == null ? "" : "&cursor=" + cursor);
|
||||||
|
JsonNode body = data(mockMvc.perform(authed(get(url), reader))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn());
|
||||||
|
body.get("items").forEach(item -> seen.add(item.get("id").asText()));
|
||||||
|
if (!body.get("hasMore").asBoolean()) {
|
||||||
|
assertThat(body.get("nextCursor").isNull()).isTrue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = body.get("nextCursor").asText();
|
||||||
|
}
|
||||||
|
List<String> expected = new ArrayList<>(created);
|
||||||
|
java.util.Collections.reverse(expected);
|
||||||
|
expected.remove(deletedId);
|
||||||
|
assertThat(seen).containsExactlyElementsOf(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listRejectsBadPagingInput() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments?limit=0"), user))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments?cursor=不是游标"), user))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(authed(get("/api/v1/posts/不是UUID/comments"), user))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteWalksThePermissionBoundary() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID commenter = newUser();
|
||||||
|
UUID stranger = newUser();
|
||||||
|
String postId = publishPost(author);
|
||||||
|
String commentId = data(mockMvc.perform(commentRequest(commenter, postId,
|
||||||
|
UUID.randomUUID().toString(), "{\"content\": \"别人的评论\"}"))
|
||||||
|
.andReturn()).get("id").asText();
|
||||||
|
|
||||||
|
// a visible comment deleted by a non-author (the post's owner
|
||||||
|
// included — D3-7: 帖主删他人评论首版不做) is 403/40301
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), stranger))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(40301));
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), author))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(40301));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), commenter))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
String state = jdbcClient.sql(
|
||||||
|
"SELECT status || ':' || (deleted_at IS NOT NULL) FROM community.comments"
|
||||||
|
+ " WHERE id = :id")
|
||||||
|
.param("id", UUID.fromString(commentId))
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(state).isEqualTo("deleted:true");
|
||||||
|
|
||||||
|
// repeat delete and absent id merge into 404/40404
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), commenter))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40404));
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + UUID.randomUUID()), commenter))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40404));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOnCommentOfDeletedPostAnswers40404() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
String commentId = data(mockMvc.perform(commentRequest(user, postId,
|
||||||
|
UUID.randomUUID().toString(), "{\"content\": \"帖没了\"}"))
|
||||||
|
.andReturn()).get("id").asText();
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/posts/" + postId), user))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), user))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40404));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commentCountReconcilesWithVisibleRows() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = publishPost(user);
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
ids.add(data(mockMvc.perform(commentRequest(user, postId,
|
||||||
|
UUID.randomUUID().toString(), "{\"content\": \"第" + i + "条\"}"))
|
||||||
|
.andReturn()).get("id").asText());
|
||||||
|
}
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/comments/" + ids.get(0)), user))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
long column = jdbcClient.sql("SELECT comment_count FROM community.posts WHERE id = :id")
|
||||||
|
.param("id", UUID.fromString(postId))
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
long visible = jdbcClient.sql("""
|
||||||
|
SELECT count(*) FROM community.comments
|
||||||
|
WHERE post_id = :id AND status = 'visible'
|
||||||
|
""")
|
||||||
|
.param("id", UUID.fromString(postId))
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
assertThat(column).isEqualTo(2).isEqualTo(visible);
|
||||||
|
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments"), user))
|
||||||
|
.andExpect(jsonPath("$.data.items.length()").value(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String publishPost(UUID author) throws Exception {
|
||||||
|
return createPost(author, "{\"content\": \"被评论的帖子\", \"status\": \"published\"}")
|
||||||
|
.get("id").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder commentRequest(UUID userId, String postId, String key,
|
||||||
|
String body) {
|
||||||
|
return authed(post("/api/v1/posts/" + postId + "/comments"), userId)
|
||||||
|
.header("Idempotency-Key", key)
|
||||||
|
.content(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countRows(String table, String column, String value) {
|
||||||
|
return jdbcClient.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :value")
|
||||||
|
.param("value", UUID.fromString(value))
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user