From 7f1dd330979862f89c0a9c8c3b592c2356db718e Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Wed, 9 Sep 2026 10:50:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=95=E5=B1=82=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=B9=82=E7=AD=89=E5=88=9B=E5=BB=BA/?= =?UTF-8?q?=E6=B8=B8=E6=A0=87=E5=88=97=E8=A1=A8/=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E8=BD=AF=E5=88=A0=E4=B8=8E=20comment=5Fcount=20=E5=90=8C?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E7=BB=B4=E6=8A=A4=EF=BC=88T3-07=EF=BC=8CADR-?= =?UTF-8?q?019=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../patbond/common/error/ErrorCode.java | 1 + .../controller/CommentController.java | 71 ++++ .../community/dto/CommentResponse.java | 19 + .../community/dto/CreateCommentRequest.java | 38 ++ .../repository/CommentRepository.java | 145 +++++++ .../community/service/CommentService.java | 177 +++++++++ .../community/support/CommentCursor.java | 45 +++ .../interaction/CommentIntegrationTest.java | 363 ++++++++++++++++++ 8 files changed, 859 insertions(+) create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/controller/CommentController.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/dto/CommentResponse.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/dto/CreateCommentRequest.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/repository/CommentRepository.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/service/CommentService.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/support/CommentCursor.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/interaction/CommentIntegrationTest.java diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index ae987d8..bfd4f42 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -26,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 约束不满足"), diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/CommentController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/CommentController.java new file mode 100644 index 0000000..d8e070c --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/CommentController.java @@ -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> 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 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 delete( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID commentId) { + commentService.delete(userId, commentId); + return ApiResponse.success(null); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommentResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommentResponse.java new file mode 100644 index 0000000..2204310 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommentResponse.java @@ -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) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/CreateCommentRequest.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CreateCommentRequest.java new file mode 100644 index 0000000..e5af557 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CreateCommentRequest.java @@ -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; + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/CommentRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/CommentRepository.java new file mode 100644 index 0000000..839554b --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/CommentRepository.java @@ -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 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 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 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) { + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/CommentService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/CommentService.java new file mode 100644 index 0000000..a7162fa --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/CommentService.java @@ -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: + * + *
    + *
  • Interaction surface — 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 — 互动域不区分「作者的草稿」.
  • + *
  • Idempotent create (ADR-019) — 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 + * 同一先例).
  • + *
  • Delete — 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.
  • + *
+ */ +@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 list(UUID postId, int limit, String cursor) { + requireInteractable(postId); + CommentCursor after = cursor == null ? null : CommentCursor.decode(cursor); + List rows = commentRepository.pageByPost(postId, after, limit + 1); + boolean hasMore = rows.size() > limit; + List 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 assemble(List rows) { + Set userIds = new HashSet<>(); + for (CommentRow row : rows) { + userIds.add(row.authorUserId()); + if (row.replyToUserId() != null) { + userIds.add(row.replyToUserId()); + } + } + Map 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(); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/support/CommentCursor.java b/patbond-community/src/main/java/com/patbond/patbond/community/support/CommentCursor.java new file mode 100644 index 0000000..a8b9332 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/support/CommentCursor.java @@ -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 无效"); + } + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/interaction/CommentIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/interaction/CommentIntegrationTest.java new file mode 100644 index 0000000..d86f77f --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/interaction/CommentIntegrationTest.java @@ -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 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 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 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 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 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(); + } +}