feat: 获赞聚合——新增 GET /api/v1/me/community-stats 读侧实时聚合(T3.5-06,ADR-022)
CI / backend-test (push) Failing after 1s

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 10:03:07 +08:00
parent 15c2e66519
commit d98a400f47
5 changed files with 313 additions and 0 deletions
@@ -1,6 +1,7 @@
package com.patbond.patbond.community.controller; package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse; 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.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostResponse; import com.patbond.patbond.community.dto.PostResponse;
@@ -86,4 +87,17 @@ public class PostController {
@RequestParam(required = false) String cursor) { @RequestParam(required = false) String cursor) {
return ApiResponse.success(postService.listMine(userId, status, limit, 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<CommunityStatsResponse> communityStats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(postService.communityStats(userId));
}
} }
@@ -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.
*
* <p>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.</p>
*
* <p>{@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.</p>
*/
public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) {
}
@@ -1,5 +1,6 @@
package com.patbond.patbond.community.repository; 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.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;
@@ -169,6 +170,31 @@ public class PostRepository {
.update(); .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 * 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; * exact key of ix_posts_author_created. Soft-deleted rows never appear;
@@ -5,6 +5,7 @@ import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.PetVisibilityGateway; import com.patbond.patbond.community.access.PetVisibilityGateway;
import com.patbond.patbond.community.author.AuthorProfileGateway; import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse; 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.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest; import com.patbond.patbond.community.dto.PostMediaAttachRequest;
@@ -205,6 +206,19 @@ public class PostService {
return new CursorPage<>(assemble(page), nextCursor, hasMore); 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 shared write gate of PATCH/DELETE: locks the live row, then walks
* the 403/404 boundary — invisible (absent, deleted, hidden/archived, * the 403/404 boundary — invisible (absent, deleted, hidden/archived,
@@ -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,读侧实时
* 聚合,不引冗余列):
*
* <ul>
* <li>{@code receivedLikeCount} = 本人「已发布且未软删」帖的 like_count 之和;</li>
* <li>{@code publishedPostCount} = 同一集合的帖子数;</li>
* <li>草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、他人的帖不计;</li>
* <li>空数据答 0 而非 null,任何已认证用户都有 stats,从不 404。</li>
* </ul>
*/
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<Long> read = () -> {
startTogether.await();
return stats(author).get("receivedLikeCount").asLong();
};
Future<Long> first = pool.submit(read);
Future<Long> 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());
}
}