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) 复合游标降序翻页) + * + *

真游标分页(替"永远第一页"桩): + *

    + *
  1. 解码入参 cursor 串 → 起始游标键 (sortScore,id)(首页为 null);
  2. + *
  3. 循环按复合游标取批 + 读侧可见态二次过滤累积可见行,直到攒满 size+1 条可见 或 库取空;
  4. + *
  5. 取满 size+1 → 截到 size,hasMore=true,nextCursor=第 size 条可见行的 (sortScore,id) 编码; + * 未满 → hasMore=false,nextCursor="" 到底(对齐契约 T-FED-06:空串=到底)。
  6. + *
+ * 二次过滤续取(隐藏坑):游标每轮按"上批最后一条原始库行"(非可见行)推进——被过滤掉的行也须跨过,否则下轮会重复取到它们; + * 多取 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);