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;
|
||||
|
||||
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<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;
|
||||
|
||||
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;
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+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