feat(feed): 出流卡片回填 project 本体元信息(title/cover/计数) — 修卡片 title/cover=null

缺口:feed 不持久化游戏本体元信息,buildStream 出流卡片 title/coverUrl/playCount/likeCount 一直为 null(跟踪项①),demo 不可演示。

修复:ProjectApi 新增 getFeedMeta(local @Primary, 契约 -api 路径) + ProjectFeedMetaDTO;ProjectServiceImpl 读 game_project 映射;FeedServiceImpl.buildStream 出流(可见态过滤后)按 gameId 去重回填本体字段,游戏不存在则留空不阻断。不碰 project -server 依赖、不动数据回路。

验证:feed18+project15 单测绿(含 title 回填断言);staging 重部署后 feed/stream 返回真标题且 B(q88)仍在 A(q28)前(数据回路状态存活)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-09 16:08:47 +00:00
parent 35f13ab7df
commit ab0037cd4b
7 changed files with 128 additions and 2 deletions

View File

@ -2,6 +2,7 @@ package cn.wanxiang.game.module.feed.service.feed;
import cn.wanxiang.game.module.feed.controller.admin.feed.vo.FeaturedPageReqVO;
import cn.wanxiang.game.module.feed.controller.admin.feed.vo.FeaturedReqVO;
import cn.wanxiang.game.module.feed.controller.app.feed.vo.FeedCardRespVO;
import cn.wanxiang.game.module.feed.controller.app.feed.vo.FeedInteractReqVO;
import cn.wanxiang.game.module.feed.controller.app.feed.vo.FeedStreamReqVO;
import cn.wanxiang.game.module.feed.controller.app.feed.vo.FeedStreamRespVO;
@ -17,6 +18,7 @@ import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.wanxiang.game.module.feed.enums.FeedActionEnum;
import cn.wanxiang.game.module.project.api.ProjectApi;
import cn.wanxiang.game.module.project.dto.ProjectFeedMetaDTO;
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
import cn.wanxiang.game.module.runtime.enums.PackageStatusEnum;
@ -109,8 +111,11 @@ public class FeedServiceImpl implements FeedService {
// 故对 rank 结果批查 project.status 二次过滤 project.status==PUBLISHED(4) 的游戏出流未发布/降级下架/封禁/审核中不出流
List<FeedRankDO> visibleRanks = filterByProjectPublished(ranks);
FeedStreamRespVO resp = new FeedStreamRespVO();
resp.setList(FeedConvert.toCardList(visibleRanks));
// TODO 跨模块回填本体元信息读 project同库直查或 RPC登录态用户互动 liked/favorited game_feed_interact_log
// 出流卡片先填本模块自有字段gameId/versionId/zone/quality再跨模块回填 project 本体元信息title/cover/计数
List<FeedCardRespVO> cards = FeedConvert.toCardList(visibleRanks);
backfillCardMeta(cards);
resp.setList(cards);
// TODO 登录态用户互动 liked/favorited game_feed_interact_log 回填当前默认 false
// TODO cursor 续接以末条 sortScore 编码 nextCursor不足 size 条则 hasMore=false骨架先返回空串占位
resp.setNextCursor("");
// hasMore 以二次过滤后的真实出流条数判定避免被过滤掉的行误判还有更多
@ -143,6 +148,37 @@ public class FeedServiceImpl implements FeedService {
.collect(Collectors.toList());
}
/**
* 跨模块回填卡片本体元信息§3.1feed 不持久化本体 ProjectApi 本地 bean project 回填
*
* gameId 去重读 project 元信息title/coverUrl/playCount/likeCount回填到卡片
* 游戏不存在getFeedMeta data=null则保留该卡本体字段为空不阻断出流
*
* @param cards 待回填的卡片列表已按可见态过滤为已发布游戏
*/
private void backfillCardMeta(List<FeedCardRespVO> cards) {
if (cards.isEmpty()) {
return;
}
// 去重 gameId project 元信息缓存 map避免同一游戏多次 RPC
Map<Long, ProjectFeedMetaDTO> metaByGameId = new LinkedHashMap<>();
for (FeedCardRespVO card : cards) {
metaByGameId.computeIfAbsent(card.getGameId(),
gid -> projectApi.getFeedMeta(gid).getCheckedData());
}
// 回填本体字段title/cover/计数元信息缺失游戏不存在则跳过该卡留空由前端缺省兜底
for (FeedCardRespVO card : cards) {
ProjectFeedMetaDTO meta = metaByGameId.get(card.getGameId());
if (meta == null) {
continue;
}
card.setTitle(meta.getTitle());
card.setCoverUrl(meta.getCoverUrl());
card.setPlayCount(meta.getPlayCount());
card.setLikeCount(meta.getLikeCount());
}
}
@Override
public List<ZoneRespVO> getZones() {
// TODO 跨模块对接 project game_zonetype=1官方精选 2UGC普通status=1启用 sort 升序返回

View File

@ -12,6 +12,7 @@ import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper;
import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.wanxiang.game.module.project.api.ProjectApi;
import cn.wanxiang.game.module.project.dto.ProjectFeedMetaDTO;
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
@ -66,6 +67,12 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
// §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));
// 出流后跨模块回填本体元信息game1 回填真元信息断言生效game2 无元信息留空不阻断
ProjectFeedMetaDTO meta1 = new ProjectFeedMetaDTO();
meta1.setGameId(1L);
meta1.setTitle("甲游戏");
when(projectApi.getFeedMeta(1L)).thenReturn(CommonResult.success(meta1));
when(projectApi.getFeedMeta(2L)).thenReturn(CommonResult.success(null));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);
@ -73,6 +80,7 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
assertEquals(2, resp.getList().size());
assertEquals(1L, resp.getList().get(0).getGameId());
assertEquals("甲游戏", resp.getList().get(0).getTitle()); // 本体元信息已回填
assertTrue(resp.getHasMore()); // 取满 size 还有下一页
assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串
}
@ -85,6 +93,8 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
rank(2L, 11L, 0L, new BigDecimal("80")))); // project 已下架(5) 剔除
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(5));
// game1 出流后回填本体元信息game2 已被可见态过滤不参与回填
when(projectApi.getFeedMeta(1L)).thenReturn(CommonResult.success(null));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);

