feat: 获赞聚合——新增 GET /api/v1/me/community-stats 读侧实时聚合(T3.5-06,ADR-022)
CI / backend-test (push) Failing after 1s
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:
+14
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -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) {
|
||||||
|
}
|
||||||
+26
@@ -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;
|
||||||
|
|||||||
+14
@@ -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,
|
||||||
|
|||||||
+237
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user