From d98a400f47d100d488210a0ee98d04a6943652a4 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:03:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8E=B7=E8=B5=9E=E8=81=9A=E5=90=88?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20GET=20/api/v1/me/communi?= =?UTF-8?q?ty-stats=20=E8=AF=BB=E4=BE=A7=E5=AE=9E=E6=97=B6=E8=81=9A?= =?UTF-8?q?=E5=90=88=EF=BC=88T3.5-06=EF=BC=8CADR-022=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /api/v1/me/community-stats,返回 {receivedLikeCount, publishedPostCount}:本人「已发布且未软删」帖的 like_count 之和与帖子数 - 口径:草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、运营态 hidden/archived 不计(在 M3 契约里对所有人不可见)、他人帖不计;空数据答 0 不答 null,任何已认证用户都有 stats,从不 404 - 走读侧实时聚合不引冗余列(ADR-022):写侧无按人计数器,也就没有可漂移的 副本;单次查询压在 ix_posts_author_created 的前导列上 - 端点独立而不并入 /users/{userId}/follow-stats(ADR-022 决策 A):后者主体是 「某用户的关注数」,混入「我的获赞」会让一个载荷有两个主体;且本端点主体 恒为 token 里的自己,路径上没有可枚举的 userId - 测试 +12(空数据零值/多帖求和/自赞与取消赞/草稿与软删与运营态排除/他人帖 不串味/401 两态/无他人入口/并发与重复读幂等/载荷形态),community 模块 94 → 106 Co-Authored-By: Claude Fable 5 --- .../community/controller/PostController.java | 14 ++ .../community/dto/CommunityStatsResponse.java | 22 ++ .../community/repository/PostRepository.java | 26 ++ .../community/service/PostService.java | 14 ++ .../post/MeCommunityStatsIntegrationTest.java | 237 ++++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java index c38113f..6c43dc0 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java @@ -1,6 +1,7 @@ package com.patbond.patbond.community.controller; import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.community.dto.CommunityStatsResponse; import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.PostResponse; @@ -86,4 +87,17 @@ public class PostController { @RequestParam(required = false) String cursor) { return ApiResponse.success(postService.listMine(userId, status, limit, cursor)); } + + /** + * The caller's own community numbers (T3.5-06, ADR-022 决策 A: a dedicated + * endpoint rather than an addition to /users/{userId}/follow-stats, whose + * subject is "some user's follow counts" — mixing "my likes received" in + * would give one payload two subjects). Lives here because posts are the + * sole source of both numbers, next to the other /me posts read. + */ + @GetMapping("/api/v1/me/community-stats") + public ApiResponse communityStats( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) { + return ApiResponse.success(postService.communityStats(userId)); + } } diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java new file mode 100644 index 0000000..ab7f562 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java @@ -0,0 +1,22 @@ +package com.patbond.patbond.community.dto; + +/** + * GET /api/v1/me/community-stats (T3.5-06, ADR-022 决策 A) — the caller's own + * community numbers, read-side aggregates over community.posts with no new + * denormalized column. + * + *

Scope, frozen with this ticket: both numbers count ONLY the caller's own + * posts that are {@code status='published'} and not soft-deleted. Drafts are + * excluded (they are not works yet, and their likes cannot exist anyway), + * soft-deleted posts are excluded (deleting a post removes its numbers), and + * the operational states hidden/archived are excluded for the same reason + * they are invisible everywhere else in the M3 contract. Empty data yields + * {@code 0}, never null.

+ * + *

{@code receivedLikeCount} is {@code SUM(posts.like_count)} — the counter + * the write side maintains in the same transaction as the like row (T3-07), + * so this is exact, not an estimate. A user's own likes on their own posts are + * counted, exactly as the per-post number shows them.

+ */ +public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java index b5a4e78..4c01727 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.patbond.patbond.community.repository; +import com.patbond.patbond.community.dto.CommunityStatsResponse; import com.patbond.patbond.community.support.BookmarkCursor; import com.patbond.patbond.community.support.FeedCursor; import com.patbond.patbond.community.support.PostCursor; @@ -169,6 +170,31 @@ public class PostRepository { .update(); } + /** + * The author's own community aggregates in one indexed pass over + * ix_posts_author_created's leading column (T3.5-06, ADR-022: read-side + * aggregation, no denormalized column). {@code COALESCE} turns the empty + * SUM into 0 so the endpoint never answers null; the + * {@code deleted_at IS NULL} predicate is belt-and-braces — softDelete + * parks published rows as 'archived', so status='published' already + * implies live (ck_posts_publish_state). + */ + public CommunityStatsResponse aggregateByAuthor(UUID authorUserId) { + return jdbcClient.sql(""" + SELECT COALESCE(SUM(like_count), 0) AS received_like_count, + COUNT(*) AS published_post_count + FROM community.posts + WHERE author_user_id = :authorUserId + AND status = 'published' + AND deleted_at IS NULL + """) + .param("authorUserId", authorUserId) + .query((rs, rowNum) -> new CommunityStatsResponse( + rs.getLong("received_like_count"), + rs.getLong("published_post_count"))) + .single(); + } + /** * One page of the author's own posts in (created_at DESC, id DESC) — the * exact key of ix_posts_author_created. Soft-deleted rows never appear; diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java index 8450670..0b83016 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java @@ -5,6 +5,7 @@ import com.patbond.patbond.common.error.ErrorCode; import com.patbond.patbond.community.access.PetVisibilityGateway; import com.patbond.patbond.community.author.AuthorProfileGateway; import com.patbond.patbond.community.dto.AuthorSummaryResponse; +import com.patbond.patbond.community.dto.CommunityStatsResponse; import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.PostMediaAttachRequest; @@ -205,6 +206,19 @@ public class PostService { return new CursorPage<>(assemble(page), nextCursor, hasMore); } + /** + * The caller's own community numbers (T3.5-06): total likes received on + * published, live posts plus the count of those posts. Aggregated on read + * (ADR-022) — the write side keeps no per-user counter, so there is + * nothing that can drift out of sync. Empty data is a legitimate answer of + * zeros, never a 404: every authenticated user has stats, even a brand-new + * one with nothing published. + */ + @Transactional(readOnly = true) + public CommunityStatsResponse communityStats(UUID userId) { + return postRepository.aggregateByAuthor(userId); + } + /** * The shared write gate of PATCH/DELETE: locks the live row, then walks * the 403/404 boundary — invisible (absent, deleted, hidden/archived, diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java new file mode 100644 index 0000000..890b529 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java @@ -0,0 +1,237 @@ +package com.patbond.patbond.community.post; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +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.patch; +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.5-06 获赞聚合:GET /api/v1/me/community-stats。口径(ADR-022,读侧实时 + * 聚合,不引冗余列): + * + *
    + *
  • {@code receivedLikeCount} = 本人「已发布且未软删」帖的 like_count 之和;
  • + *
  • {@code publishedPostCount} = 同一集合的帖子数;
  • + *
  • 草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、他人的帖不计;
  • + *
  • 空数据答 0 而非 null,任何已认证用户都有 stats,从不 404。
  • + *
+ */ +class MeCommunityStatsIntegrationTest extends PostApiTestBase { + + private static final String DRAFT = "{\"content\":\"草稿内容\"}"; + private static final String PUBLISHED = "{\"content\":\"已发布内容\",\"status\":\"published\"}"; + + private JsonNode stats(UUID userId) throws Exception { + return data(mockMvc.perform(authed(get("/api/v1/me/community-stats"), userId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn()); + } + + private void like(UUID actor, String postId) throws Exception { + mockMvc.perform(authed(put("/api/v1/posts/{postId}/like", postId), actor)) + .andExpect(status().isOk()); + } + + // ---- 成功路径 + 空数据 ---------------------------------------------- + + @Test + void freshUserGetsZerosNotNullsAndNever404() throws Exception { + JsonNode stats = stats(newUser()); + assertThat(stats.get("receivedLikeCount").isNull()).isFalse(); + assertThat(stats.get("publishedPostCount").isNull()).isFalse(); + assertThat(stats.get("receivedLikeCount").asLong()).isZero(); + assertThat(stats.get("publishedPostCount").asLong()).isZero(); + } + + @Test + void sumsLikesAcrossThePublishedPostsOfTheCaller() throws Exception { + UUID author = newUser(); + UUID fanA = newUser(); + UUID fanB = newUser(); + String first = createPost(author, PUBLISHED).get("id").asText(); + String second = createPost(author, PUBLISHED).get("id").asText(); + + like(fanA, first); + like(fanB, first); + like(fanA, second); + + JsonNode stats = stats(author); + assertThat(stats.get("receivedLikeCount").asLong()).isEqualTo(3); + assertThat(stats.get("publishedPostCount").asLong()).isEqualTo(2); + } + + @Test + void countsSelfLikesExactlyAsThePerPostNumberDoes() throws Exception { + UUID author = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + like(author, postId); + + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1); + } + + @Test + void unlikingBringsTheNumberBackDown() throws Exception { + UUID author = newUser(); + UUID fan = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + like(fan, postId); + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1); + + mockMvc.perform(authed(delete("/api/v1/posts/{postId}/like", postId), fan)) + .andExpect(status().isOk()); + assertThat(stats(author).get("receivedLikeCount").asLong()).isZero(); + } + + // ---- 口径边界:草稿 / 软删 / 他人 ----------------------------------- + + @Test + void draftsAreExcludedFromBothNumbers() throws Exception { + UUID author = newUser(); + createPost(author, DRAFT); + createPost(author, DRAFT); + + JsonNode before = stats(author); + assertThat(before.get("publishedPostCount").asLong()).isZero(); + assertThat(before.get("receivedLikeCount").asLong()).isZero(); + + // 发布其中一篇后才计入 + String draftId = createPost(author, DRAFT).get("id").asText(); + mockMvc.perform(authed(patch("/api/v1/posts/{postId}", draftId), author) + .content("{\"version\":0,\"status\":\"published\"}")) + .andExpect(status().isOk()); + assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1); + } + + @Test + void softDeletedPostsDropOutOfBothNumbers() throws Exception { + UUID author = newUser(); + UUID fan = newUser(); + String keep = createPost(author, PUBLISHED).get("id").asText(); + String doomed = createPost(author, PUBLISHED).get("id").asText(); + like(fan, keep); + like(fan, doomed); + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(2); + + mockMvc.perform(authed(delete("/api/v1/posts/{postId}", doomed), author)) + .andExpect(status().isOk()); + + JsonNode after = stats(author); + assertThat(after.get("receivedLikeCount").asLong()).isEqualTo(1); + assertThat(after.get("publishedPostCount").asLong()).isEqualTo(1); + } + + @Test + void otherPeoplesPostsNeverLeakIntoMyStats() throws Exception { + UUID me = newUser(); + UUID other = newUser(); + UUID fan = newUser(); + String theirs = createPost(other, PUBLISHED).get("id").asText(); + like(fan, theirs); + like(me, theirs); + + JsonNode mine = stats(me); + assertThat(mine.get("receivedLikeCount").asLong()).isZero(); + assertThat(mine.get("publishedPostCount").asLong()).isZero(); + // 对方的数字是对方的 + assertThat(stats(other).get("receivedLikeCount").asLong()).isEqualTo(2); + } + + /** + * hidden/archived 是运营态(D3-7),在 M3 契约里对所有人不可见,因此也不计 + * 入作品数——「看不到的帖不该出现在我的作品计数里」。 + */ + @Test + void operationalStatesAreExcluded() throws Exception { + UUID author = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1); + + jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id") + .param("id", UUID.fromString(postId)) + .update(); + assertThat(stats(author).get("publishedPostCount").asLong()).isZero(); + } + + // ---- 无权限 / 不存在 ------------------------------------------------- + + @Test + void requiresAValidAccessToken() throws Exception { + mockMvc.perform(get("/api/v1/me/community-stats")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + mockMvc.perform(get("/api/v1/me/community-stats") + .header("Authorization", "Bearer not.a.jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + /** + * 主体永远是 token 里的自己,路径上没有可枚举的 userId —— 「查不到别人的 + * 获赞」不靠权限判断,而靠端点形态本身就没有别人的入口。 + */ + @Test + void hasNoPathParameterToProbeSomeoneElse() throws Exception { + UUID me = newUser(); + mockMvc.perform(authed(get("/api/v1/me/community-stats/{userId}", newUser()), me)) + .andExpect(status().isNotFound()); + } + + // ---- 并发与重放 ----------------------------------------------------- + + /** + * 读侧聚合天然幂等:并发重复读必须给出同一答案,且不产生任何副作用 + * (连续两次读的数字完全相同)。 + */ + @Test + void concurrentAndRepeatedReadsAreIdenticalAndSideEffectFree() throws Exception { + UUID author = newUser(); + UUID fan = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + like(fan, postId); + + CyclicBarrier startTogether = new CyclicBarrier(2); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Callable read = () -> { + startTogether.await(); + return stats(author).get("receivedLikeCount").asLong(); + }; + Future first = pool.submit(read); + Future second = pool.submit(read); + assertThat(first.get()).isEqualTo(1); + assertThat(second.get()).isEqualTo(1); + } finally { + pool.shutdownNow(); + } + + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1); + assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1); + } + + // ---- 形态回归 ------------------------------------------------------- + + @Test + void payloadCarriesExactlyTheTwoNumbers() throws Exception { + UUID author = newUser(); + mockMvc.perform(authed(get("/api/v1/me/community-stats"), author)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.receivedLikeCount").value(0)) + .andExpect(jsonPath("$.data.publishedPostCount").value(0)) + .andExpect(jsonPath("$.data.followerCount").doesNotExist()) + .andExpect(jsonPath("$.data.userId").doesNotExist()); + } +}