feat(feed): U1 R-FEED feed 游标分页真化((sort_score,id)复合游标+二次过滤续取)

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) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-18 03:56:57 +00:00
parent 750fdce993
commit 9f2b9f9feb
3 changed files with 487 additions and 45 deletions

View File

@ -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<FeedRankDO> {
/**
* 按分区取可出流游戏,按 sort_score 降序(游戏流主查询,命中 idx_zone_status_sort)
* 按分区取可出流游戏,按 (sort_score, id) 降序复合排序(游戏流主查询,命中 idx_zone_status_sort)
*
* cursor 翻页骨架:以 sortScore 降序为序,cursor 解析后续在 service 用 ltIfPresent 续接(MVP 先全量降序取 limit)。
* <p><b>复合游标(keyset 分页)防丢/重</b>:sort_score 是 DECIMAL 可重复,单以 sort_score 续接会在相等值处丢行或重复,
* 故必须以 <b>(sort_score, id)</b> 复合键续接。降序续接谓词(取严格小于游标的下一段):
* <pre> sort_score &lt; cursorSortScore OR (sort_score = cursorSortScore AND id &lt; cursorId)</pre>
* 同时排序键也须为 (sort_score DESC, id DESC) 才能与游标谓词同序、保证翻页确定性(无 id 兜底则相等 sort_score 行序不定 → 丢/重)。</p>
*
* @param zoneId 分区维度:0默认混合流 / >0 指定专区
* @param limit 单页条数
* @return 排序记录列表(已按 sortScore 降序)
* <p>游标缺省(首页):cursorSortScore/cursorId 任一为 null 则不追加游标谓词,等价首页全量降序取 limit。
* 两值由 service 从 cursor 串解码,要么同时有值要么同时为 null(service 保证),故此处任一为 null 即视为首页。</p>
*
* @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<FeedRankDO> selectStreamByZone(Long zoneId, Integer limit) {
return selectList(new LambdaQueryWrapperX<FeedRankDO>()
default List<FeedRankDO> selectStreamByZone(Long zoneId, BigDecimal cursorSortScore, Long cursorId, Integer limit) {
// 游标是否生效:service 保证两值同有同无;任一为 null 即首页(不追加游标谓词)
boolean hasCursor = cursorSortScore != null && cursorId != null;
LambdaQueryWrapperX<FeedRankDO> wrapper = new LambdaQueryWrapperX<FeedRankDO>()
.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);
}
/**

View File

@ -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<FeedRankDO> ranks = feedRankMapper.selectStreamByZone(zoneId, size);
// 读侧可见态 enforce(§3.1 C1 / Codex H9):rank.status=1 只是覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致,
// 故对 rank 结果批查 project.status 二次过滤——仅 project.status==PUBLISHED(4) 的游戏出流,未发布/降级(下架/封禁/审核中)不出流。
List<FeedRankDO> 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) 复合游标降序翻页)
*
* <p><b>真游标分页(替"永远第一页"桩)</b>:
* <ol>
* <li>解码入参 cursor 串 → 起始游标键 (sortScore,id)(首页为 null);</li>
* <li>循环按复合游标取批 + 读侧可见态二次过滤累积可见行,直到攒满 size+1 条可见 或 库取空;</li>
* <li>取满 size+1 → 截到 size,hasMore=true,nextCursor=第 size 条可见行的 (sortScore,id) 编码;
* 未满 → hasMore=false,nextCursor="" 到底(对齐契约 T-FED-06:空串=到底)。</li>
* </ol>
* <b>二次过滤续取(隐藏坑)</b>:游标每轮按"上批最后一条<b>原始库行</b>"(非可见行)推进——被过滤掉的行也须跨过,否则下轮会重复取到它们;
* 多取 1 条(FETCH_PROBE)做 hasMore 探测,确保"过滤后正好够 size 但库里其实还有更多"时 hasMore 仍为真、且 nextCursor 接得上不丢不重。</p>
*
* @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<FeedRankDO> 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<FeedRankDO> 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<FeedRankDO> pageRanks = overflow ? visibleAcc.subList(0, size) : visibleAcc;
FeedStreamRespVO resp = new FeedStreamRespVO();
// 出流卡片:先填本模块自有字段(gameId/versionId/zone/quality),再跨模块回填 project 本体元信息(title/cover/计数)
List<FeedCardRespVO> cards = FeedConvert.toCardList(visibleRanks);
List<FeedCardRespVO> 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;
}
}
// ============================== 私有校验/辅助 ==============================
/**

View File

@ -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<FeedRankDO> 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<Long> 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<Long> 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<FeedRankDO> 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<Long> 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<FeedRankDO> dataset) {
// 预排序:sort_score DESC, id DESC(与 Mapper orderByDesc(sortScore).orderByDesc(id) 同序)
List<FeedRankDO> 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<FeedRankDO> 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);