From 9f2b9f9feb8b041b0bfa8daecfb6f81e5423db3c Mon Sep 17 00:00:00 2001
From: zizi
Date: Thu, 18 Jun 2026 03:56:57 +0000
Subject: [PATCH] =?UTF-8?q?feat(feed):=20U1=20R-FEED=20feed=20=E6=B8=B8?=
=?UTF-8?q?=E6=A0=87=E5=88=86=E9=A1=B5=E7=9C=9F=E5=8C=96((sort=5Fscore,id)?=
=?UTF-8?q?=E5=A4=8D=E5=90=88=E6=B8=B8=E6=A0=87+=E4=BA=8C=E6=AC=A1?=
=?UTF-8?q?=E8=BF=87=E6=BB=A4=E7=BB=AD=E5=8F=96)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
codex 评审 MERGE-WITH-FIXES→修 P0(MAX_FETCH_ROUNDS 高过滤率提前到底丢数据:dbExhausted 区分库尾vs轮次中断,hasMore=overflow||!dbExhausted)。plan 2026-06-18-002 U1。
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../feed/dal/mysql/rank/FeedRankMapper.java | 40 ++-
.../feed/service/feed/FeedServiceImpl.java | 175 ++++++++--
.../service/feed/FeedServiceImplTest.java | 317 +++++++++++++++++-
3 files changed, 487 insertions(+), 45 deletions(-)
diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java
index 793b47cb..d74e4d3c 100644
--- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java
+++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/dal/mysql/rank/FeedRankMapper.java
@@ -7,6 +7,7 @@ import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
+import java.math.BigDecimal;
import java.util.List;
/**
@@ -18,20 +19,39 @@ import java.util.List;
public interface FeedRankMapper extends BaseMapperX {
/**
- * 按分区取可出流游戏,按 sort_score 降序(游戏流主查询,命中 idx_zone_status_sort)
+ * 按分区取可出流游戏,按 (sort_score, id) 降序复合排序(游戏流主查询,命中 idx_zone_status_sort)
*
- * cursor 翻页骨架:以 sortScore 降序为序,cursor 解析后续在 service 用 ltIfPresent 续接(MVP 先全量降序取 limit)。
+ * 复合游标(keyset 分页)防丢/重:sort_score 是 DECIMAL 可重复,单以 sort_score 续接会在相等值处丢行或重复,
+ * 故必须以 (sort_score, id) 复合键续接。降序续接谓词(取严格小于游标的下一段):
+ *
sort_score < cursorSortScore OR (sort_score = cursorSortScore AND id < cursorId)
+ * 同时排序键也须为 (sort_score DESC, id DESC) 才能与游标谓词同序、保证翻页确定性(无 id 兜底则相等 sort_score 行序不定 → 丢/重)。
*
- * @param zoneId 分区维度:0默认混合流 / >0 指定专区
- * @param limit 单页条数
- * @return 排序记录列表(已按 sortScore 降序)
+ * 游标缺省(首页):cursorSortScore/cursorId 任一为 null 则不追加游标谓词,等价首页全量降序取 limit。
+ * 两值由 service 从 cursor 串解码,要么同时有值要么同时为 null(service 保证),故此处任一为 null 即视为首页。
+ *
+ * @param zoneId 分区维度:0默认混合流 / >0 指定专区
+ * @param cursorSortScore 游标排序分(上页末条 sort_score;首页传 null)
+ * @param cursorId 游标记录 ID(上页末条 id,作 sort_score 相等时的次级游标;首页传 null)
+ * @param limit 单页条数
+ * @return 排序记录列表(已按 (sort_score, id) 降序,长度 ≤ limit)
*/
- default List selectStreamByZone(Long zoneId, Integer limit) {
- return selectList(new LambdaQueryWrapperX()
+ default List selectStreamByZone(Long zoneId, BigDecimal cursorSortScore, Long cursorId, Integer limit) {
+ // 游标是否生效:service 保证两值同有同无;任一为 null 即首页(不追加游标谓词)
+ boolean hasCursor = cursorSortScore != null && cursorId != null;
+ LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX()
.eq(FeedRankDO::getZoneId, zoneId) // 分区维度过滤
- .eq(FeedRankDO::getStatus, 1) // 仅可出流
- .orderByDesc(FeedRankDO::getSortScore) // 按最终排序分降序
- .last("LIMIT " + limit)); // 取单页(cursor 续接逻辑在 service)
+ .eq(FeedRankDO::getStatus, 1); // 仅可出流
+ if (hasCursor) {
+ // 复合游标续接:sort_score < 游标分 OR (sort_score = 游标分 AND id < 游标id),括号整体 AND 进前置条件,防止 OR 逃逸过滤条件
+ wrapper.and(w -> w
+ .lt(FeedRankDO::getSortScore, cursorSortScore)
+ .or(o -> o.eq(FeedRankDO::getSortScore, cursorSortScore).lt(FeedRankDO::getId, cursorId)));
+ }
+ // 排序键与游标谓词同序:sort_score DESC, id DESC(id 兜底保证相等 sort_score 行的确定序)
+ wrapper.orderByDesc(FeedRankDO::getSortScore)
+ .orderByDesc(FeedRankDO::getId)
+ .last("LIMIT " + limit); // 取单批(二次过滤后不足 size 的续取在 service 循环推进游标)
+ return selectList(wrapper);
}
/**
diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java
index b6e6e228..a83b6224 100644
--- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java
+++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java
@@ -32,7 +32,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -94,43 +96,108 @@ public class FeedServiceImpl implements FeedService {
@Override
public FeedStreamRespVO getFeedStream(FeedStreamReqVO reqVO, Long userId) {
- // 默认混合流:zone_id=0,按 sort_score 降序取单页
- return buildStream(DEFAULT_ZONE_ID, reqVO.getSize(), userId);
+ // 默认混合流:zone_id=0,按 (sort_score, id) 复合游标降序翻页
+ return buildStream(DEFAULT_ZONE_ID, reqVO.getCursor(), reqVO.getSize(), userId);
}
@Override
public FeedStreamRespVO getZoneStream(ZoneStreamReqVO reqVO, Long userId) {
// TODO 跨模块对接 project:校验 zoneId 对应 game_zone 存在且 status=1 启用;非法应抛 FEED_ZONE_NOT_EXISTS。
// MVP 同库可直查 game_zone,或待 project -api RPC 就绪后改 Feign。骨架阶段先直接出流。
- return buildStream(reqVO.getZoneId(), reqVO.getSize(), userId);
+ return buildStream(reqVO.getZoneId(), reqVO.getCursor(), reqVO.getSize(), userId);
}
/**
- * 构造游戏流分页结果(命中 game_feed_rank 覆盖层则按 sort_score 降序出流)
+ * 单批取数上限的放大倍率 + 续取轮次上限(二次过滤后不足 size 的续取防护,§U1 隐藏坑)
*
- * cursor 骨架:MVP 先按 sort_score 降序取 size 条;nextCursor/hasMore 留待补全
- * (后续以末条 sortScore 编码 cursor,翻页用 ltIfPresent(sortScore) 续接,对齐契约 T-FED-06)。
- *
- * @param zoneId 分区维度(0=混合流 / >0=指定专区)
- * @param size 单页条数
- * @param userId 当前用户 ID(登录态回填互动状态,匿名恒 false)
- * @return 游戏流分页结果
+ * 二次过滤(filterByProjectPublished)会丢行 → 按 size 取 size 条再过滤可能不足 size,但库里仍有更多 → 须循环推进游标续取。
+ * 每轮以 (size+1) 取批(多取 1 条用于"是否还有下一页"探测),最多 MAX_FETCH_ROUNDS 轮兜底,避免过滤率极高时无界拉库。
*/
- private FeedStreamRespVO buildStream(Long zoneId, Integer size, Long userId) {
- List ranks = feedRankMapper.selectStreamByZone(zoneId, size);
- // 读侧可见态 enforce(§3.1 C1 / Codex H9):rank.status=1 只是覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致,
- // 故对 rank 结果批查 project.status 二次过滤——仅 project.status==PUBLISHED(4) 的游戏出流,未发布/降级(下架/封禁/审核中)不出流。
- List visibleRanks = filterByProjectPublished(ranks);
+ private static final int FETCH_PROBE = 1; // 每轮多取 1 条,探测"过滤后是否仍有下一页"
+ private static final int MAX_FETCH_ROUNDS = 5; // 续取轮次上限(过滤率极高时的兜底,防无界拉库)
+
+ /**
+ * 构造游戏流分页结果(命中 game_feed_rank 覆盖层,按 (sort_score, id) 复合游标降序翻页)
+ *
+ * 真游标分页(替"永远第一页"桩):
+ *
+ * - 解码入参 cursor 串 → 起始游标键 (sortScore,id)(首页为 null);
+ * - 循环按复合游标取批 + 读侧可见态二次过滤累积可见行,直到攒满 size+1 条可见 或 库取空;
+ * - 取满 size+1 → 截到 size,hasMore=true,nextCursor=第 size 条可见行的 (sortScore,id) 编码;
+ * 未满 → hasMore=false,nextCursor="" 到底(对齐契约 T-FED-06:空串=到底)。
+ *
+ * 二次过滤续取(隐藏坑):游标每轮按"上批最后一条原始库行"(非可见行)推进——被过滤掉的行也须跨过,否则下轮会重复取到它们;
+ * 多取 1 条(FETCH_PROBE)做 hasMore 探测,确保"过滤后正好够 size 但库里其实还有更多"时 hasMore 仍为真、且 nextCursor 接得上不丢不重。
+ *
+ * @param zoneId 分区维度(0=混合流 / >0=指定专区)
+ * @param rawCursor 入参 cursor 串(首屏不传/空串=首页;翻页透传上页 nextCursor)
+ * @param size 单页条数
+ * @param userId 当前用户 ID(登录态回填互动状态,匿名恒 false)
+ * @return 游戏流分页结果(含 nextCursor/hasMore)
+ */
+ private FeedStreamRespVO buildStream(Long zoneId, String rawCursor, Integer size, Long userId) {
+ // 1) 解码游标:合法 cursor → 起始键 (sortScore,id);空串/非法 cursor 一律按首页(null)容错,不抛错(cursor 对端不透明)
+ CursorKey cursor = decodeCursor(rawCursor);
+ // 累积可见行:目标攒满 size+1 条(多取 1 条用于探测 hasMore),攒够或库取空即止
+ int target = size + FETCH_PROBE;
+ List visibleAcc = new ArrayList<>(target);
+ int rounds = 0;
+ // 循环退出原因(P0 修:区分"库已取空"vs"轮次上限中断"):
+ // dbExhausted=true 仅在确认库尾(本批空 / 本批原始行数 < target)时置位;
+ // 若因轮次上限(MAX_FETCH_ROUNDS)退出而 dbExhausted 仍为 false,则库里可能还有可见游戏 →
+ // 绝不能因内部循环次数到顶就谎报到底,必须返回可继续的游标(基于本批末条原始行)让前端续取。
+ boolean dbExhausted = false;
+ while (visibleAcc.size() < target && rounds < MAX_FETCH_ROUNDS) {
+ rounds++;
+ BigDecimal cursorSort = cursor == null ? null : cursor.sortScore;
+ Long cursorId = cursor == null ? null : cursor.id;
+ // 单批取数:按 target 取(而非 size),减少过滤丢行时的续取轮次
+ List batch = feedRankMapper.selectStreamByZone(zoneId, cursorSort, cursorId, target);
+ if (batch.isEmpty()) {
+ dbExhausted = true; // 本批为空 → 确认库已取空,无更多
+ break;
+ }
+ // 读侧可见态 enforce(§3.1 C1 / Codex H9):rank.status=1 仅覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致,
+ // 故批查 project.status 二次过滤——仅 PUBLISHED(4) 出流;未发布/降级(下架/封禁/审核中)剔除。
+ visibleAcc.addAll(filterByProjectPublished(batch));
+ // 游标按"本批最后一条原始库行"推进(含被过滤行)——被过滤行也须跨过,否则下轮重复取到 → 续取不重不漏的关键
+ FeedRankDO lastRaw = batch.get(batch.size() - 1);
+ cursor = new CursorKey(lastRaw.getSortScore(), lastRaw.getId());
+ // 本批原始行数 < target,说明该批已到库尾,无须再取(即便可见行仍不足 size)
+ if (batch.size() < target) {
+ dbExhausted = true; // 本批未取满 → 确认库已到尾
+ break;
+ }
+ }
+ // 2) 判定 hasMore + 截断到 size:
+ // a) 可见行 > size → 攒到了探测位,确有下一页,截到 size;
+ // b) 否则若 !dbExhausted(因轮次上限中断而退出,库未确认取空)→ 库里可能还有可见游戏,仍判 hasMore=true(防提前到底丢数据);
+ // c) 仅当 dbExhausted(确认库尾)且可见行 ≤ size 时才真到底。
+ boolean overflow = visibleAcc.size() > size;
+ boolean hasMore = overflow || !dbExhausted;
+ List pageRanks = overflow ? visibleAcc.subList(0, size) : visibleAcc;
+
FeedStreamRespVO resp = new FeedStreamRespVO();
// 出流卡片:先填本模块自有字段(gameId/versionId/zone/quality),再跨模块回填 project 本体元信息(title/cover/计数)
- List cards = FeedConvert.toCardList(visibleRanks);
+ List cards = FeedConvert.toCardList(pageRanks);
backfillCardMeta(cards);
resp.setList(cards);
// TODO 登录态用户互动 liked/favorited 读 game_feed_interact_log 回填(当前默认 false)。
- // TODO cursor 续接:以末条 sortScore 编码 nextCursor;不足 size 条则 hasMore=false。骨架先返回空串占位。
- resp.setNextCursor("");
- // hasMore 以二次过滤后的真实出流条数判定(避免被过滤掉的行误判还有更多)
- resp.setHasMore(visibleRanks.size() >= size);
+ // 3) nextCursor(须接得上不丢不重,与 hasMore 口径一致):
+ // a) 可见行溢出(攒到探测位)→ 编码本页末条可见行 (sortScore,id),下页从此严格小于处续接;
+ // b) 轮次上限中断但库未取空 → 本页可见行已全数返回,须以"本批末条原始库行"游标续接(跨过已消费/已过滤的行),
+ // 否则用末条可见行游标会漏掉"末条可见行与末条原始行之间被过滤的行之后"的数据;cursor==null(首批即空,理论不达此分支)时退化为到底空串;
+ // c) 真到底 → 空串(契约 T-FED-06:空串=到底)。
+ if (overflow && !pageRanks.isEmpty()) {
+ FeedRankDO lastVisible = pageRanks.get(pageRanks.size() - 1);
+ resp.setNextCursor(encodeCursor(lastVisible.getSortScore(), lastVisible.getId()));
+ } else if (hasMore && cursor != null) {
+ // 轮次上限中断:以本批末条原始库行续接(cursor 即循环中推进到的"末条原始行"位置)
+ resp.setNextCursor(encodeCursor(cursor.sortScore, cursor.id));
+ } else {
+ resp.setNextCursor("");
+ }
+ resp.setHasMore(hasMore);
return resp;
}
@@ -486,6 +553,72 @@ public class FeedServiceImpl implements FeedService {
return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value);
}
+ // ============================== cursor 编解码(复合游标 (sortScore,id),契约 T-FED-06)==============================
+
+ /** cursor 内部分隔符(sortScore 与 id 之间;BigDecimal/Long 文本均不含 '|',无歧义) */
+ private static final String CURSOR_SEP = "|";
+
+ /**
+ * 复合游标键 (sortScore, id) 内部载体(cursor 编解码的中间表示,不外泄)
+ *
+ * 不可变小值对象:sortScore=上页末条排序分(DECIMAL),id=上页末条记录 ID(DECIMAL 相等时的次级游标,防丢/重)。
+ */
+ private static final class CursorKey {
+ private final BigDecimal sortScore;
+ private final Long id;
+
+ private CursorKey(BigDecimal sortScore, Long id) {
+ this.sortScore = sortScore;
+ this.id = id;
+ }
+ }
+
+ /**
+ * 编码 cursor:(sortScore, id) → URL-safe Base64 串(对端不透明,仅本端解码续接)
+ *
+ * 明文形如 "{sortScore}|{id}"(sortScore 用 toPlainString 避免科学计数法失真),Base64 包一层避免前端误把内部结构当业务字段用。
+ *
+ * @param sortScore 末条排序分
+ * @param id 末条记录 ID
+ * @return cursor 串
+ */
+ private static String encodeCursor(BigDecimal sortScore, Long id) {
+ // sortScore 用 toPlainString(非 toString)防 DECIMAL 大/小值被科学计数法表示,保证解码 new BigDecimal 往返一致
+ String plain = (sortScore == null ? BigDecimal.ZERO : sortScore).toPlainString() + CURSOR_SEP + id;
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(plain.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * 解码 cursor:Base64 串 → 复合游标键 (sortScore, id);空串/非法一律按首页(返回 null)容错
+ *
+ * cursor 对端不透明,非法值不应抛错打断出流(前端可能透传过期/篡改 cursor)——解码失败一律降级为首页全量,记 warn 便于排查。
+ * 两值要么同时解析成功要么整体视为首页(避免半个游标导致 mapper 条件不成对)。
+ *
+ * @param rawCursor 入参 cursor 串(可空/空串=首页)
+ * @return 复合游标键;首页或非法返回 null
+ */
+ private static CursorKey decodeCursor(String rawCursor) {
+ if (!StringUtils.hasText(rawCursor)) {
+ return null; // 首屏不传 cursor → 首页
+ }
+ try {
+ String plain = new String(Base64.getUrlDecoder().decode(rawCursor), StandardCharsets.UTF_8);
+ int sep = plain.indexOf(CURSOR_SEP);
+ if (sep <= 0 || sep >= plain.length() - 1) {
+ // 缺分隔符 / 分隔符在首尾(无 sortScore 或无 id)→ 非法,降级首页
+ log.warn("[buildStream] cursor 结构非法(缺分隔符或字段),降级首页:cursor={}", rawCursor);
+ return null;
+ }
+ BigDecimal sortScore = new BigDecimal(plain.substring(0, sep));
+ Long id = Long.valueOf(plain.substring(sep + CURSOR_SEP.length()));
+ return new CursorKey(sortScore, id);
+ } catch (IllegalArgumentException e) {
+ // Base64 解码失败 / BigDecimal/Long 解析失败 → 过期或篡改 cursor,降级首页不打断
+ log.warn("[buildStream] cursor 解码失败,降级首页:cursor={}, err={}", rawCursor, e.getMessage());
+ return null;
+ }
+ }
+
// ============================== 私有校验/辅助 ==============================
/**
diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java
index acc9dde4..a485a7ef 100644
--- a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java
+++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImplTest.java
@@ -24,13 +24,19 @@ import org.mockito.Mock;
import org.mockito.Spy;
import java.math.BigDecimal;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
import static com.wanxiang.huijing.game.module.feed.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*;
/**
@@ -60,13 +66,16 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
@Test
void testGetFeedStream_descAndHasMore() {
- // 排序覆盖层返回 2 条(size=2 → hasMore=true)
- when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList(
- rank(1L, 10L, 0L, new BigDecimal("90")),
- rank(2L, 11L, 0L, new BigDecimal("80"))));
- // §3.1 C1 读侧可见态二次过滤:两游戏均 project.status=PUBLISHED(4) 才出流(不 stub 则 getStatus 返回 null 被剔除)
+ // size=2 → 内部目标取 size+1=3 条(多取 1 条探测 hasMore);首页游标 (null,null)
+ // 返回 3 条全已发布 → 可见行 3 > size 2 → hasMore=true,截到 2 条,nextCursor 编码第 2 条 (sortScore=80,id=2)
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Arrays.asList(
+ rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90")),
+ rankWithId(2L, 2L, 11L, 0L, new BigDecimal("80")),
+ rankWithId(3L, 3L, 12L, 0L, new BigDecimal("70"))));
+ // §3.1 C1 读侧可见态二次过滤:三游戏均 project.status=PUBLISHED(4) 才出流(不 stub 则 getStatus 返回 null 被剔除)
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(4));
+ when(projectApi.getStatus(3L)).thenReturn(CommonResult.success(4));
// 出流后跨模块回填本体元信息:game1 回填真元信息(断言生效)、game2 无元信息(留空不阻断)
ProjectFeedMetaDTO meta1 = new ProjectFeedMetaDTO();
meta1.setGameId(1L);
@@ -78,19 +87,23 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
- assertEquals(2, resp.getList().size());
+ assertEquals(2, resp.getList().size()); // 截到 size,探测的第 3 条不出页
assertEquals(1L, resp.getList().get(0).getGameId());
- assertEquals("甲游戏", resp.getList().get(0).getTitle()); // 本体元信息已回填
- assertTrue(resp.getHasMore()); // 取满 size → 还有下一页
- assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串
+ assertEquals("甲游戏", resp.getList().get(0).getTitle()); // 本体元信息已回填
+ assertTrue(resp.getHasMore()); // 可见行 > size → 还有下一页
+ // nextCursor=本页末条 (sortScore=80,id=2) 的复合游标编码(非空、可解码回原值)
+ assertFalse(resp.getNextCursor().isEmpty());
+ assertEquals(new BigDecimal("80"), decodeCursorSort(resp.getNextCursor()));
+ assertEquals(2L, decodeCursorId(resp.getNextCursor()));
}
@Test
void testGetFeedStream_filterOutNonPublished() {
// §3.1 C1 读侧 enforce:rank 在流(status=1)但 project 非 PUBLISHED 的行必须被剔除(手工写错/降级不出流)
- when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList(
- rank(1L, 10L, 0L, new BigDecimal("90")), // project 已发布 → 出流
- rank(2L, 11L, 0L, new BigDecimal("80")))); // project 已下架(5) → 剔除
+ // 首页取 size+1=3 条,仅 1 条已发布 → 可见行 1 ≤ size 2 → hasMore=false(过滤后不足且库已到尾)
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Arrays.asList(
+ rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90")), // project 已发布 → 出流
+ rankWithId(2L, 2L, 11L, 0L, new BigDecimal("80")))); // project 已下架(5) → 剔除(仅 2 条 < target 3 → 库到尾)
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(5));
// game1 出流后回填本体元信息(game2 已被可见态过滤,不参与回填)
@@ -102,7 +115,215 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
assertEquals(1, resp.getList().size()); // 仅保留已发布游戏
assertEquals(1L, resp.getList().get(0).getGameId());
- assertFalse(resp.getHasMore()); // 过滤后不足 size → 无下一页
+ assertFalse(resp.getHasMore()); // 过滤后不足 size + 库到尾 → 无下一页
+ assertEquals("", resp.getNextCursor()); // 到底 → 空串(契约 T-FED-06)
+ }
+
+ @Test
+ void testGetFeedStream_emptyCursorIsFirstPage() {
+ // 空 cursor = 首页:mapper 收到游标 (null,null),limit=size+1(探测位)
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Collections.emptyList());
+ FeedStreamReqVO reqVO = new FeedStreamReqVO();
+ reqVO.setSize(2); // cursor 不设(首屏不传)
+
+ FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
+
+ assertTrue(resp.getList().isEmpty());
+ assertFalse(resp.getHasMore());
+ assertEquals("", resp.getNextCursor());
+ // 验证确以"首页游标(null,null)"调库(而非任何非空游标)
+ verify(feedRankMapper).selectStreamByZone(eq(0L), isNull(), isNull(), eq(3));
+ }
+
+ @Test
+ void testGetFeedStream_blankCursorStringTreatedAsFirstPage() {
+ // 空白串 cursor(前端可能透传 "")= 首页容错:不解码、不抛错,按 (null,null) 取首页
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Collections.emptyList());
+ FeedStreamReqVO reqVO = new FeedStreamReqVO();
+ reqVO.setSize(2);
+ reqVO.setCursor(" "); // 空白串
+
+ FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
+
+ assertFalse(resp.getHasMore());
+ verify(feedRankMapper).selectStreamByZone(eq(0L), isNull(), isNull(), eq(3));
+ }
+
+ @Test
+ void testGetFeedStream_malformedCursorFallsBackToFirstPage() {
+ // 非法 cursor(非 Base64 / 结构损坏)= 首页容错:解码失败降级首页、不抛错(cursor 对端不透明)
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Collections.emptyList());
+ FeedStreamReqVO reqVO = new FeedStreamReqVO();
+ reqVO.setSize(2);
+ reqVO.setCursor("!!!not-a-valid-cursor!!!"); // 篡改/过期 cursor
+
+ FeedStreamRespVO resp = assertDoesNotThrow(() -> feedService.getFeedStream(reqVO, 99L));
+
+ assertFalse(resp.getHasMore());
+ verify(feedRankMapper).selectStreamByZone(eq(0L), isNull(), isNull(), eq(3)); // 降级首页 (null,null)
+ }
+
+ @Test
+ void testGetFeedStream_secondPageContinuesFromCursor_roundTrip() {
+ // cursor 编解码往返一致(T-FED-06):把首页末条编码的 nextCursor 回传,mapper 须收到对应解码游标续接下一页
+ // 首页:3 条全发布 → hasMore,nextCursor=(sortScore=80,id=2)
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Arrays.asList(
+ rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90")),
+ rankWithId(2L, 2L, 11L, 0L, new BigDecimal("80")),
+ rankWithId(3L, 3L, 12L, 0L, new BigDecimal("70"))));
+ stubPublished(1L, 2L, 3L);
+ when(projectApi.getFeedMeta(any())).thenReturn(CommonResult.success(null));
+ FeedStreamReqVO p1 = new FeedStreamReqVO();
+ p1.setSize(2);
+ FeedStreamRespVO r1 = feedService.getFeedStream(p1, 99L);
+ String nextCursor = r1.getNextCursor();
+ assertFalse(nextCursor.isEmpty());
+
+ // 第二页:回传 nextCursor,断言 mapper 收到的游标正是 (80, 2)(往返一致),返回剩余 1 条到底
+ when(feedRankMapper.selectStreamByZone(eq(0L), eq(new BigDecimal("80")), eq(2L), eq(3)))
+ .thenReturn(Collections.singletonList(rankWithId(3L, 3L, 12L, 0L, new BigDecimal("70"))));
+ FeedStreamReqVO p2 = new FeedStreamReqVO();
+ p2.setSize(2);
+ p2.setCursor(nextCursor);
+
+ FeedStreamRespVO r2 = feedService.getFeedStream(p2, 99L);
+
+ assertEquals(1, r2.getList().size());
+ assertEquals(3L, r2.getList().get(0).getGameId());
+ assertFalse(r2.getHasMore()); // 剩余不足 size + 库到尾 → 到底
+ assertEquals("", r2.getNextCursor());
+ verify(feedRankMapper).selectStreamByZone(eq(0L), eq(new BigDecimal("80")), eq(2L), eq(3)); // 游标往返一致
+ }
+
+ @Test
+ void testGetFeedStream_filteredOutThenContinueToFillSize() {
+ // 二次过滤后续取补足(隐藏坑):首批含被过滤行致可见不足 size,须按"末条原始库行"推进游标续取补满
+ // 数据集(sort 降序):g1(95) 已发布 / g2(90) 下架 / g3(85) 已发布 / g4(80) 已发布 …
+ // size=2 → target=3。第 1 批取 [g1,g2,g3]:g2 被过滤 → 可见 [g1,g3]=2,但 batch.size()==target=3 不算到尾 → 继续
+ // 第 2 批从游标 (85,3) 续取 [g4]:可见 [g1,g3,g4]=3 > size → hasMore,截到 [g1,g3]
+ when(feedRankMapper.selectStreamByZone(eq(0L), isNull(), isNull(), eq(3))).thenReturn(Arrays.asList(
+ rankWithId(1L, 1L, 10L, 0L, new BigDecimal("95")),
+ rankWithId(2L, 2L, 11L, 0L, new BigDecimal("90")),
+ rankWithId(3L, 3L, 12L, 0L, new BigDecimal("85"))));
+ when(feedRankMapper.selectStreamByZone(eq(0L), eq(new BigDecimal("85")), eq(3L), eq(3)))
+ .thenReturn(Collections.singletonList(rankWithId(4L, 4L, 13L, 0L, new BigDecimal("80"))));
+ when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
+ when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(5)); // 下架 → 过滤
+ when(projectApi.getStatus(3L)).thenReturn(CommonResult.success(4));
+ when(projectApi.getStatus(4L)).thenReturn(CommonResult.success(4));
+ when(projectApi.getFeedMeta(any())).thenReturn(CommonResult.success(null));
+ FeedStreamReqVO reqVO = new FeedStreamReqVO();
+ reqVO.setSize(2);
+
+ FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
+
+ // 过滤补足后正好 size,且续取确发生(第二轮按 (85,3) 推进,跨过了被过滤的 g2)
+ assertEquals(2, resp.getList().size());
+ assertEquals(1L, resp.getList().get(0).getGameId());
+ assertEquals(3L, resp.getList().get(1).getGameId()); // g2 被过滤,g3 补上
+ assertTrue(resp.getHasMore()); // g4 探测到 → 还有下一页
+ assertEquals(3L, decodeCursorId(resp.getNextCursor())); // nextCursor 指向本页末条 g3(id=3)
+ verify(feedRankMapper).selectStreamByZone(eq(0L), eq(new BigDecimal("85")), eq(3L), eq(3)); // 确发生续取
+ }
+
+ @Test
+ void testGetFeedStream_fullWalkToEnd_noDupNoLoss_withDuplicateSortScores() {
+ // 多页翻到底不丢不重 + sort_score 重复值续接:用"假库"按 keyset 语义切片,逐页翻到底,校验全集无重无漏
+ // 数据集 6 条全发布,含重复 sort_score:80 出现 3 次(g2/g3/g4),靠 id 次级游标区分(防丢/重的核心场景)
+ List dataset = Arrays.asList(
+ rankWithId(1L, 1L, 10L, 0L, new BigDecimal("90")),
+ rankWithId(2L, 2L, 11L, 0L, new BigDecimal("80")),
+ rankWithId(3L, 3L, 12L, 0L, new BigDecimal("80")), // 与 g2 同分 → 靠 id 区分
+ rankWithId(4L, 4L, 13L, 0L, new BigDecimal("80")), // 与 g2/g3 同分
+ rankWithId(5L, 5L, 14L, 0L, new BigDecimal("70")),
+ rankWithId(6L, 6L, 15L, 0L, new BigDecimal("60")));
+ installFakeKeysetDb(dataset); // 让 selectStreamByZone(任意游标) 按 keyset 语义返回切片
+ stubPublished(1L, 2L, 3L, 4L, 5L, 6L);
+ when(projectApi.getFeedMeta(any())).thenReturn(CommonResult.success(null));
+
+ // 逐页翻(size=2)到底,收集所有 gameId
+ List collected = new ArrayList<>();
+ String cursor = null;
+ int guard = 0;
+ while (guard++ < 20) { // 防御性轮次上限(正常 6 条/页 2 = 3 页 + 1 次到底)
+ FeedStreamReqVO req = new FeedStreamReqVO();
+ req.setSize(2);
+ req.setCursor(cursor);
+ FeedStreamRespVO resp = feedService.getFeedStream(req, 99L);
+ resp.getList().forEach(c -> collected.add(c.getGameId()));
+ if (!resp.getHasMore()) {
+ assertEquals("", resp.getNextCursor()); // 到底必空串
+ break;
+ }
+ cursor = resp.getNextCursor();
+ assertFalse(cursor.isEmpty());
+ }
+
+ // 全集无漏:6 条都翻到
+ assertEquals(6, collected.size(), "翻到底应取全 6 条,实际=" + collected);
+ // 无重:去重后仍 6 条
+ Set unique = new HashSet<>(collected);
+ assertEquals(6, unique.size(), "不应有重复,实际=" + collected);
+ // 顺序正确:sort_score DESC, id DESC(同分 80 的 g2/g3/g4 按 id 降序 = 4,3,2)
+ assertEquals(Arrays.asList(1L, 4L, 3L, 2L, 5L, 6L), collected);
+ }
+
+ @Test
+ void testGetFeedStream_roundCapInterrupted_doesNotPrematurelyReportEnd() {
+ // P1(覆盖 P0 修:轮次上限中断 ≠ 到底):连续多批高过滤率、满批但可见不足 size+1,
+ // 单次取流会在续取轮次上限(MAX_FETCH_ROUNDS=5)处中断,但库里其实仍有可见游戏(在被过滤行之后)。
+ // 断言:① 中断时不提前到底(hasMore=true、nextCursor 非空);② 续取最终能取到真库尾的全部可见游戏(不丢数据)。
+ //
+ // 数据集(sort 降序,靠 id 区分):前 15 条全"已下架(5)"被过滤 + 末 3 条"已发布(4)"位于真库尾。
+ // size=2 → target=3。单次取流每轮取 3 条原始行,5 轮恰好消费完前 15 条被过滤行 → 该次 visibleAcc=0、
+ // 但末批仍是满批(库未取空) → 必须 hasMore=true 续接,绝不能因 5 轮到顶谎报到底而漏掉末 3 条已发布游戏。
+ List dataset = new ArrayList<>();
+ // 前 15 条:sort 从 150 递减到 136,id=1..15,全部"已下架(5)"(出流时被二次过滤剔除)
+ for (int i = 1; i <= 15; i++) {
+ dataset.add(rankWithId((long) i, (long) i, 100L + i, 0L, new BigDecimal(151 - i)));
+ }
+ // 末 3 条:sort=135/134/133,id=16/17/18,全部"已发布(4)"(位于真库尾,须被翻到)
+ for (int i = 16; i <= 18; i++) {
+ dataset.add(rankWithId((long) i, (long) i, 100L + i, 0L, new BigDecimal(151 - i)));
+ }
+ installFakeKeysetDb(dataset); // 复用 keyset 假库:按 (sort DESC,id DESC) 严格小于游标切片,limit=target=3
+ // 状态桩:1..15 已下架(5) 被过滤;16..18 已发布(4) 出流
+ for (int i = 1; i <= 15; i++) {
+ when(projectApi.getStatus((long) i)).thenReturn(CommonResult.success(5));
+ }
+ stubPublished(16L, 17L, 18L);
+ when(projectApi.getFeedMeta(any())).thenReturn(CommonResult.success(null));
+
+ // 第一次取流:5 轮全被过滤 → 本次 0 可见,但库未空 → 不可到底
+ FeedStreamReqVO first = new FeedStreamReqVO();
+ first.setSize(2);
+ FeedStreamRespVO firstResp = feedService.getFeedStream(first, 99L);
+ assertTrue(firstResp.getList().isEmpty(), "首次 5 轮全被过滤,本批应 0 可见");
+ assertTrue(firstResp.getHasMore(), "P0 核心:轮次上限中断且库未取空,必须 hasMore=true(不得提前到底)");
+ assertFalse(firstResp.getNextCursor().isEmpty(), "中断续取必须给出非空 nextCursor(基于本批末条原始行)");
+ // 续接游标须落在已消费的第 15 条原始行(sort=136,id=15)处,下页严格小于此续取(不丢不重的关键)
+ assertEquals(15L, decodeCursorId(firstResp.getNextCursor()), "nextCursor 应指向本批末条原始库行 id=15");
+
+ // 继续按 nextCursor 翻到真到底,收集所有可见 gameId
+ List collected = new ArrayList<>();
+ String cursor = firstResp.getNextCursor();
+ int guard = 0;
+ while (guard++ < 20) { // 防御性上限(避免断言失败时死循环)
+ FeedStreamReqVO req = new FeedStreamReqVO();
+ req.setSize(2);
+ req.setCursor(cursor);
+ FeedStreamRespVO resp = feedService.getFeedStream(req, 99L);
+ resp.getList().forEach(c -> collected.add(c.getGameId()));
+ if (!resp.getHasMore()) {
+ assertEquals("", resp.getNextCursor(), "真到底必空串");
+ break;
+ }
+ cursor = resp.getNextCursor();
+ assertFalse(cursor.isEmpty());
+ }
+ // 不丢数据:真库尾的 3 条已发布游戏都被翻到(顺序 sort DESC = 16,17,18)
+ assertEquals(Arrays.asList(16L, 17L, 18L), collected,
+ "续取须取到真库尾全部已发布游戏,不得因轮次上限提前到底而丢数据,实际=" + collected);
}
// ============================== 互动:动作校验 ==============================
@@ -380,7 +601,7 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
// ============================== 测试夹具 ==============================
- /** 构造排序覆盖层记录 */
+ /** 构造排序覆盖层记录(不含 id;非 cursor 场景沿用) */
private static FeedRankDO rank(Long gameId, Long versionId, Long zoneId, BigDecimal quality) {
FeedRankDO rank = new FeedRankDO();
rank.setGameId(gameId);
@@ -392,6 +613,74 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
return rank;
}
+ /** 构造排序覆盖层记录(含 id + 显式 sortScore;cursor 复合游标场景必填 id 作次级游标) */
+ private static FeedRankDO rankWithId(Long id, Long gameId, Long versionId, Long zoneId, BigDecimal sortScore) {
+ FeedRankDO rank = new FeedRankDO();
+ rank.setId(id);
+ rank.setGameId(gameId);
+ rank.setVersionId(versionId);
+ rank.setZoneId(zoneId);
+ rank.setQualityScore(sortScore);
+ rank.setSortScore(sortScore);
+ rank.setStatus(1);
+ return rank;
+ }
+
+ /** 批量桩控这些 gameId 的 project.status=PUBLISHED(4)(出流可见态二次过滤放行) */
+ private void stubPublished(Long... gameIds) {
+ for (Long gid : gameIds) {
+ when(projectApi.getStatus(gid)).thenReturn(CommonResult.success(4));
+ }
+ }
+
+ /**
+ * 安装"假库":让 feedRankMapper.selectStreamByZone(任意游标) 按 mapper 的 (sort_score,id) 降序 keyset 语义返回切片
+ *
+ * 复刻 Mapper 真实谓词:先按 (sortScore DESC, id DESC) 全排序,再取"严格小于游标"的下一段前 limit 条,
+ * 使 service 的多页翻到底走真实续接路径(验证不丢不重 + 同分靠 id 续接)。
+ *
+ * @param dataset 已发布候选全集(顺序不限,内部自行按 keyset 排序)
+ */
+ private void installFakeKeysetDb(List dataset) {
+ // 预排序:sort_score DESC, id DESC(与 Mapper orderByDesc(sortScore).orderByDesc(id) 同序)
+ List sorted = new ArrayList<>(dataset);
+ sorted.sort((a, b) -> {
+ int c = b.getSortScore().compareTo(a.getSortScore()); // sortScore 降序
+ return c != 0 ? c : Long.compare(b.getId(), a.getId()); // 同分 id 降序
+ });
+ when(feedRankMapper.selectStreamByZone(eq(0L), any(), any(), eq(3))).thenAnswer(inv -> {
+ BigDecimal cursorSort = inv.getArgument(1); // 可能为 null(首页)
+ Long cursorId = inv.getArgument(2); // 可能为 null(首页)
+ int limit = inv.getArgument(3);
+ List page = new ArrayList<>(limit);
+ for (FeedRankDO r : sorted) {
+ // 游标谓词:首页(游标 null)全收;否则取 (sortScore,id) 严格小于游标的下一段
+ boolean afterCursor = (cursorSort == null || cursorId == null)
+ || r.getSortScore().compareTo(cursorSort) < 0
+ || (r.getSortScore().compareTo(cursorSort) == 0 && r.getId() < cursorId);
+ if (afterCursor) {
+ page.add(r);
+ if (page.size() == limit) {
+ break; // 命中 LIMIT
+ }
+ }
+ }
+ return page;
+ });
+ }
+
+ /** 解码 cursor 串 → sortScore(须与 FeedServiceImpl 编码口径一致:URL-Base64 over "sortScore|id") */
+ private static BigDecimal decodeCursorSort(String cursor) {
+ String plain = new String(java.util.Base64.getUrlDecoder().decode(cursor), java.nio.charset.StandardCharsets.UTF_8);
+ return new BigDecimal(plain.substring(0, plain.indexOf('|')));
+ }
+
+ /** 解码 cursor 串 → id(须与 FeedServiceImpl 编码口径一致) */
+ private static Long decodeCursorId(String cursor) {
+ String plain = new String(java.util.Base64.getUrlDecoder().decode(cursor), java.nio.charset.StandardCharsets.UTF_8);
+ return Long.valueOf(plain.substring(plain.indexOf('|') + 1));
+ }
+
private static FeedInteractReqVO interactReq(Long gameId, Integer action, Boolean active) {
FeedInteractReqVO reqVO = new FeedInteractReqVO();
reqVO.setGameId(gameId);