feat: 单层评论——幂等创建/游标列表/作者软删与 comment_count 同事务维护(T3-07,ADR-019)
CI / backend-test (push) Successful in 7m33s

- POST /api/v1/posts/{postId}/comments:Idempotency-Key 必带,落
  client_request_id + 规范化 request_hash(uq author×key,键按作者隔离);
  同键同 payload 返回首条(201,不重复计数),异 payload 409/40905,
  重试撞已删首评 404/40404(沿 T3-04 §2.4 先例);replyToUserId 可选
  @ 回复,目标须为存活用户(404/40406);content trim 后 1~2000
- GET 评论列表:(created_at DESC, id DESC) 走 ix_comments_post_created
  keyset 游标,仅 status=visible,作者与 @ 目标批量走 AuthorProfileGateway
  (降级 id-only 同构复用)
- DELETE /api/v1/comments/{commentId} 顶层短路径:仅评论作者可删
  (D3-7 拍板,帖主删他人评论不做);可见评论他人删 403/40301,
  不存在/已删/所属帖不可见合并 404/40404;FOR UPDATE 锁定状态迁移,
  comment_count 同事务 -1 恰一次
- 评论域同用互动门禁:帖子公开面之外(含作者本人草稿)一律 404/40403
  逐字节一致
- ErrorCode 新增 40404 COMMENT_NOT_FOUND
- CommentIntegrationTest 14 例:六类路径、幂等矩阵专项、分页不丢不重、
  计数对账专项(删评后列值 = visible 行数 = 列表长度)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 10:50:28 +08:00
parent 19e8cba59f
commit 7f1dd33097
8 changed files with 859 additions and 0 deletions
@@ -26,6 +26,7 @@ public enum ErrorCode {
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"),
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
COMMENT_NOT_FOUND(40404, 404, "评论不存在"),
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
@@ -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);
}
}
@@ -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) {
}
@@ -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;
}
}
@@ -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) {
}
}
@@ -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 onlyD3-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();
}
}
@@ -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 无效");
}
}
}
@@ -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();
}
}