View File

@ -1,6 +1,7 @@
package cn.wanxiang.game.module.project.api;
import cn.wanxiang.game.module.project.enums.ApiConstants;
import cn.wanxiang.game.module.project.dto.ProjectFeedMetaDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -66,6 +67,20 @@ public interface ProjectApi {
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
CommonResult<Integer> getStatus(@RequestParam("gameId") Long gameId);
/**
* 按游戏 ID 取游戏流卡片本体元信息title/coverUrl/playCount/likeCountfeed 回填用
*
* feed 不持久化游戏本体元信息buildStream 出流已按可见态过滤为已发布游戏后据此回填卡片标题/封面/计数
* 显式列命中主键查询游戏不存在时 data 返回 nullfeed 侧留空不回填不阻断出流
*
* @param gameId 游戏项目 IDgame_project.id
* @return 卡片本体元信息CommonResult 包裹游戏不存在为 null
*/
@GetMapping(PREFIX + "/get-feed-meta")
@Operation(summary = "按游戏 ID 取游戏流卡片本体元信息feed 回填)")
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
CommonResult<ProjectFeedMetaDTO> getFeedMeta(@RequestParam("gameId") Long gameId);
/**
* 创建项目studio 写类 RPC建草稿显式传 creatorUserId owner
*

View File

@ -0,0 +1,30 @@
package cn.wanxiang.game.module.project.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 游戏流卡片本体元信息 DTOfeed 回填用project 对外契约 -api
*
* feed 不持久化游戏本体元信息标题/封面/累计计数 buildStream 出流已按可见态过滤为已发布游戏
* {@link cn.wanxiang.game.module.project.api.ProjectApi#getFeedMeta} 回填到 FeedCard
* 跨模块共享消费方feed只引用本 -api不依赖 project -server
*
* @author 造梦AI
*/
@Data
public class ProjectFeedMetaDTO implements Serializable {
/** 游戏 IDgame_project.id */
private Long gameId;
/** 标题 */
private String title;
/** 封面图 URL首屏卡片展示可能为空字符串前端做缺省兜底 */
private String coverUrl;
/** 累计试玩数telemetry 回写到 project */
private Long playCount;
/** 累计点赞数 */
private Long likeCount;
}

View File

@ -1,6 +1,7 @@
package cn.wanxiang.game.module.project.api;
import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectCreateReqVO;
import cn.wanxiang.game.module.project.dto.ProjectFeedMetaDTO;
import cn.wanxiang.game.module.project.service.project.ProjectService;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
@ -44,6 +45,12 @@ public class ProjectApiImpl implements ProjectApi {
return success(projectService.getStatus(gameId));
}
@Override
public CommonResult<ProjectFeedMetaDTO> getFeedMeta(Long gameId) {
// feed 出流回填委托 ProjectService 读本体元信息title/cover/计数游戏不存在返回 nullfeed 侧留空不阻断出流
return success(projectService.getFeedMeta(gameId));
}
@Override
public CommonResult<Long> createProject(Long creatorUserId, String title, String templateId) {
// studio 写类 RPCRPC 无登录上下文owner=显式 creatorUserId调用方受信传入不裸暴露给前端

View File

@ -8,6 +8,7 @@ import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPageReqV
import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishReqVO;
import cn.wanxiang.game.module.project.controller.app.project.vo.ProjectPublishRespVO;
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
import cn.wanxiang.game.module.project.dto.ProjectFeedMetaDTO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
/**
@ -113,6 +114,16 @@ public interface ProjectService {
*/
Integer getStatus(Long gameId);
/**
* 取游戏流卡片本体元信息title/coverUrl/playCount/likeCount feed -api 回填
*
* 只读命中主键查询游戏不存在返回 null feed 侧留空不回填不阻断出流
*
* @param gameId 游戏项目 IDgame_project.id
* @return 卡片本体元信息 DTO游戏不存在返回 null
*/
ProjectFeedMetaDTO getFeedMeta(Long gameId);
/**
* 封禁联动下架R3 compliance 跨模块调用
*

View File

@ -11,6 +11,7 @@ import cn.wanxiang.game.module.project.controller.app.project.vo.GateResultVO;
import cn.wanxiang.game.module.project.convert.project.ProjectConvert;
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
import cn.wanxiang.game.module.project.dal.dataobject.review.ReviewRecordDO;
import cn.wanxiang.game.module.project.dto.ProjectFeedMetaDTO;
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
@ -243,6 +244,22 @@ public class ProjectServiceImpl implements ProjectService {
return project == null ? null : project.getStatus();
}
@Override
public ProjectFeedMetaDTO getFeedMeta(Long gameId) {
// feed 出流回填 game_project 本体元信息映射为对外 DTO游戏不存在返回 nullfeed 侧留空不阻断出流
ProjectDO project = projectMapper.selectById(gameId);
if (project == null) {
return null;
}
ProjectFeedMetaDTO meta = new ProjectFeedMetaDTO();
meta.setGameId(project.getId());
meta.setTitle(project.getTitle());
meta.setCoverUrl(project.getCoverUrl());
meta.setPlayCount(project.getPlayCount());
meta.setLikeCount(project.getLikeCount());
return meta;
}
@Override
public boolean unlistIfPublished(Long gameId) {
// R3 封禁联动下架状态查询 + 下架决策均收敛在 project 权威侧复用既有 reviewProject 状态机不重写=D2