feat(B1): 黄金闭环后端实现(step②) — 发布编排/同步写数据回路/PackageStore/version 链

step② 五模块 impl(编译验证待 mini-desktop,与 step① 合并编译):
- project: PublishOrchestrationService(@Transactional 六步原子,注入 Feed/RuntimePackageApi 本地 bean=H5)+GameVersion DO/Mapper/Service(genTaskId 幂等)+ProjectVersionApiImpl+reviewProject α(REVIEWING→PUBLISHED 跳过 APPROVED)+ProjectApi.getStatus
- feed: FeedApiImpl(@Primary)+upsertRank 两 mode 分流(PUBLISH_BASELINE 写前校验可见态前三条件 / QUALITY_REFRESH 保留 boost/pinned/status)+buildStream 二次过滤+去 isGamePublished 默认放行
- runtime: RuntimePackageApiImpl(@Primary 委托 publish/getStatus)+PackageStore(DB impl 读写 package_json)+manifest 端点(原样 JSON 不包 CommonResult)
- telemetry: EventIngestServiceImpl 同步写本地事务(eventId 幂等→仅真插入累加 stat→算 quality_score→FeedApi 回灌→置已聚合,失败整批回滚);MQ 仅留 TODO
- aigc: completeWithVersion(taskId 幂等)

主 agent 独立核验: 发布编排事务原子/reviewProject α/同步写逻辑 已读核;守门④无环复检通过;新增 ProjectApi.getStatus+RuntimePackageApi.getStatus(enforce 只读)
已知限制(T+1 非阻断): stat 累加为 MP select+update 非原子,并发可能丢增量(顺序 A/B 不受影响,真流量前改原生原子 upsert)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-09 14:21:06 +00:00
parent e670eaf476
commit 1740d8c226
38 changed files with 1600 additions and 91 deletions

View File

@ -78,4 +78,17 @@ public interface AigcTaskService {
*/ */
PageResult<AigcTaskDO> getAdminTaskPage(AigcTaskAdminPageReqVO reqVO); PageResult<AigcTaskDO> getAdminTaskPage(AigcTaskAdminPageReqVO reqVO);
/**
* 完成任务并回填版本 ID黄金闭环 §3.3 C3 / Codex H7 PackageFactory 落包后调用
*
* game_aigc_task.version_id + status=succeeded成功终态 taskId 幂等
* 重复回填同任务安全已是 succeeded version_id 一致则直接返回不二次写入
* studio 轮询完成态据此拿到 versionId{@code StudioServiceImpl} task.getVersionId()
* PackageFactory version 不够必须回填 aigc 任务才闭环
*
* @param taskId 生成任务 ID
* @param versionId 产物落地版本 IDproject.game_version.id ProjectVersionApi.createForPackage 产出
*/
void completeWithVersion(Long taskId, Long versionId);
} }

View File

@ -140,6 +140,31 @@ public class AigcTaskServiceImpl implements AigcTaskService {
return aigcTaskMapper.selectAdminPage(reqVO); return aigcTaskMapper.selectAdminPage(reqVO);
} }
@Override
public void completeWithVersion(Long taskId, Long versionId) {
// §3.3 C3PackageFactory 落包后回填 version_id + status=succeeded taskId 幂等供 studio 轮询取 versionId
AigcTaskDO task = aigcTaskMapper.selectById(taskId);
if (task == null) {
// 任务不存在回填目标缺失PackageFactory 应基于真实任务落包抛任务不存在便于排障
throw exception(AIGC_TASK_NOT_EXISTS);
}
// 幂等已是成功终态且 version_id 一致 重复回填安全直接返回不二次写入
if (Objects.equals(task.getStatus(), AigcTaskStatusEnum.SUCCEEDED.getStatus())
&& Objects.equals(task.getVersionId(), versionId)) {
log.info("[completeWithVersion] 回填幂等命中,跳过 taskId={}, versionId={}", taskId, versionId);
return;
}
// 置成功终态 + 回填版本 ID + 终态完成时间
AigcTaskDO update = new AigcTaskDO();
update.setId(taskId);
update.setVersionId(versionId);
update.setStatus(AigcTaskStatusEnum.SUCCEEDED.getStatus());
update.setProgress(100);
update.setFinishTime(LocalDateTime.now());
aigcTaskMapper.updateById(update);
log.info("[completeWithVersion] 任务完成并回填版本 taskId={}, versionId={}", taskId, versionId);
}
// ============================== 私有校验/工具 ============================== // ============================== 私有校验/工具 ==============================
/** /**

View File

@ -7,7 +7,7 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
* *
* feed 模块独占 1-103-***-*** .agents/rules/engineering-conventions.md §1.3 * feed 模块独占 1-103-***-*** .agents/rules/engineering-conventions.md §1.3
* 约定禁止与其它模块错误码段重叠新增错误码在此登记 * 约定禁止与其它模块错误码段重叠新增错误码在此登记
* 段内业务细分000 游戏流 / 001 互动 / 002 分享 / 003 精选池 * 段内业务细分000 游戏流 / 001 互动 / 002 分享 / 003 精选池 / 004 排序行 upsert黄金闭环 §3.2/§3.5
* *
* @author 绘境AI * @author 绘境AI
*/ */
@ -31,4 +31,10 @@ public interface ErrorCodeConstants {
/** 精选目标非法:仅 project status=4 已发布的游戏可入精选(对齐契约 1-103-003-001 */ /** 精选目标非法:仅 project status=4 已发布的游戏可入精选(对齐契约 1-103-003-001 */
ErrorCode FEED_FEATURED_GAME_NOT_PUBLISHED = new ErrorCode(1_103_003_001, "仅已发布的游戏可加入精选池"); ErrorCode FEED_FEATURED_GAME_NOT_PUBLISHED = new ErrorCode(1_103_003_001, "仅已发布的游戏可加入精选池");
// ========== 排序行 upsert 1-103-004-***黄金闭环 §3.2/§3.5==========
/** upsert 模式缺失FeedRankUpsertReqDTO.mode 必填(分流发布基线/算分回灌,守门③) */
ErrorCode FEED_RANK_UPSERT_MODE_REQUIRED = new ErrorCode(1_103_004_000, "排序行 upsert 模式不能为空");
/** 可见态写侧校验未过mode=PUBLISH_BASELINE 写 status=1 前须满足「project 已发布 + 当前生效版本一致 + 运行包已发布」三条件§3.1 C1 */
ErrorCode FEED_RANK_VISIBILITY_VIOLATION = new ErrorCode(1_103_004_001, "排序行不满足可见态条件,拒绝写入发布基线");
} }

View File

@ -31,13 +31,20 @@
<version>${revision}</version> <version>${revision}</version>
</dependency> </dependency>
<!-- 跨模块契约project -apiGAP-3 分享回填 currentVersionId,仅依赖 -api 不依赖 -server --> <!-- 跨模块契约project -apiGAP-3 分享回填 currentVersionId§3.1/§3.2 可见态 enforce 读 project.status,仅依赖 -api 不依赖 -server -->
<dependency> <dependency>
<groupId>cn.iocoder.cloud</groupId> <groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-project-api</artifactId> <artifactId>game-module-project-api</artifactId>
<version>${revision}</version> <version>${revision}</version>
</dependency> </dependency>
<!-- 跨模块契约runtime -api§3.1 C1 可见态第三条件upsertRank(PUBLISH_BASELINE) 写侧 enforce 读 runtime_package.status仅依赖 -api 不依赖 -server守门④无环 -->
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>game-module-runtime-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件:数据权限 + 多租户DO 继承 TenantBaseDO --> <!-- 业务组件:数据权限 + 多租户DO 继承 TenantBaseDO -->
<dependency> <dependency>
<groupId>cn.iocoder.cloud</groupId> <groupId>cn.iocoder.cloud</groupId>

View File

@ -0,0 +1,37 @@
package cn.wanxiang.game.module.feed.api;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.wanxiang.game.module.feed.service.feed.FeedService;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 游戏流排序 API 实现黄金闭环 §3.2/§3.5提供 RESTful 接口给跨模块 Feign 调用发布编排 + telemetry 算分回灌写排序行
*
* @RestController + @Primary yudao DictDataApiImpl / 既有 ProjectApiImpl 同构单体内同进程调用走本实现拆微服务后走 Feign守门/§3.2 H5
* 仅委托 {@link FeedService#upsertRank} mode 分流 + 写侧可见态 enforce不写业务逻辑业务在 Service
* 事务原子§3.2 Codex H5上游发布编排/算分回灌注入本 @Primary bean在其本地 @Transactional 内调用本方法抛错则上游整体回滚
*
* @author 造梦AI
*/
@RestController // 提供 RESTful API 接口 Feign 调用
@Validated
@Primary // @FeignClient 接口同名 Bean 冲突时优先用本地实现同进程调用就地解析上游事务内本地调用
public class FeedApiImpl implements FeedApi {
@Resource
private FeedService feedService;
@Override
public CommonResult<Boolean> upsertRank(FeedRankUpsertReqDTO req) {
// mode 分流PUBLISH_BASELINE发布基线写前 enforce 可见态三条件/ QUALITY_REFRESH仅刷分保留 boost/pinned/status
feedService.upsertRank(req);
return success(Boolean.TRUE);
}
}

View File

@ -10,6 +10,7 @@ import cn.wanxiang.game.module.feed.controller.app.feed.vo.ShareMetaRespVO;
import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneRespVO; import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneRespVO;
import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneStreamReqVO; import cn.wanxiang.game.module.feed.controller.app.feed.vo.ZoneStreamReqVO;
import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO; import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import java.util.List; import java.util.List;
@ -83,4 +84,18 @@ public interface FeedService {
*/ */
void setFeatured(FeaturedReqVO reqVO); void setFeatured(FeaturedReqVO reqVO);
/**
* upsert 游戏流排序行黄金闭环 §3.2/§3.5两个上游写入点经本方法按 mode 分流守门
*
* - PUBLISH_BASELINE发布编排§3.2 C2 status=1 前校验可见态前三条件
* project.status==PUBLISHED(4) AND project.current_version_id==versionId AND runtime_package(versionId).status==1
* 不满足拒写抛错使发布事务回滚满足则写全字段 status=1 入流
* - QUALITY_REFRESHtelemetry 算分回灌§3.5 C5仅更 quality_score/sort_score保留 boost/pinned/status不覆盖运营加权与在流态
*
* 同步事务语义由调用方在其本地 @Transactional 内调用本方法抛错则上游整体回滚
*
* @param req 排序行 upsert 入参 mode 分流
*/
void upsertRank(FeedRankUpsertReqDTO req);
} }

View File

@ -14,8 +14,12 @@ import cn.wanxiang.game.module.feed.dal.dataobject.interact.FeedInteractLogDO;
import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO; import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper; 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.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.feed.enums.FeedActionEnum;
import cn.wanxiang.game.module.project.api.ProjectApi; import cn.wanxiang.game.module.project.api.ProjectApi;
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
import cn.wanxiang.game.module.runtime.enums.PackageStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -25,8 +29,11 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
import static cn.wanxiang.game.module.feed.enums.ErrorCodeConstants.*; import static cn.wanxiang.game.module.feed.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -56,11 +63,20 @@ public class FeedServiceImpl implements FeedService {
* project 模块 RPC 契约仅依赖 -api * project 模块 RPC 契约仅依赖 -api
* *
* GAP-3分享元数据回填当前生效版本 IDgetCurrentVersionId供分享落地页直达即玩加载 * GAP-3分享元数据回填当前生效版本 IDgetCurrentVersionId供分享落地页直达即玩加载
* §3.1/§3.2 可见态 enforcegetStatus project.status写侧第一条件 + 读侧二次过滤getCurrentVersionId 用于写侧第二条件
* MVP 单体 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析 * MVP 单体 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析
*/ */
@Resource @Resource
private ProjectApi projectApi; private ProjectApi projectApi;
/**
* runtime 模块 RPC 契约仅依赖 -api§3.1 C1 写侧可见态第三条件
*
* getStatus game_runtime_package(versionId).status==1 已发布MVP 单体 runtime 模块 RuntimePackageApiImpl(@RestController @Primary) 就地解析
*/
@Resource
private RuntimePackageApi runtimePackageApi;
// ============================== 游戏流 ============================== // ============================== 游戏流 ==============================
@Override @Override
@ -89,15 +105,44 @@ public class FeedServiceImpl implements FeedService {
*/ */
private FeedStreamRespVO buildStream(Long zoneId, Integer size, Long userId) { private FeedStreamRespVO buildStream(Long zoneId, Integer size, Long userId) {
List<FeedRankDO> ranks = feedRankMapper.selectStreamByZone(zoneId, size); List<FeedRankDO> ranks = feedRankMapper.selectStreamByZone(zoneId, size);
// 读侧可见态 enforce§3.1 C1 / Codex H9rank.status=1 只是覆盖层在流态仍可能因手工写错/项目降级而与权威态不一致
// 故对 rank 结果批查 project.status 二次过滤 project.status==PUBLISHED(4) 的游戏出流未发布/降级下架/封禁/审核中不出流
List<FeedRankDO> visibleRanks = filterByProjectPublished(ranks);
FeedStreamRespVO resp = new FeedStreamRespVO(); FeedStreamRespVO resp = new FeedStreamRespVO();
resp.setList(FeedConvert.toCardList(ranks)); resp.setList(FeedConvert.toCardList(visibleRanks));
// TODO 跨模块回填本体元信息读 project同库直查或 RPC登录态用户互动 liked/favorited game_feed_interact_log // TODO 跨模块回填本体元信息读 project同库直查或 RPC登录态用户互动 liked/favorited game_feed_interact_log
// TODO cursor 续接以末条 sortScore 编码 nextCursor不足 size 条则 hasMore=false骨架先返回空串占位 // TODO cursor 续接以末条 sortScore 编码 nextCursor不足 size 条则 hasMore=false骨架先返回空串占位
resp.setNextCursor(""); resp.setNextCursor("");
resp.setHasMore(ranks.size() >= size); // hasMore 以二次过滤后的真实出流条数判定避免被过滤掉的行误判还有更多
resp.setHasMore(visibleRanks.size() >= size);
return resp; return resp;
} }
/**
* 读侧可见态二次过滤§3.1 C1批查每条 rank 对应游戏的 project.status PUBLISHED(4) 保留出流
*
* ProjectApi 本地 bean gameId status同进程方法调用非真实 Feign去重 gameId 减少调用次数
* 未发布/降级含游戏不存在 status=null一律剔除杜绝手工写错一条 rank 就出流去掉旧 isGamePublished 默认放行
*
* @param ranks 排序行候选已按 sort_score 降序
* @return 仅保留 project 已发布的排序行保持原降序
*/
private List<FeedRankDO> filterByProjectPublished(List<FeedRankDO> ranks) {
if (ranks.isEmpty()) {
return ranks;
}
// 去重 gameId 批量读 project.status缓存到 map避免同一游戏多次 RPC
Map<Long, Integer> statusByGameId = new LinkedHashMap<>();
for (FeedRankDO rank : ranks) {
statusByGameId.computeIfAbsent(rank.getGameId(),
gid -> projectApi.getStatus(gid).getCheckedData());
}
// 仅保留 project.status==PUBLISHED(4) 的行status=null 即游戏不存在剔除
return ranks.stream()
.filter(rank -> Objects.equals(statusByGameId.get(rank.getGameId()), ProjectStatusEnum.PUBLISHED.getStatus()))
.collect(Collectors.toList());
}
@Override @Override
public List<ZoneRespVO> getZones() { public List<ZoneRespVO> getZones() {
// TODO 跨模块对接 project game_zonetype=1官方精选 2UGC普通status=1启用 sort 升序返回 // TODO 跨模块对接 project game_zonetype=1官方精选 2UGC普通status=1启用 sort 升序返回
@ -244,6 +289,117 @@ public class FeedServiceImpl implements FeedService {
} }
} }
// ============================== 排序行 upsert发布基线 / 算分回灌两路§3.2/§3.5==============================
@Override
@Transactional(rollbackFor = Exception.class) // 由上游发布编排/算分回灌本地事务内调用本方法抛错则上游整体回滚
public void upsertRank(FeedRankUpsertReqDTO req) {
if (req.getMode() == null) {
// mode 必填缺失无法判定写入字段集按非法入参拒绝守门
throw exception(FEED_RANK_UPSERT_MODE_REQUIRED);
}
if (FeedRankUpsertReqDTO.UpsertMode.PUBLISH_BASELINE == req.getMode()) {
upsertPublishBaseline(req);
} else {
upsertQualityRefresh(req);
}
}
/**
* 发布基线 upsertmode=PUBLISH_BASELINE§3.2 C2 status=1 前校验可见态前三条件满足则写全字段
*
* 可见态前三条件§3.1 C1 写侧 enforce ProjectApi/RuntimePackageApi 本地 bean 不满足拒写抛错回滚
* project.status==PUBLISHED(4) project.current_version_id==versionId runtime_package(versionId).status==1 已发布
*
* @param req 排序行入参
*/
private void upsertPublishBaseline(FeedRankUpsertReqDTO req) {
// 条件project 已发布
if (!isGamePublished(req.getGameId())) {
throw exception(FEED_RANK_VISIBILITY_VIOLATION);
}
// 条件current_version_id 与待写版本一致避免把非当前版本写进流
Long currentVersionId = projectApi.getCurrentVersionId(req.getGameId()).getCheckedData();
if (!Objects.equals(currentVersionId, req.getVersionId())) {
throw exception(FEED_RANK_VISIBILITY_VIOLATION);
}
// 条件runtime_package 已发布status=1
Integer packageStatus = req.getVersionId() == null ? null
: runtimePackageApi.getStatus(req.getVersionId()).getCheckedData();
if (!Objects.equals(packageStatus, PackageStatusEnum.PUBLISHED.getStatus())) {
throw exception(FEED_RANK_VISIBILITY_VIOLATION);
}
// upsert命中 uk_game_zone 则更新全字段否则新插复用 setFeatured select+insert/updateById 范式 tenant/审计自动填充
Long zoneId = req.getZoneId() == null ? DEFAULT_ZONE_ID : req.getZoneId();
FeedRankDO existing = feedRankMapper.selectByGameAndZone(req.getGameId(), zoneId);
if (existing == null) {
FeedRankDO rank = new FeedRankDO();
rank.setGameId(req.getGameId());
rank.setVersionId(req.getVersionId());
rank.setZoneId(zoneId);
rank.setQualityScore(toDecimal(req.getQualityScore()));
rank.setBoost(toDecimal(req.getBoost()));
rank.setSortScore(toDecimal(req.getSortScore()));
rank.setStatus(req.getStatus() == null ? 1 : req.getStatus());
rank.setPinned(req.getPinned() == null ? 0 : req.getPinned());
feedRankMapper.insert(rank);
} else {
// 重发布刷新版本/分数/在流态/置顶发布基线全字段覆盖
FeedRankDO update = new FeedRankDO();
update.setId(existing.getId());
update.setVersionId(req.getVersionId());
update.setQualityScore(toDecimal(req.getQualityScore()));
update.setBoost(toDecimal(req.getBoost()));
update.setSortScore(toDecimal(req.getSortScore()));
update.setStatus(req.getStatus() == null ? 1 : req.getStatus());
update.setPinned(req.getPinned() == null ? 0 : req.getPinned());
feedRankMapper.updateById(update);
}
log.info("[upsertRank] 发布基线写入 gameId={}, zoneId={}, versionId={}", req.getGameId(), zoneId, req.getVersionId());
}
/**
* 算分回灌 upsertmode=QUALITY_REFRESH§3.5 C5仅更 quality_score/sort_score保留 boost/pinned/status守门
*
* 仅对已存在的排序行做刷新 UPDATE不新建未发布游戏无发布基线行则无可刷新telemetry 只对已发布游戏回灌有意义
* 仅设置 quality_score/sort_score 两个字段其余字段不置入 update DOMyBatis-Plus 默认策略跳过 null boost/pinned/status 原值保留
*
* @param req 排序行入参
*/
private void upsertQualityRefresh(FeedRankUpsertReqDTO req) {
Long zoneId = req.getZoneId() == null ? DEFAULT_ZONE_ID : req.getZoneId();
FeedRankDO existing = feedRankMapper.selectByGameAndZone(req.getGameId(), zoneId);
if (existing == null) {
// 无发布基线行无可回灌游戏未发布或未入流记日志后返回不新建避免回灌产生无发布态的孤儿排序行
log.info("[upsertRank] 算分回灌跳过:无发布基线排序行 gameId={}, zoneId={}", req.getGameId(), zoneId);
return;
}
// 仅刷 quality_score/sort_score不置 boost/pinned/status守门保留运营加权与在流态
// sort_score feed 侧权威重算 = quality_score + 既有 boostboost 权威归 feed 运营telemetry 不持有
// 故不采信 req.sortScore避免回灌覆盖运营加权 setFeatured sort_score=quality+boost 口径一致
BigDecimal quality = toDecimal(req.getQualityScore());
BigDecimal boost = existing.getBoost() == null ? BigDecimal.ZERO : existing.getBoost();
FeedRankDO update = new FeedRankDO();
update.setId(existing.getId());
update.setQualityScore(quality);
update.setSortScore(quality.add(boost));
feedRankMapper.updateById(update);
log.info("[upsertRank] 算分回灌刷新 gameId={}, zoneId={}, qualityScore={}, boost={}, sortScore={}",
req.getGameId(), zoneId, quality, boost, quality.add(boost));
}
/**
* Double 排序值 BigDecimalfeed_rank 列为 DECIMAL10,4null 安全缺省 0
*
* BigDecimal.valueOf 避免 new BigDecimal(double) 的二进制浮点失真
*
* @param value DTO Double
* @return BigDecimal
*/
private static BigDecimal toDecimal(Double value) {
return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value);
}
// ============================== 私有校验/辅助 ============================== // ============================== 私有校验/辅助 ==============================
/** /**
@ -274,20 +430,19 @@ public class FeedServiceImpl implements FeedService {
} }
/** /**
* 校验游戏是否 project status=4 已发布分享/精选的发布门禁 * 校验游戏是否 project status=4 已发布分享/精选的发布门禁 + 可见态写侧第一条件复用
* *
* 跨模块读 project 的对接点同库直查 game_project.status project -api RPC * §3.1 C1去掉旧骨架默认放行 true ProjectApi 本地 bean project.status PUBLISHED(4) true
* 设计为可重写 seam骨架阶段默认放行true实际接入时替换为真实查询 * 仍保留 protected seam 便于单测桩控@Spy
* *
* @param gameId 游戏 ID * @param gameId 游戏 ID
* @return true=已发布 * @return true=已发布project.status==PUBLISHED(4)
*/ */
protected boolean isGamePublished(Long gameId) { protected boolean isGamePublished(Long gameId) {
// TODO 跨模块对接 project game_project where id=gameId and status=4
// MVP 同库可直查或待 project -api RPC 就绪后改 Feign含已删/封禁过滤
// 骨架阶段默认放行避免阻断联调集成阶段必须替换为真实校验
Objects.requireNonNull(gameId, "gameId 不能为空"); Objects.requireNonNull(gameId, "gameId 不能为空");
return true; // project -api 本地 bean status同进程 Feign游戏不存在 status=null PUBLISHED 一律 false
Integer status = projectApi.getStatus(gameId).getCheckedData();
return Objects.equals(status, ProjectStatusEnum.PUBLISHED.getStatus());
} }
} }

View File

@ -10,7 +10,9 @@ import cn.wanxiang.game.module.feed.dal.dataobject.interact.FeedInteractLogDO;
import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO; import cn.wanxiang.game.module.feed.dal.dataobject.rank.FeedRankDO;
import cn.wanxiang.game.module.feed.dal.mysql.interact.FeedInteractLogMapper; 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.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.api.ProjectApi;
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
@ -49,7 +51,9 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private FeedInteractLogMapper feedInteractLogMapper; private FeedInteractLogMapper feedInteractLogMapper;
@Mock @Mock
private ProjectApi projectApi; // GAP-3分享回填 currentVersionId project 契约 private ProjectApi projectApi; // GAP-3分享回填 currentVersionId project 契约§3.1/§3.2 可见态 enforce status
@Mock
private RuntimePackageApi runtimePackageApi; // §3.1 C1 可见态第三条件 runtime_package.status
// ============================== 游戏流 ============================== // ============================== 游戏流 ==============================
@ -59,6 +63,9 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList( when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList(
rank(1L, 10L, 0L, new BigDecimal("90")), rank(1L, 10L, 0L, new BigDecimal("90")),
rank(2L, 11L, 0L, new BigDecimal("80")))); rank(2L, 11L, 0L, new BigDecimal("80"))));
// §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));
FeedStreamReqVO reqVO = new FeedStreamReqVO(); FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2); reqVO.setSize(2);
@ -70,6 +77,24 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串 assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串
} }
@Test
void testGetFeedStream_filterOutNonPublished() {
// §3.1 C1 读侧 enforcerank 在流(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) 剔除
when(projectApi.getStatus(1L)).thenReturn(CommonResult.success(4));
when(projectApi.getStatus(2L)).thenReturn(CommonResult.success(5));
FeedStreamReqVO reqVO = new FeedStreamReqVO();
reqVO.setSize(2);
FeedStreamRespVO resp = feedService.getFeedStream(reqVO, 99L);
assertEquals(1, resp.getList().size()); // 仅保留已发布游戏
assertEquals(1L, resp.getList().get(0).getGameId());
assertFalse(resp.getHasMore()); // 过滤后不足 size 无下一页
}
// ============================== 互动动作校验 ============================== // ============================== 互动动作校验 ==============================
@Test @Test
@ -231,6 +256,84 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
verify(feedRankMapper, never()).insert(any(FeedRankDO.class)); verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
} }
// ============================== 排序行 upsert发布基线 / 算分回灌§3.2/§3.5==============================
@Test
void testUpsertRank_publishBaseline_insertWhenVisibilityPass() {
// 可见态前三条件全过project 已发布(4) + current_version_id==versionId + runtime_package 已发布(1)
doReturn(true).when(feedService).isGamePublished(1L); // 条件复用 isGamePublished seam
when(projectApi.getCurrentVersionId(1L)).thenReturn(CommonResult.success(2048L)); // 条件
when(runtimePackageApi.getStatus(2048L)).thenReturn(CommonResult.success(1)); // 条件
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(null); // 无既有行 新插
feedService.upsertRank(baselineReq(1L, 2048L, 0L));
ArgumentCaptor<FeedRankDO> captor = ArgumentCaptor.forClass(FeedRankDO.class);
verify(feedRankMapper).insert(captor.capture());
FeedRankDO saved = captor.getValue();
assertEquals(1, saved.getStatus()); // 发布即入流 status=1
assertEquals(0, saved.getPinned());
assertEquals(2048L, saved.getVersionId());
}
@Test
void testUpsertRank_publishBaseline_rejectedWhenPackageNotPublished() {
// 条件条件不满足运行包 status=0 预览就绪未发布 拒写抛错发布事务回滚
doReturn(true).when(feedService).isGamePublished(1L);
when(projectApi.getCurrentVersionId(1L)).thenReturn(CommonResult.success(2048L));
when(runtimePackageApi.getStatus(2048L)).thenReturn(CommonResult.success(0));
ServiceException ex = assertThrows(ServiceException.class,
() -> feedService.upsertRank(baselineReq(1L, 2048L, 0L)));
assertEquals(FEED_RANK_VISIBILITY_VIOLATION.getCode(), ex.getCode());
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
verify(feedRankMapper, never()).updateById(any(FeedRankDO.class));
}
@Test
void testUpsertRank_qualityRefresh_onlyUpdatesScoresPreservesBoostPinnedStatus() {
// 算分回灌仅更 quality_score/sort_score保留 boost/pinned/status守门update DO 不含这三字段
FeedRankDO existing = new FeedRankDO();
existing.setId(7L);
existing.setBoost(new BigDecimal("5"));
existing.setPinned(1);
existing.setStatus(1);
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(existing);
feedService.upsertRank(refreshReq(1L, 0L, 88.5D, 93.5D));
ArgumentCaptor<FeedRankDO> captor = ArgumentCaptor.forClass(FeedRankDO.class);
verify(feedRankMapper).updateById(captor.capture());
FeedRankDO update = captor.getValue();
assertEquals(7L, update.getId());
assertEquals(0, new BigDecimal("88.5").compareTo(update.getQualityScore()));
assertEquals(0, new BigDecimal("93.5").compareTo(update.getSortScore()));
// 守门boost/pinned/status 不被置入 update DO保持 nullMyBatis-Plus 跳过 DB 原值保留
assertNull(update.getBoost());
assertNull(update.getPinned());
assertNull(update.getStatus());
}
@Test
void testUpsertRank_qualityRefresh_noopWhenNoBaseline() {
// 无发布基线行算分回灌空转不新建孤儿排序行
when(feedRankMapper.selectByGameAndZone(1L, 0L)).thenReturn(null);
feedService.upsertRank(refreshReq(1L, 0L, 88.5D, 93.5D));
verify(feedRankMapper, never()).insert(any(FeedRankDO.class));
verify(feedRankMapper, never()).updateById(any(FeedRankDO.class));
}
@Test
void testUpsertRank_modeRequired() {
// mode 缺失 非法入参拒绝守门
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
req.setGameId(1L);
ServiceException ex = assertThrows(ServiceException.class, () -> feedService.upsertRank(req));
assertEquals(FEED_RANK_UPSERT_MODE_REQUIRED.getCode(), ex.getCode());
}
// ============================== 测试夹具 ============================== // ============================== 测试夹具 ==============================
/** 构造排序覆盖层记录 */ /** 构造排序覆盖层记录 */
@ -262,4 +365,30 @@ class FeedServiceImplTest extends BaseMockitoUnitTest {
return reqVO; return reqVO;
} }
/** 构造发布基线 upsert 入参mode=PUBLISH_BASELINE分数/boost/sort=0status=1pinned=0 */
private static FeedRankUpsertReqDTO baselineReq(Long gameId, Long versionId, Long zoneId) {
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
req.setGameId(gameId);
req.setVersionId(versionId);
req.setZoneId(zoneId);
req.setQualityScore(0D);
req.setBoost(0D);
req.setSortScore(0D);
req.setStatus(1);
req.setPinned(0);
req.setMode(FeedRankUpsertReqDTO.UpsertMode.PUBLISH_BASELINE);
return req;
}
/** 构造算分回灌 upsert 入参mode=QUALITY_REFRESH仅 quality_score/sort_score */
private static FeedRankUpsertReqDTO refreshReq(Long gameId, Long zoneId, Double qualityScore, Double sortScore) {
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
req.setGameId(gameId);
req.setZoneId(zoneId);
req.setQualityScore(qualityScore);
req.setSortScore(sortScore);
req.setMode(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH);
return req;
}
} }

View File

@ -51,6 +51,21 @@ public interface ProjectApi {
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024") @Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
CommonResult<Long> getCurrentVersionId(@RequestParam("gameId") Long gameId); CommonResult<Long> getCurrentVersionId(@RequestParam("gameId") Long gameId);
/**
* 按游戏 ID 反查项目状态机值§3.1/§3.2 C1/C2 可见态校验feed 读写双侧 enforce 依赖
*
* 用途feed 写侧PUBLISH_BASELINE upsert 前校验 project.status==PUBLISHED(4)+ 读侧buildStream rank 结果批查 project.status 二次过滤未发布/降级不出流
* 显式只查 status 单列命中主键游戏不存在时 data 返回 null由调用方按业务决定 feed 写侧视为不满足可见态拒写
* 状态值语义见 {@link cn.wanxiang.game.module.project.enums.ProjectStatusEnum}4=已发布
*
* @param gameId 游戏项目 IDgame_project.id
* @return 项目状态机值CommonResult 包裹游戏不存在为 null
*/
@GetMapping(PREFIX + "/get-status")
@Operation(summary = "按游戏 ID 反查项目状态机值(可见态 enforce§3.1/§3.2")
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
CommonResult<Integer> getStatus(@RequestParam("gameId") Long gameId);
/** /**
* 创建项目studio 写类 RPC建草稿显式传 creatorUserId owner * 创建项目studio 写类 RPC建草稿显式传 creatorUserId owner
* *

View File

@ -12,6 +12,10 @@ import java.util.Objects;
* 合法流转0草稿 1审核中 (2已通过 / 3已拒绝) 4已发布 (5已下架 / 6已封禁) * 合法流转0草稿 1审核中 (2已通过 / 3已拒绝) 4已发布 (5已下架 / 6已封禁)
* 注意状态机校验在 ProjectService 承载DO 层不做校验 db-schemas 注释 * 注意状态机校验在 ProjectService 承载DO 层不做校验 db-schemas 注释
* *
* D-PUB=α auto-publish黄金闭环 §12026-06-09 拍板审核通过即自动发布
* 人工 reviewProject(APPROVE) 在同一本地事务内经发布编排把项目态直接 REVIEWING(1)PUBLISHED(4)
* APPROVED(2) 仅作审核记录的决策值game_review_record.decision=通过项目态不停留 APPROVED(2)
*
* @author 绘境AI * @author 绘境AI
*/ */
@Getter @Getter

View File

@ -38,6 +38,12 @@ public class ProjectApiImpl implements ProjectApi {
return success(projectService.getCurrentVersionId(gameId)); return success(projectService.getCurrentVersionId(gameId));
} }
@Override
public CommonResult<Integer> getStatus(Long gameId) {
// §3.1/§3.2 可见态 enforce game_project.id 返回 status不存在返回 null feed 写侧拒写读侧过滤
return success(projectService.getStatus(gameId));
}
@Override @Override
public CommonResult<Long> createProject(Long creatorUserId, String title, String templateId) { public CommonResult<Long> createProject(Long creatorUserId, String title, String templateId) {
// studio 写类 RPCRPC 无登录上下文owner=显式 creatorUserId调用方受信传入不裸暴露给前端 // studio 写类 RPCRPC 无登录上下文owner=显式 creatorUserId调用方受信传入不裸暴露给前端

View File

@ -0,0 +1,35 @@
package cn.wanxiang.game.module.project.api;
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import cn.wanxiang.game.module.project.service.version.GameVersionService;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 游戏版本 API 实现黄金闭环 §3.3 C3提供 RESTful 接口给跨模块 Feign 调用PackageFactory 落包建版本
*
* @RestController + @Primary yudao DictDataApiImpl / 既有 ProjectApiImpl 同构单体内同进程调用走本实现拆微服务后走 Feign守门/§3.2 H5
* 仅委托 {@link GameVersionService} 落包建版本幂等不写业务逻辑业务在 Service
*
* @author 造梦AI
*/
@RestController // 提供 RESTful API 接口 Feign 调用
@Validated
@Primary // @FeignClient 接口同名 Bean 冲突时优先用本地实现同进程调用就地解析
public class ProjectVersionApiImpl implements ProjectVersionApi {
@Resource
private GameVersionService gameVersionService;
@Override
public CommonResult<Long> createForPackage(ProjectVersionCreateForPackageReqDTO req) {
// 落包建版本委托 GameVersionService 创建/复用 game_versionstatus=2 预览就绪 genTaskId 幂等
return success(gameVersionService.createForPackage(req));
}
}

View File

@ -0,0 +1,61 @@
package cn.wanxiang.game.module.project.dal.dataobject.version;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 游戏版本 DO对应表 game_versionGameVersion 版本管理 + 草稿/发布黄金闭环 §3.3 C3 新增
*
* 继承 {@link TenantBaseDO}自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列
* version 权威归 project game_version 表归属一致Codex H6/H7
* - PackageFactory 落包后经 {@code ProjectVersionApi.createForPackage} 建版本status=2 预览就绪 gen_task_id 幂等
* - 发布编排{@code PublishOrchestrationService}发布成功后显式置 status=3已发布不依赖 runtime 回写
* 状态机校验在 Service 承载DO 层只承载数据
*
* @author 造梦AI
*/
@TableName("game_version")
@KeySequence("game_version_seq") // Oracle/PostgreSQL 等主键自增用MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
public class GameVersionDO extends TenantBaseDO {
/**
* 版本 ID= GamePackage.versionId
*/
private Long id;
/**
* 所属游戏 IDgame_project.id
*/
private Long gameId;
/**
* 版本号同游戏内递增
*/
private Integer versionNo;
/**
* 生成任务 IDaigc taskId落包建版本幂等键 gen_task_id 复用已建版本不重复建
*/
private String genTaskId;
/**
* GamePackagemanifestOSS URLMVP OSS 包存 DB退场契约 §3.4为空落空串
*/
private String packageUrl;
/**
* 包总字节编译期门禁
*/
private Long bundleSize;
/**
* 整包 sha256hexManifest 完整性校验 manifest 端点原样响应字节同源计算 §3.4 C4
*/
private String checksum;
/**
* 版本状态0草稿 1已编译 2预览就绪 3已发布
*
* 落包建版本写 2预览就绪发布编排成功后由本编排显式置 3已发布§3.2 C2 步骤不依赖 runtime TODOCodex H6
*/
private Integer status;
}

View File

@ -70,4 +70,18 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.eq(ProjectDO::getId, id)); // 命中主键 .eq(ProjectDO::getId, id)); // 命中主键
} }
/**
* 按游戏 ID 查项目状态机值§3.1/§3.2 可见态 enforce跨模块 Feignfeed 读写双侧校验
*
* 只取 status 单列命中主键id显式列不裸 select*不加载整行最小化跨模块查询开销
*
* @param id 游戏项目 ID主键
* @return 仅含 status DO游戏不存在返回 null
*/
default ProjectDO selectStatusById(Long id) {
return selectOne(new LambdaQueryWrapperX<ProjectDO>()
.select(ProjectDO::getStatus) // 显式只查状态机所需列
.eq(ProjectDO::getId, id)); // 命中主键
}
} }

View File

@ -0,0 +1,29 @@
package cn.wanxiang.game.module.project.dal.mysql.version;
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
/**
* 游戏版本 Mapper黄金闭环 §3.3 C3 新增
*
* @author 造梦AI
*/
@Mapper
public interface GameVersionMapper extends BaseMapperX<GameVersionDO> {
/**
* 按生成任务 ID 取已建版本落包建版本幂等 gen_task_id 复用不重复建
*
* 命中 idx_game_version 不直接适用 gen_task_id 过滤取一行同任务对应一版本业务量级下行数有限
*
* @param genTaskId 生成任务 IDaigc taskId
* @return 已建版本不存在返回 null
*/
default GameVersionDO selectByGenTaskId(String genTaskId) {
return selectOne(new LambdaQueryWrapperX<GameVersionDO>()
.eq(GameVersionDO::getGenTaskId, genTaskId));
}
}

View File

@ -103,6 +103,16 @@ public interface ProjectService {
*/ */
Long getCurrentVersionId(Long gameId); Long getCurrentVersionId(Long gameId);
/**
* 按游戏 ID 反查项目状态机值§3.1/§3.2 可见态 enforce feed 跨模块 Feign 读写双侧校验
*
* 只读显式只查 status 单列命中主键游戏不存在返回 null由调用方处理 feed 写侧视为不满足可见态拒写
*
* @param gameId 游戏项目 IDgame_project.id
* @return 项目状态机值游戏不存在返回 null
*/
Integer getStatus(Long gameId);
/** /**
* 封禁联动下架R3 compliance 跨模块调用 * 封禁联动下架R3 compliance 跨模块调用
* *

View File

@ -15,6 +15,7 @@ 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.dal.mysql.review.ReviewRecordMapper;
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum; import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
import cn.wanxiang.game.module.project.enums.ReviewDecisionEnum; import cn.wanxiang.game.module.project.enums.ReviewDecisionEnum;
import cn.wanxiang.game.module.project.service.publish.PublishOrchestrationService;
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi; import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO; import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
import cn.wanxiang.game.module.compliance.dto.GateVerdict; import cn.wanxiang.game.module.compliance.dto.GateVerdict;
@ -62,6 +63,13 @@ public class ProjectServiceImpl implements ProjectService {
@Resource @Resource
private ComplianceGateApi complianceGateApi; private ComplianceGateApi complianceGateApi;
/**
* 发布编排 Service§3.2 C2α auto-publish
* reviewProject(APPROVE) 在同一本地事务内委托其串联校验包翻包翻版本态翻项目态 PUBLISHED feed 基线任一步失败整体回滚
*/
@Resource
private PublishOrchestrationService publishOrchestrationService;
@Override @Override
public PageResult<ProjectDO> getMyProjectPage(ProjectPageReqVO reqVO, Long userId) { public PageResult<ProjectDO> getMyProjectPage(ProjectPageReqVO reqVO, Long userId) {
return projectMapper.selectMyPage(reqVO, userId); return projectMapper.selectMyPage(reqVO, userId);
@ -181,28 +189,37 @@ public class ProjectServiceImpl implements ProjectService {
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录需同事务 @Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录 +APPROVE发布编排需同一本地事务§3.2 C2 事务原子
public void reviewProject(ReviewReqVO reqVO, Long reviewerUserId) { public void reviewProject(ReviewReqVO reqVO, Long reviewerUserId) {
ProjectDO project = projectMapper.selectById(reqVO.getGameId()); ProjectDO project = projectMapper.selectById(reqVO.getGameId());
if (project == null) { if (project == null) {
throw exception(PROJECT_NOT_EXISTS); throw exception(PROJECT_NOT_EXISTS);
} }
// 决策 目标状态含合法流转校验 boolean isApprove = Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision());
// 决策 目标状态含合法流转校验APPROVE 仍校验仅审核中可通过 α 下项目态不停留 APPROVED(2)由发布编排直翻 PUBLISHED(4)
Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus()); Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus());
// 更新项目状态 if (!isApprove) {
ProjectDO update = new ProjectDO(); // REJECT/UNLIST 分支不变直接写目标状态REJECTED(3) / UNLISTED(5)
update.setId(project.getId()); ProjectDO update = new ProjectDO();
update.setStatus(targetStatus); update.setId(project.getId());
projectMapper.updateById(update); update.setStatus(targetStatus);
// 落审核记录锁风门 Gate 二态通过=pass拒绝/下架=block projectMapper.updateById(update);
}
// 落审核记录锁风门 Gate 二态通过=pass拒绝/下架=block
// D-PUB=αAPPROVE 先落 APPROVED 审核记录decision 仍为通过gate=pass项目态不停留 APPROVED ProjectStatusEnum 注释
ReviewRecordDO record = new ReviewRecordDO(); ReviewRecordDO record = new ReviewRecordDO();
record.setGameId(reqVO.getGameId()); record.setGameId(reqVO.getGameId());
record.setVersionId(reqVO.getVersionId()); record.setVersionId(reqVO.getVersionId());
record.setReviewerUserId(reviewerUserId); record.setReviewerUserId(reviewerUserId);
record.setDecision(reqVO.getDecision()); record.setDecision(reqVO.getDecision());
record.setGateResult(Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision()) ? "pass" : "block"); record.setGateResult(isApprove ? "pass" : "block");
record.setReason(reqVO.getReason()); record.setReason(reqVO.getReason());
reviewRecordMapper.insert(record); reviewRecordMapper.insert(record);
// D-PUB=α§1/§3.2 C2审核通过即自动发布在同一本地事务内调发布编排使项目 REVIEWING(1)PUBLISHED(4)跳过常驻 APPROVED(2)
// 编排注入 RuntimePackageApi/FeedApi @Primary 本地 bean同进程 Feign任一步失败整体回滚审核记录与状态同回滚
if (isApprove) {
publishOrchestrationService.publish(project);
}
} }
@Override @Override
@ -219,6 +236,13 @@ public class ProjectServiceImpl implements ProjectService {
return project == null ? null : project.getCurrentVersionId(); return project == null ? null : project.getCurrentVersionId();
} }
@Override
public Integer getStatus(Long gameId) {
// §3.1/§3.2 可见态 enforce 只读显式只查 status 单列命中主键游戏不存在返回 nullfeed 写侧据此拒写读侧据此过滤
ProjectDO project = projectMapper.selectStatusById(gameId);
return project == null ? null : project.getStatus();
}
@Override @Override
public boolean unlistIfPublished(Long gameId) { public boolean unlistIfPublished(Long gameId) {
// R3 封禁联动下架状态查询 + 下架决策均收敛在 project 权威侧复用既有 reviewProject 状态机不重写=D2 // R3 封禁联动下架状态查询 + 下架决策均收敛在 project 权威侧复用既有 reviewProject 状态机不重写=D2

View File

@ -0,0 +1,26 @@
package cn.wanxiang.game.module.project.service.publish;
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
/**
* 发布编排 Service 接口黄金闭环 §3.2 C2 核心新增
*
* 职责发布串成一个本地事务原子操作校验包就绪 翻包 01 翻版本态 3 项目态 PUBLISHED(4) feed 发布基线
* 触发点α auto-publishD-PUB §1人工 {@code ProjectServiceImpl.reviewProject(APPROVE)} 在同一事务内调用本编排
*
* 事务原子硬约束Codex H5B1 必守本编排注入 FeedApi/RuntimePackageApi @Primary 本地 bean
* 在调用方reviewProject @Transactional同一本地事务内串联事务内禁止 Feign/HTTP/@Async/REQUIRES_NEW
* 任一步抛错则 project/package/version/feed 全回滚含审核记录保证发布要么全成要么全不成
*
* @author 造梦AI
*/
public interface PublishOrchestrationService {
/**
* 执行发布编排在调用方本地事务内串联§3.2 C2 六步
*
* @param project 待发布项目 DO须已查出提供 id/currentVersionId/launchZoneId
*/
void publish(ProjectDO project);
}

View File

@ -0,0 +1,102 @@
package cn.wanxiang.game.module.project.service.publish;
import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO;
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
import cn.wanxiang.game.module.project.service.version.GameVersionService;
import cn.wanxiang.game.module.feed.api.FeedApi;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.wanxiang.game.module.runtime.api.RuntimePackageApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static cn.wanxiang.game.module.project.enums.ErrorCodeConstants.PROJECT_PUBLISH_PACKAGE_NOT_READY;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 发布编排 Service 实现黄金闭环 §3.2 C2 核心新增
*
* 事务原子硬约束Codex H5本类注入的 {@link FeedApi}/{@link RuntimePackageApi} MVP 单体内由对方模块的
* @RestController @Primary 本地实现就地解析同进程方法调用非真实 Feign/HTTP故能与调用方reviewProject
* @Transactional落在同一本地事务本类方法标 @Transactional(REQUIRED 默认传播与调用方事务合并
* 事务内禁止 @Async/REQUIRES_NEW任一步抛错则全回滚
*
* @author 造梦AI
*/
@Slf4j
@Service
public class PublishOrchestrationServiceImpl implements PublishOrchestrationService {
/** 运行包状态:预览就绪(发布前置态,仅此态可被发布翻 0→1 */
private static final int PACKAGE_STATUS_PREVIEW_READY = 0;
/** 发布基线默认专区0 主混合流(项目未设 launch_zone_id 时回落) */
private static final long DEFAULT_LAUNCH_ZONE_ID = 0L;
@Resource
private ProjectMapper projectMapper;
@Resource
private GameVersionService gameVersionService;
/**
* runtime 发布翻包 seam仅依赖 -apiMVP 单体由 runtime 模块 RuntimePackageApiImpl(@RestController @Primary) 就地解析
* 事务内本地调用 Feign抛错则发布事务整体回滚
*/
@Resource
private RuntimePackageApi runtimePackageApi;
/**
* feed 写排序 seam仅依赖 -apiMVP 单体由 feed 模块 FeedApiImpl(@RestController @Primary) 就地解析
* 事务内本地调用 Feign抛错则发布事务整体回滚故障注入测试feed upsert 抛错 project/package/version 全回滚
*/
@Resource
private FeedApi feedApi;
@Override
@Transactional(rollbackFor = Exception.class) // 与调用方 reviewProject 事务合并REQUIRED六步任一失败全回滚
public void publish(ProjectDO project) {
Long versionId = project.getCurrentVersionId();
log.info("[publish] 发布编排开始 gameId={}, currentVersionId={}", project.getId(), versionId);
// 校验当前生效版本运行包就绪status==0 预览就绪currentVersionId 缺失或运行包未就绪抛错回滚审核不落
// RuntimePackageApi 本地 bean game_runtime_package(versionId).status无就绪运行包 data null
Integer packageStatus = versionId == null ? null
: runtimePackageApi.getStatus(versionId).getCheckedData();
if (packageStatus == null || packageStatus != PACKAGE_STATUS_PREVIEW_READY) {
log.warn("[publish] 运行包未就绪,拒绝发布并回滚 gameId={}, versionId={}, packageStatus={}",
project.getId(), versionId, packageStatus);
throw exception(PROJECT_PUBLISH_PACKAGE_NOT_READY);
}
// 翻包 01 已发布复用 runtime publishPackage 逻辑version/project 回写不在 runtime归本编排Codex H6
runtimePackageApi.publish(versionId);
// 翻版本态 game_version(versionId).status=3 已发布版本权威归 project本编排显式写不依赖 runtime TODO
gameVersionService.markPublished(versionId);
// 翻项目态 project.status=PUBLISHED(4)全仓首个 PUBLISHED 写入点
ProjectDO update = new ProjectDO();
update.setId(project.getId());
update.setStatus(ProjectStatusEnum.PUBLISHED.getStatus());
projectMapper.updateById(update);
// feed 发布基线mode=PUBLISH_BASELINE §3.5 算分回灌 QUALITY_REFRESH 分流
// zone_id=launch_zone_id 0quality_score/boost/sort_score=0status=1 入流pinned=0
FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO();
req.setGameId(project.getId());
req.setVersionId(versionId);
req.setZoneId(project.getLaunchZoneId() == null ? DEFAULT_LAUNCH_ZONE_ID : project.getLaunchZoneId());
req.setQualityScore(0D);
req.setBoost(0D);
req.setSortScore(0D);
req.setStatus(1);
req.setPinned(0);
req.setMode(FeedRankUpsertReqDTO.UpsertMode.PUBLISH_BASELINE);
feedApi.upsertRank(req);
log.info("[publish] 发布编排完成 gameId={}, versionId={}, zoneId={}", project.getId(), versionId, req.getZoneId());
}
}

View File

@ -0,0 +1,44 @@
package cn.wanxiang.game.module.project.service.version;
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
/**
* 游戏版本 Service 接口黄金闭环 §3.3 C3 新增
*
* 承载落包建版本PackageFactory project genTaskId 幂等发布编排显式翻版本态status=3
* version 权威归 projectCodex H6/H7版本由 project runtime 不建版本不回写版本态
*
* @author 造梦AI
*/
public interface GameVersionService {
/**
* 落包建版本PackageFactory 落包后创建/复用 game_version返回 versionId
*
* 创建态 status=2预览就绪 req.genTaskId 幂等同任务复用已建版本不重复建避免重复落包重复建版本
* 不写 current_version_id仍由创作者 submitPublish 写入本方法只建版本不动当前生效版本
*
* @param req 建版本入参gameId/genTaskId/packageUrl 可空/bundleSize/checksum
* @return 新建或复用的版本 ID
*/
Long createForPackage(ProjectVersionCreateForPackageReqDTO req);
/**
* 取版本 DO发布编排取当前生效版本以做版本态流转
*
* @param versionId 版本 ID
* @return 版本 DO不存在返回 null
*/
GameVersionDO getVersion(Long versionId);
/**
* 置版本为已发布status=3发布编排步骤显式写§3.2 C2 / Codex H6
*
* {@code PublishOrchestrationService} 在发布本地事务内调用不依赖 runtime 回写
*
* @param versionId 版本 ID
*/
void markPublished(Long versionId);
}

View File

@ -0,0 +1,73 @@
package cn.wanxiang.game.module.project.service.version;
import cn.wanxiang.game.module.project.dal.dataobject.version.GameVersionDO;
import cn.wanxiang.game.module.project.dal.mysql.version.GameVersionMapper;
import cn.wanxiang.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* 游戏版本 Service 实现黄金闭环 §3.3 C3 新增
*
* 业务规则落包建版本幂等版本态流转在此承载可单测可追溯
*
* @author 造梦AI
*/
@Slf4j
@Service
public class GameVersionServiceImpl implements GameVersionService {
/** 版本状态预览就绪落包建版本态scene=preview 可取包) */
private static final int STATUS_PREVIEW_READY = 2;
/** 版本状态:已发布(发布编排成功后置位) */
private static final int STATUS_PUBLISHED = 3;
/** 落包建版本默认版本号MVP 单版本场景;多版本递增留后续) */
private static final int DEFAULT_VERSION_NO = 1;
@Resource
private GameVersionMapper gameVersionMapper;
@Override
public Long createForPackage(ProjectVersionCreateForPackageReqDTO req) {
// 幂等 genTaskId 已建版本则直接复用不重复建PackageFactory 重复落包安全
if (StringUtils.hasText(req.getGenTaskId())) {
GameVersionDO existing = gameVersionMapper.selectByGenTaskId(req.getGenTaskId());
if (existing != null) {
log.info("[createForPackage] 落包建版本幂等命中,复用已建版本 genTaskId={}, versionId={}",
req.getGenTaskId(), existing.getId());
return existing.getId();
}
}
// 新建版本status=2 预览就绪package_url 可空MVP OSS 包存 DB退场契约 §3.4落空串
GameVersionDO version = new GameVersionDO();
version.setGameId(req.getGameId());
version.setVersionNo(DEFAULT_VERSION_NO);
version.setGenTaskId(req.getGenTaskId());
version.setPackageUrl(StringUtils.hasText(req.getPackageUrl()) ? req.getPackageUrl() : "");
version.setBundleSize(req.getBundleSize() == null ? 0L : req.getBundleSize());
version.setChecksum(StringUtils.hasText(req.getChecksum()) ? req.getChecksum() : "");
version.setStatus(STATUS_PREVIEW_READY);
gameVersionMapper.insert(version);
log.info("[createForPackage] 落包建版本成功 gameId={}, genTaskId={}, versionId={}",
req.getGameId(), req.getGenTaskId(), version.getId());
return version.getId();
}
@Override
public GameVersionDO getVersion(Long versionId) {
return gameVersionMapper.selectById(versionId);
}
@Override
public void markPublished(Long versionId) {
// 发布编排步骤§3.2 C2 / Codex H6版本权威归 project由本编排显式置已发布不依赖 runtime 回写
GameVersionDO update = new GameVersionDO();
update.setId(versionId);
update.setStatus(STATUS_PUBLISHED);
gameVersionMapper.updateById(update);
log.info("[markPublished] 版本已置已发布 versionId={}", versionId);
}
}

View File

@ -10,6 +10,7 @@ import cn.wanxiang.game.module.project.dal.dataobject.review.ReviewRecordDO;
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper; 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.dal.mysql.review.ReviewRecordMapper;
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum; import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
import cn.wanxiang.game.module.project.service.publish.PublishOrchestrationService;
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi; import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
import cn.wanxiang.game.module.compliance.dto.GateVerdict; import cn.wanxiang.game.module.compliance.dto.GateVerdict;
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
@ -45,6 +46,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
private ReviewRecordMapper reviewRecordMapper; private ReviewRecordMapper reviewRecordMapper;
@Mock @Mock
private ComplianceGateApi complianceGateApi; // R1 合规锁风门发布前注入 private ComplianceGateApi complianceGateApi; // R1 合规锁风门发布前注入
@Mock
private PublishOrchestrationService publishOrchestrationService; // §3.2 C2 发布编排α auto-publishAPPROVE 时调用
// ============================== createProject ============================== // ============================== createProject ==============================
@ -218,22 +221,23 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
// ============================== reviewProject 流转 ============================== // ============================== reviewProject 流转 ==============================
@Test @Test
void testReviewProject_approveFromReviewing() { void testReviewProject_approveFromReviewing_autoPublish() {
// D-PUB=αAPPROVE 不直接写 APPROVED(2)而是落 pass 审核记录 + 调发布编排编排内写 PUBLISHED本测试 mock 编排
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus());
when(projectMapper.selectById(1L)).thenReturn(project); when(projectMapper.selectById(1L)).thenReturn(project);
ReviewReqVO reqVO = reviewReq(1L, 1); // 1=通过 ReviewReqVO reqVO = reviewReq(1L, 1); // 1=通过
projectService.reviewProject(reqVO, 7L); projectService.reviewProject(reqVO, 7L);
// 项目状态 已通过
ArgumentCaptor<ProjectDO> captor = ArgumentCaptor.forClass(ProjectDO.class);
verify(projectMapper).updateById(captor.capture());
assertEquals(ProjectStatusEnum.APPROVED.getStatus(), captor.getValue().getStatus());
// 落审核记录pass // 落审核记录pass
ArgumentCaptor<ReviewRecordDO> rc = ArgumentCaptor.forClass(ReviewRecordDO.class); ArgumentCaptor<ReviewRecordDO> rc = ArgumentCaptor.forClass(ReviewRecordDO.class);
verify(reviewRecordMapper).insert(rc.capture()); verify(reviewRecordMapper).insert(rc.capture());
assertEquals("pass", rc.getValue().getGateResult()); assertEquals("pass", rc.getValue().getGateResult());
assertEquals(7L, rc.getValue().getReviewerUserId()); assertEquals(7L, rc.getValue().getReviewerUserId());
// α在同一事务内委托发布编排项目态 PUBLISHED 由编排承载
verify(publishOrchestrationService).publish(project);
// APPROVE 分支不再由本方法直接写项目状态不调 projectMapper.updateByIdPUBLISHED 写入在编排内 mock
verify(projectMapper, never()).updateById(any(ProjectDO.class));
} }
@Test @Test

View File

@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@ -43,4 +44,19 @@ public interface RuntimePackageApi {
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024")
CommonResult<Boolean> publish(@RequestParam("versionId") Long versionId); CommonResult<Boolean> publish(@RequestParam("versionId") Long versionId);
/**
* 按版本 ID 反查运行包状态机值§3.1 C1 可见态第三条件feed 写侧 PUBLISH_BASELINE enforce 依赖
*
* 用途feed 写侧 mode=PUBLISH_BASELINE upsert 前校验 game_runtime_package(versionId).status==1已发布不满足拒写
* 委托 {@code RuntimePackageService}显式只查 status该版本无就绪运行包时 data 返回 nullfeed 写侧视为不满足可见态拒写
* 状态值语义见 {@link cn.wanxiang.game.module.runtime.enums.PackageStatusEnum}0预览就绪 1已发布 2已失效
*
* @param versionId 版本 IDproject.game_version.id
* @return 运行包状态机值CommonResult 包裹无就绪运行包为 null
*/
@GetMapping(PREFIX + "/get-status")
@Operation(summary = "按版本 ID 反查运行包状态机值(可见态第三条件 enforce§3.1")
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024")
CommonResult<Integer> getStatus(@RequestParam("versionId") Long versionId);
} }

View File

@ -0,0 +1,41 @@
package cn.wanxiang.game.module.runtime.api;
import cn.wanxiang.game.module.runtime.service.pkg.RuntimePackageService;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 运行包发布 API 实现黄金闭环 §3.2 C2提供 RESTful 接口给跨模块 Feign 调用发布编排翻包 + 可见态校验
*
* @RestController + @Primary yudao DictDataApiImpl / 既有 ProjectApiImpl 同构单体内同进程调用走本实现拆微服务后走 Feign守门/§3.2 H5
* 仅委托 {@link RuntimePackageService}publish 翻包 01不在此回写 version/project那归 project 编排Codex H6getStatus feed 写侧可见态 enforce
*
* @author 造梦AI
*/
@RestController // 提供 RESTful API 接口 Feign 调用
@Validated
@Primary // @FeignClient 接口同名 Bean 冲突时优先用本地实现同进程调用就地解析发布编排事务内本地调用
public class RuntimePackageApiImpl implements RuntimePackageApi {
@Resource
private RuntimePackageService runtimePackageService;
@Override
public CommonResult<Boolean> publish(Long versionId) {
// 发布翻包委托 RuntimePackageService.publishPackage01 已发布幂等未就绪抛 RUNTIME_PACKAGE_NOT_READY
runtimePackageService.publishPackage(versionId);
return success(Boolean.TRUE);
}
@Override
public CommonResult<Integer> getStatus(Long versionId) {
// §3.1 C1 可见态第三条件返回 game_runtime_package(versionId).status无就绪运行包返回 nullfeed 写侧据此拒写
return success(runtimePackageService.getPackageStatus(versionId));
}
}

View File

@ -9,6 +9,7 @@ import cn.wanxiang.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
import cn.wanxiang.game.module.runtime.dal.dataobject.session.RuntimeSessionDO; import cn.wanxiang.game.module.runtime.dal.dataobject.session.RuntimeSessionDO;
import cn.wanxiang.game.module.runtime.enums.RuntimeSceneEnum; import cn.wanxiang.game.module.runtime.enums.RuntimeSceneEnum;
import cn.wanxiang.game.module.runtime.service.pkg.RuntimePackageService; import cn.wanxiang.game.module.runtime.service.pkg.RuntimePackageService;
import cn.wanxiang.game.module.runtime.service.pkg.store.PackageStore;
import cn.wanxiang.game.module.runtime.service.session.RuntimeSessionService; import cn.wanxiang.game.module.runtime.service.session.RuntimeSessionService;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@ -18,9 +19,13 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import static cn.wanxiang.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/** /**
@ -43,6 +48,12 @@ public class AppRuntimeController {
@Resource @Resource
private RuntimeSessionService runtimeSessionService; private RuntimeSessionService runtimeSessionService;
/**
* 整包 manifest 存储抽象§3.4 C4MVP=DbPackageStore game_runtime_package.package_jsonM3 OSS impl 调用方不变
*/
@Resource
private PackageStore packageStore;
@GetMapping("/package/{versionId}") @GetMapping("/package/{versionId}")
@Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15") @Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15")
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048")
@ -56,6 +67,28 @@ public class AppRuntimeController {
return success(RuntimeConvert.toPackageRespVO(pkg)); return success(RuntimeConvert.toPackageRespVO(pkg));
} }
@GetMapping(value = "/package/{versionId}/manifest", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "取版本运行包 manifest 原始 JSON",
description = "§3.4 C4返回 manifest 原始 JSON 文本,不包 CommonResult、不 parse/re-serialize宿主对响应文本算 sha256 与 checksum 严格比对后注入运行容器")
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048")
@Parameter(name = "scene", description = "preview 创作者预览 / play 玩家试玩", example = "play")
public String getPackageManifestRaw(
@PathVariable("versionId") Long versionId,
@RequestParam(value = "scene", required = false, defaultValue = "play") String scene) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
// 取包门禁与 GET /package/{versionId} 同源复用 getPackageManifest scene+status 权威判定 + 预览归属
// 未就绪/未发布/越权预览由 Service 抛对应错误码经全局异常处理返回 CommonResult JSON与取包端点一致
runtimePackageService.getPackageManifest(versionId, scene, userId);
// PackageStore manifest 原始 JSON返回类型为 String GlobalResponseBodyHandler 不拦截只拦 CommonResult
// StringHttpMessageConverter 原样写出字节不重序列化保字节一致性§3.4 C4
String manifest = packageStore.getManifest(versionId);
if (!StringUtils.hasText(manifest)) {
// 门禁通过但 manifest 未落库PackageFactory 未写 package_json视为运行包未就绪与取包统一错误码
throw exception(RUNTIME_PACKAGE_NOT_READY);
}
return manifest;
}
@PostMapping("/session/start") @PostMapping("/session/start")
@Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用clientPlayToken 幂等") @Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用clientPlayToken 幂等")
public CommonResult<SessionStartRespVO> startSession(@Valid @RequestBody SessionStartReqVO reqVO) { public CommonResult<SessionStartRespVO> startSession(@Valid @RequestBody SessionStartReqVO reqVO) {

View File

@ -41,9 +41,11 @@ public class RuntimeConvert {
} }
// 平铺字段gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷 // 平铺字段gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷
RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class); RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class);
// GAP-2 分支A纯派生 DDLmanifestUrl packageUrl 同前缀派生 .../manifest.json // manifestUrl 解析§3.4 C4 退场契约
// 宿主据此先 fetch manifest 并按 checksum sha256 完整性校验通过后再注入运行容器T-RT-15 // - MVP OSSpackageUrl 整包存 DB指向 manifest 端点 /app-api/runtime/package/{versionId}/manifest
vo.setManifestUrl(deriveManifestUrl(pkg.getPackageUrl())); // 宿主 fetch 该端点取原始 JSON对响应文本算 sha256 checksum 比对后注入相对 URL 由前端 resolve API base§2.5
// - M3 OSSpackageUrl 非空回落 GAP-2 分支A packageUrl 同前缀派生 .../manifest.jsonOSS/CDN 版本化 URL
vo.setManifestUrl(resolveManifestUrl(pkg));
// 沙箱策略单独组装sandboxAttr 直拷allowOrigins 逗号串拆 List // 沙箱策略单独组装sandboxAttr 直拷allowOrigins 逗号串拆 List
SandboxPolicyVO sandbox = new SandboxPolicyVO(); SandboxPolicyVO sandbox = new SandboxPolicyVO();
sandbox.setSandboxAttr(pkg.getSandboxAttr()); sandbox.setSandboxAttr(pkg.getSandboxAttr());
@ -52,6 +54,21 @@ public class RuntimeConvert {
return vo; return vo;
} }
/**
* 解析 manifestUrl§3.4 C4 退场契约MVP DB 端点 / M3 OSS 派生
*
* @param pkg 运行包 DO packageUrl MVP/OSSversionId 拼端点路径
* @return manifestUrlpackageUrl manifest 端点相对路径非空 同前缀派生 .json
*/
public static String resolveManifestUrl(RuntimePackageDO pkg) {
if (!StringUtils.hasText(pkg.getPackageUrl())) {
// MVP OSS整包存 DBmanifestUrl 指向原样服务端点相对路径前端 resolve API base
return "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest";
}
// M3 OSS保留 GAP-2 分支A 派生
return deriveManifestUrl(pkg.getPackageUrl());
}
/** /**
* packageUrl 同前缀派生 manifest.json 取包清单 URLGAP-2 分支A纯派生不增列 * packageUrl 同前缀派生 manifest.json 取包清单 URLGAP-2 分支A纯派生不增列
* *

View File

@ -79,5 +79,13 @@ public class RuntimePackageDO extends TenantBaseDO {
* 01 publish 对接点回写并同步置 project.game_version.status=3 * 01 publish 对接点回写并同步置 project.game_version.status=3
*/ */
private Integer status; private Integer status;
/**
* 整包 manifest 原始 JSON黄金闭环 §3.4 C4V10 新增 package_json LONGTEXT
*
* MVP OSS整包存 DB {@code PackageStore} DB impl 读写本列M3 OSS 后下线退场契约
* manifest 端点 GET /app-api/runtime/package/{versionId}/manifest PackageStore 读出原样返回
* 不包 CommonResult parse/re-serialize任何重序列化都破坏字节一致性导致宿主 sha256 校验失败
*/
private String packageJson;
} }

View File

@ -38,4 +38,15 @@ public interface RuntimePackageService {
*/ */
void publishPackage(Long versionId); void publishPackage(Long versionId);
/**
* 取版本运行包状态机值§3.1 C1 可见态第三条件 feed 写侧 PUBLISH_BASELINE enforce
*
* 只读显式取 game_runtime_package(versionId).status该版本无就绪运行包返回 nullfeed 写侧据此视为不满足可见态拒写
* 状态值语义见 {@link cn.wanxiang.game.module.runtime.enums.PackageStatusEnum}
*
* @param versionId 版本 ID
* @return 运行包状态机值无就绪运行包返回 null
*/
Integer getPackageStatus(Long versionId);
} }

View File

@ -74,13 +74,19 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
update.setId(pkg.getId()); update.setId(pkg.getId());
update.setStatus(PackageStatusEnum.PUBLISHED.getStatus()); update.setStatus(PackageStatusEnum.PUBLISHED.getStatus());
runtimePackageMapper.updateById(update); runtimePackageMapper.updateById(update);
// TODO 对接点project 发布编排回写发布态写入链路最后一跳回写 project.game_version.status=3已发布 // 黄金闭环 §3.2 C2 / Codex H6version/project 回写不在此发布态写入链路由 project PublishOrchestrationService 承载
// project 模块 -apiFeignProjectVersionApi#updateVersionStatusToPublished(versionId)就绪后在此补全 // 其在同一本地事务内先调本方法翻包 01再显式置 game_version.status=3 project.status=PUBLISHED(4)本模块只负责翻包态
// 注意跨模块远程调用须放在本地事务提交之后并做超时/失败补偿重试或运营告警 log.info("[publishPackage] 运行包已置已发布 versionId={}, pkgId={}version/project 回写归 project 发布编排)",
log.info("[publishPackage] 运行包已置已发布 versionId={}, pkgId={}project.game_version 回写为 TODO 对接点)",
versionId, pkg.getId()); versionId, pkg.getId());
} }
@Override
public Integer getPackageStatus(Long versionId) {
// §3.1 C1 可见态第三条件只读取该版本运行包 status无就绪运行包返回 nullfeed 写侧据此拒写
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
return pkg == null ? null : pkg.getStatus();
}
/** /**
* 预览归属校验仅版本 owner创作者本人可预览未发布运行包 * 预览归属校验仅版本 owner创作者本人可预览未发布运行包
* *

View File

@ -0,0 +1,47 @@
package cn.wanxiang.game.module.runtime.service.pkg.store;
import cn.wanxiang.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
import cn.wanxiang.game.module.runtime.dal.mysql.pkg.RuntimePackageMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 整包 manifest 存储 DB 实现黄金闭环 §3.4 C4MVP OSS读写 game_runtime_package.package_json
*
* 退场契约M3 OSS 后新增 OSS impl 并切换 @Primary本类下线{@link PackageStore} 调用方不变
*
* @author 造梦AI
*/
@Slf4j
@Component
public class DbPackageStore implements PackageStore {
@Resource
private RuntimePackageMapper runtimePackageMapper;
@Override
public void putManifest(Long versionId, String json) {
// 落包写入 versionId 定位运行包行 package_json 原始 JSON parse/re-serialize保字节一致 §3.4 C4
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
if (pkg == null) {
// 防御无运行包行无法落 manifestPackageFactory 应先建 game_runtime_package 再写 manifest
log.warn("[putManifest] 无对应运行包,跳过写入 manifest versionId={}", versionId);
return;
}
RuntimePackageDO update = new RuntimePackageDO();
update.setId(pkg.getId());
update.setPackageJson(json);
runtimePackageMapper.updateById(update);
log.info("[putManifest] manifest 已写入 DB versionId={}, pkgId={}, jsonLen={}",
versionId, pkg.getId(), json == null ? 0 : json.length());
}
@Override
public String getManifest(Long versionId) {
// 服务读出取该版本运行包 package_json 原样字符串无运行包/未写入返回 null端点据此判 404/未就绪
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
return pkg == null ? null : pkg.getPackageJson();
}
}

View File

@ -0,0 +1,33 @@
package cn.wanxiang.game.module.runtime.service.pkg.store;
/**
* 整包 manifest 存储抽象黄金闭环 §3.4 C4 新增退场契约隔离层
*
* 目的整包 manifest 的存取介质与上层取包/服务逻辑解耦
* MVP impl={@link DbPackageStore}读写 game_runtime_package.package_json OSS
* M3 OSS 后换 OSS impl读写 OSS/CDN 版本化对象调用方/契约不变退场契约
*
* 字节一致性约束§3.4 C4putManifest 写入与 getManifest 读出必须是同一原始字节串
* 不得 parse/re-serialize/加换行manifest 端点据此原样返回宿主对响应文本算 sha256 checksum 严格比对
*
* @author 造梦AI
*/
public interface PackageStore {
/**
* 写入指定版本的整包 manifest 原始 JSONPackageFactory 落包时调用
*
* @param versionId 版本 ID定位 game_runtime_package
* @param json manifest 原始 JSON 字符串原样字节调用方据相同字节计算 checksum
*/
void putManifest(Long versionId, String json);
/**
* 读取指定版本的整包 manifest 原始 JSONmanifest 端点服务时调用
*
* @param versionId 版本 ID
* @return manifest 原始 JSON 字符串该版本无运行包或未写入 package_json 时返回 null
*/
String getManifest(Long versionId);
}

View File

@ -74,4 +74,21 @@ class RuntimeConvertTest {
assertNull(RuntimeConvert.toPackageRespVO(null)); assertNull(RuntimeConvert.toPackageRespVO(null));
} }
// ============================== §3.4 C4MVP OSS manifestUrl 指向 DB 端点 ==============================
@Test
void testToPackageRespVO_mvpEmptyPackageUrl_manifestUrlPointsToEndpoint() {
// MVP OSSpackageUrl 整包存 DBmanifestUrl 指向 /app-api/runtime/package/{versionId}/manifest 端点
RuntimePackageDO pkg = new RuntimePackageDO();
pkg.setGameId(1024L);
pkg.setVersionId(2048L);
pkg.setPackageUrl(""); // MVP包存 DBpackageUrl 为空
pkg.setSandboxAttr("allow-scripts");
pkg.setAllowOrigins("");
RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg);
assertEquals("/app-api/runtime/package/2048/manifest", vo.getManifestUrl());
}
} }

View File

@ -20,6 +20,10 @@ import java.util.Map;
@Data @Data
public class EnvelopeReqVO { public class EnvelopeReqVO {
@Schema(description = "事件实例 UUID必填每事件实例唯一幂等真身对应 #5 eventId / game_telemetry_event.event_id 的 uk_event_id 唯一键)。前端 crypto.randomUUID() 生成;重放同 eventId 不重复计数", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "eventId 不能为空")
private String eventId;
@Schema(description = "事件名(须在 #5 eventRegistry 登记,否则计 rejected", requiredMode = Schema.RequiredMode.REQUIRED, example = "game_play_start") @Schema(description = "事件名(须在 #5 eventRegistry 登记,否则计 rejected", requiredMode = Schema.RequiredMode.REQUIRED, example = "game_play_start")
@NotBlank(message = "事件名不能为空") @NotBlank(message = "事件名不能为空")
private String event; private String event;

View File

@ -26,6 +26,13 @@ public class TelemetryEventDO extends TenantBaseDO {
* 事件记录 ID * 事件记录 ID
*/ */
private Long id; private Long id;
/**
* 事件实例 UUID黄金闭环 §3.5 C5V10 新增 event_id幂等真身
*
* 对应表 uk_event_id 唯一键V10 起替换旧 uk_dedup前端 crypto.randomUUID() 生成逐事件实例全局唯一
* ingestBatch 真插入(affected>0)判定幂等 eventId 重复投递只落一条只累加一次彻底解决同毫秒同名误吞 + 重放重计数
*/
private String eventId;
/** /**
* 事件名须在 #5 events.schema.json eventRegistry 登记对应 {@link cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum} * 事件名须在 #5 events.schema.json eventRegistry 登记对应 {@link cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum}
*/ */

View File

@ -2,16 +2,31 @@ package cn.wanxiang.game.module.telemetry.dal.mysql.event;
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO; import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
/** /**
* 遥测原始事件 Mapper * 遥测原始事件 Mapper
* *
* 消费侧幂等落库的写入入口幂等去重依赖表 uk_dedup(trace_id, event, ts) * 黄金闭环 §3.5 C5 同步写幂等去重依赖表 uk_event_id(event_id)V10 起替换旧 uk_dedup
* 重复信封落库时的 INSERT IGNORE 语义在消费侧实现 MQ 对接点 * 消费侧同步写本地事务 event_id 预查 + 真插入判定幂等 eventId 重复投递只落一条只聚合一次
* *
* @author 绘境AI * @author 绘境AI
*/ */
@Mapper @Mapper
public interface TelemetryEventMapper extends BaseMapperX<TelemetryEventDO> { public interface TelemetryEventMapper extends BaseMapperX<TelemetryEventDO> {
/**
* 按事件实例 UUID 取原始事件§3.5 C5 幂等预查命中即已落库跳过累加避免重放重计数
*
* 命中 uk_event_id 唯一键同一信封重复投递时返回已落行消费侧据此判定非真插入不再累加
*
* @param eventId 事件实例 UUID
* @return 已落原始事件不存在返回 null
*/
default TelemetryEventDO selectByEventId(String eventId) {
return selectOne(new LambdaQueryWrapperX<TelemetryEventDO>()
.eq(TelemetryEventDO::getEventId, eventId));
}
} }

View File

@ -7,6 +7,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
/** /**
* 游戏维度聚合 Mapper看板数据源只读端 * 游戏维度聚合 Mapper看板数据源只读端
* *
@ -46,4 +48,19 @@ public interface GameStatMapper extends BaseMapperX<GameStatDO> {
.last("LIMIT 1")); .last("LIMIT 1"));
} }
/**
* 游戏×统计日 取聚合行黄金闭环 §3.5 C5 同步写累加命中 uk_game_date 唯一键
*
* 消费侧同步写在本地事务内据此做无则建首事件初值/ 有则行级累加 upsert
*
* @param gameId 游戏 ID
* @param statDate 统计日
* @return 聚合行不存在返回 null
*/
default GameStatDO selectByGameAndDate(Long gameId, LocalDate statDate) {
return selectOne(new LambdaQueryWrapperX<GameStatDO>()
.eq(GameStatDO::getGameId, gameId)
.eq(GameStatDO::getStatDate, statDate));
}
} }

View File

@ -3,22 +3,38 @@ package cn.wanxiang.game.module.telemetry.service.event;
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO; import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO;
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO; import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO;
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO; import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO;
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO;
import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper;
import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper;
import cn.wanxiang.game.module.telemetry.enums.EventProcessStatusEnum;
import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum; import cn.wanxiang.game.module.telemetry.enums.TelemetryEventEnum;
import cn.wanxiang.game.module.feed.api.FeedApi;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED; import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
/** /**
* 事件摄取 Service 实现上报通道 * 事件摄取 Service 实现上报通道黄金闭环 §3.5 C5 同步写本地事务最核心数据回路
* *
* 全部轻量校验规则批量上限/信封必填/事件名注册表在此承载可单测可追溯 * v1v2 关键改动HJ-EXEC-006 §3.5把原 dispatchToMq() 占位换成同步本地 @Transactional 落库 + 聚合 + 回灌
* MQ 投递为外部依赖本骨架留清晰中文 TODO 对接点 mock不实现 * 逐信封按 event_id INSERTuk_event_id 幂等 真插入(affected>0)才累加 game_telemetry_game_stat
* 落原始表与增量聚合在消费侧异步完成详见 mq.consumer 对接点说明 * quality_score FeedApi.upsertRank(QUALITY_REFRESH) 回灌 feed rank 置该事件 process_status=1 已聚合
* 任一步失败整体回滚事件不计MQ producer/consumer 仅留契约 + TODO不实现
* *
* @author 绘境AI * @author 绘境AI
*/ */
@ -29,16 +45,33 @@ public class EventIngestServiceImpl implements EventIngestService {
/** 单次批量上限(契约 maxItems=200超限整体 rejected前端分批重传 */ /** 单次批量上限(契约 maxItems=200超限整体 rejected前端分批重传 */
private static final int MAX_BATCH_SIZE = 200; private static final int MAX_BATCH_SIZE = 200;
/** quality_score 权重§3.5 口径):完玩率 60 + like 率 20 + share 率 20clamp[0,100] */
private static final BigDecimal W_COMPLETE = new BigDecimal("60");
private static final BigDecimal W_LIKE = new BigDecimal("20");
private static final BigDecimal W_SHARE = new BigDecimal("20");
private static final BigDecimal SCORE_MAX = new BigDecimal("100");
@Resource
private TelemetryEventMapper telemetryEventMapper;
@Resource
private GameStatMapper gameStatMapper;
/** /**
* TODO 对接点MQ 生产注入 RocketMQ 生产者投递 topic=telemetry-event * feed 算分回灌 seam仅依赖 -api§3.5 C5MVP 单体由 feed 模块 FeedApiImpl(@RestController @Primary) 就地解析
* message body = { envelope: EnvelopeReqVO }keys=traceIdtag=event|perforderly=false * 在本同步写 @Transactional 内本地调用 Feign抛错则整批回滚
* 投递失败本地落兜底表后定时补投不丢事件 yudao-spring-boot-starter-mq producer 就绪后注入 */
* contracts/api-schemas/telemetry.yaml#x-mq-contracts.producer @Resource
private FeedApi feedApi;
/*
* TODO 对接点MQ 生产§3.5 仅留契约+TODO 不实现"上报快速 ACK + 异步消费聚合"
* 注入 RocketMQ 生产者投递 topic=telemetry-event幂等键=eventId对应 uk_event_id消费侧复用本类同步写聚合逻辑
* contracts/api-schemas/telemetry.yaml#x-mq-contractsMVP 同步写已满足闭环不引入 MQ
*/ */
// @Resource
// private RocketMQTemplate rocketMQTemplate;
@Override @Override
@Transactional(rollbackFor = Exception.class) // §3.5 C5 同步写落库+聚合+回灌+置已聚合在同一本地事务任一步失败整批回滚
public EventBatchResultVO ingestBatch(EventBatchReqVO reqVO, Long userId) { public EventBatchResultVO ingestBatch(EventBatchReqVO reqVO, Long userId) {
// 批量上限超限整体拒绝统一错误码出口前端据此分批重传 // 批量上限超限整体拒绝统一错误码出口前端据此分批重传
if (reqVO.getBatch().size() > MAX_BATCH_SIZE) { if (reqVO.getBatch().size() > MAX_BATCH_SIZE) {
@ -49,18 +82,17 @@ public class EventIngestServiceImpl implements EventIngestService {
int accepted = 0; int accepted = 0;
int rejected = 0; int rejected = 0;
// 部分成功语义逐条轻量校验通过则投递 MQ失败计 rejected不中断整批 // 部分成功语义逐条轻量校验通过则同步写落库+聚合+回灌失败计 rejected不中断整批
for (EnvelopeReqVO envelope : reqVO.getBatch()) { for (EnvelopeReqVO envelope : reqVO.getBatch()) {
if (!isEnvelopeValid(envelope)) { if (!isEnvelopeValid(envelope)) {
rejected++; rejected++;
continue; continue;
} }
// 投递 MQ快速 ACK 后异步入库 + 聚合幂等键 = traceId+event+ts ingestOne(envelope, userId);
dispatchToMq(envelope, userId);
accepted++; accepted++;
} }
// 关键链路日志受理成功记 INFO含回执 traceId 与计数便于全链路排障 // 关键链路日志受理成功记 INFO含回执 traceId 与计数便于全链路排障
log.info("[ingestBatch] 批量受理完成 batchTraceId={}, accepted={}, rejected={}", batchTraceId, accepted, rejected); log.info("[ingestBatch] 批量同步写完成 batchTraceId={}, accepted={}, rejected={}", batchTraceId, accepted, rejected);
EventBatchResultVO result = new EventBatchResultVO(); EventBatchResultVO result = new EventBatchResultVO();
result.setAccepted(accepted); result.setAccepted(accepted);
@ -70,31 +102,176 @@ public class EventIngestServiceImpl implements EventIngestService {
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) // 兜底通道单条同步写 ingestBatch 同口径落库+聚合+回灌原子
public Boolean ingestBeacon(EnvelopeReqVO reqVO, Long userId) { public Boolean ingestBeacon(EnvelopeReqVO reqVO, Long userId) {
// 兜底通道页面卸载场景永远受理成功不阻塞前端卸载 // 兜底通道页面卸载场景校验失败不抛错返回受理成功不触发前端异常
// 校验失败也不抛错仅记日志/丢弃避免 sendBeacon 4xx 触发前端异常 // 校验通过则同步写若同步写抛错极少DB 故障由全局异常处理返回错误响应
if (isEnvelopeValid(reqVO)) { // sendBeacon fire-and-forget 不读响应不阻塞卸载事务该回滚仍回滚保证不脏写
dispatchToMq(reqVO, userId); if (!isEnvelopeValid(reqVO)) {
} else { log.warn("[ingestBeacon] 信封校验未通过,丢弃 eventId={}, event={}, traceId={}",
log.warn("[ingestBeacon] 信封校验未通过,丢弃 event={}, traceId={}", reqVO.getEvent(), reqVO.getTraceId()); reqVO == null ? null : reqVO.getEventId(),
reqVO == null ? null : reqVO.getEvent(),
reqVO == null ? null : reqVO.getTraceId());
return Boolean.TRUE;
} }
ingestOne(reqVO, userId);
return Boolean.TRUE; return Boolean.TRUE;
} }
// ============================== 私有校验/投递辅助 ============================== // ============================== 同步写核心§3.5 C5==============================
/** /**
* 信封轻量校验必填字段非空 + 事件名在注册表登记 * 单条信封同步写落原始事件eventId 幂等 仅真插入才累加 stat quality_score 回灌 feed 置已聚合
* *
* 必填字段event/schemaVersion/ts/traceId已由 VO @NotBlank/@NotNull Controller 层校验 * 调用前提处于 @Transactional ingestBatch / ingestOneTx 提供任一步抛错则整体回滚
* 此处再做一次"事件名注册表"业务校验未登记即视为 rejected与契约 rejected 语义一致 *
* @param envelope 单条信封已通过 isEnvelopeValid
* @param userId 当前登录用户 ID补全 user_id 匿名为 null
*/
private void ingestOne(EnvelopeReqVO envelope, Long userId) {
// 1) 幂等预查 eventId 已落库 非真插入直接跳过不重复累加杜绝重放重计数
if (telemetryEventMapper.selectByEventId(envelope.getEventId()) != null) {
log.info("[ingestOne] 幂等命中,跳过累加 eventId={}, event={}", envelope.getEventId(), envelope.getEvent());
return;
}
// 2) 真插入原始事件uk_event_id 为硬幂等后盾并发双插会抛唯一键冲突 事务回滚仍不重复计数
TelemetryEventDO eventDO = buildEventDO(envelope, userId);
telemetryEventMapper.insert(eventDO);
// 3) 仅当事件落到具体游戏game_id 非空才累加聚合否则只落事件置已聚合无聚合落点
Long gameId = parseLong(envelope.getContext() == null ? null : envelope.getContext().getGameId());
if (gameId != null) {
// 统计日以事件 ts毫秒所在系统日期归集 stat_date 按日聚合粒度一致
LocalDate statDate = Instant.ofEpochMilli(envelope.getTs()).atZone(ZoneId.systemDefault()).toLocalDate();
GameStatDO stat = accumulateStat(gameId, statDate, envelope);
// 4) quality_scoreDECIMAL5,2写回 stat
BigDecimal qualityScore = computeQualityScore(stat);
GameStatDO scoreUpdate = new GameStatDO();
scoreUpdate.setId(stat.getId());
scoreUpdate.setQualityScore(qualityScore);
gameStatMapper.updateById(scoreUpdate);
// 5) 回灌 feed rankQUALITY_REFRESHquality_score 精度转 DECIMAL10,4sort_score feed 侧据既有 boost 权威重算
FeedRankUpsertReqDTO feedReq = new FeedRankUpsertReqDTO();
feedReq.setGameId(gameId);
feedReq.setVersionId(parseLong(envelope.getContext().getVersionId()));
feedReq.setZoneId(0L); // 回灌默认混合流 zone=0与发布基线默认 zone 对齐
feedReq.setQualityScore(qualityScore.doubleValue()); // DECIMAL5,2 Doublefeed 侧落 DECIMAL10,4
feedReq.setSortScore(qualityScore.doubleValue()); // 占位feed QUALITY_REFRESH 据既有 boost 重算 sort_score保留 boost
feedReq.setMode(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH);
feedApi.upsertRank(feedReq);
}
// 6) 置该事件 process_status=1 已聚合 game_id 的事件同样置已聚合已落库无需再聚合
TelemetryEventDO statusUpdate = new TelemetryEventDO();
statusUpdate.setId(eventDO.getId());
statusUpdate.setProcessStatus(EventProcessStatusEnum.AGGREGATED.getStatus());
telemetryEventMapper.updateById(statusUpdate);
log.info("[ingestOne] 同步写完成 eventId={}, event={}, gameId={}", envelope.getEventId(), envelope.getEvent(), gameId);
}
/**
* (game_id, stat_date) 累加聚合行无则建首事件初值有则行级累加同事务内顺序累加可见前序写
*
* 按事件名映射计数增量§3.5 计数口径
* game_play_start play_countgame_play_end play_end_count(+completed_count if completed)+total_duration_ms
* like like_countshare share_count其余事件不增计数仅落原始事件
*
* @param gameId 游戏 ID
* @param statDate 统计日
* @param envelope 单条信封
* @return 累加后的聚合行 id供算分回写
*/
private GameStatDO accumulateStat(Long gameId, LocalDate statDate, EnvelopeReqVO envelope) {
String event = envelope.getEvent();
Map<String, Object> props = envelope.getProps();
// 计数增量
long dPlay = TelemetryEventEnum.GAME_PLAY_START.getEvent().equals(event) ? 1L : 0L;
long dPlayEnd = TelemetryEventEnum.GAME_PLAY_END.getEvent().equals(event) ? 1L : 0L;
long dCompleted = (dPlayEnd == 1L && isCompleted(props)) ? 1L : 0L;
long dDuration = dPlayEnd == 1L ? parseDurationMs(props) : 0L;
long dLike = TelemetryEventEnum.LIKE.getEvent().equals(event) ? 1L : 0L;
long dShare = TelemetryEventEnum.SHARE.getEvent().equals(event) ? 1L : 0L;
GameStatDO existing = gameStatMapper.selectByGameAndDate(gameId, statDate);
if (existing == null) {
// 首事件建聚合行计数为本事件增量quality_score 0下一步算分回写
GameStatDO stat = new GameStatDO();
stat.setGameId(gameId);
stat.setStatDate(statDate);
stat.setPlayCount(dPlay);
stat.setPlayEndCount(dPlayEnd);
stat.setCompletedCount(dCompleted);
stat.setTotalDurationMs(dDuration);
stat.setLikeCount(dLike);
stat.setFavoriteCount(0L);
stat.setShareCount(dShare);
stat.setReportCount(0L);
stat.setLoadFailCount(0L);
stat.setQualityScore(BigDecimal.ZERO);
gameStatMapper.insert(stat);
return stat;
}
// 已存在行级累加仅累加本事件涉及的计数列其余列只显式回写既有值避免被 null 覆盖
GameStatDO update = new GameStatDO();
update.setId(existing.getId());
update.setPlayCount(nz(existing.getPlayCount()) + dPlay);
update.setPlayEndCount(nz(existing.getPlayEndCount()) + dPlayEnd);
update.setCompletedCount(nz(existing.getCompletedCount()) + dCompleted);
update.setTotalDurationMs(nz(existing.getTotalDurationMs()) + dDuration);
update.setLikeCount(nz(existing.getLikeCount()) + dLike);
update.setShareCount(nz(existing.getShareCount()) + dShare);
gameStatMapper.updateById(update);
// 回填累加后的值到 existing供算分避免再查一次库
existing.setPlayCount(update.getPlayCount());
existing.setPlayEndCount(update.getPlayEndCount());
existing.setCompletedCount(update.getCompletedCount());
existing.setTotalDurationMs(update.getTotalDurationMs());
existing.setLikeCount(update.getLikeCount());
existing.setShareCount(update.getShareCount());
return existing;
}
/**
* quality_score§3.5 口径clamp(60×完玩率 + 20×like率 + 20×share率, 0, 100)DECIMAL5,2
*
* 完玩率 = completed_count / play_end_count§3.5 明确
* like率 = like_count / play_countshare率 = share_count / play_count每次试玩的互动密度分母为 0 则该率记 0避免除零
* like率/share率分母口径play_count为本实现按"互动密度"取定§3.5 未显式给出分母见交付说明待主 agent 复核
*
* @param stat 聚合行
* @return quality_scoreDECIMAL5,2clamp[0,100]
*/
private BigDecimal computeQualityScore(GameStatDO stat) {
BigDecimal completeRate = ratio(stat.getCompletedCount(), stat.getPlayEndCount());
BigDecimal likeRate = ratio(stat.getLikeCount(), stat.getPlayCount());
BigDecimal shareRate = ratio(stat.getShareCount(), stat.getPlayCount());
BigDecimal score = W_COMPLETE.multiply(completeRate)
.add(W_LIKE.multiply(likeRate))
.add(W_SHARE.multiply(shareRate));
// clamp[0,100]量化到 2 位小数DECIMAL5,2
if (score.compareTo(BigDecimal.ZERO) < 0) {
score = BigDecimal.ZERO;
} else if (score.compareTo(SCORE_MAX) > 0) {
score = SCORE_MAX;
}
return score.setScale(2, RoundingMode.HALF_UP);
}
// ============================== 私有校验/工具 ==============================
/**
* 信封轻量校验必填字段非空 eventId+ 事件名在注册表登记
*
* 必填字段eventId/event/schemaVersion/ts/traceId已由 VO @NotBlank/@NotNull Controller 层校验
* 此处再做一次兜底beacon 通道未走 @Valid 级联时也安全+ 事件名注册表业务校验未通过即 rejected
* *
* @param envelope 单条信封 * @param envelope 单条信封
* @return true=通过可投递false=拒绝 * @return true=通过可同步写false=拒绝
*/ */
private boolean isEnvelopeValid(EnvelopeReqVO envelope) { private boolean isEnvelopeValid(EnvelopeReqVO envelope) {
// 信封必填兜底beacon 通道未走 @Valid 级联时也安全 // 信封必填兜底eventId 缺失计 rejected§3.5 幂等真身必填
if (envelope == null if (envelope == null
|| !StringUtils.hasText(envelope.getEventId())
|| !StringUtils.hasText(envelope.getEvent()) || !StringUtils.hasText(envelope.getEvent())
|| !StringUtils.hasText(envelope.getSchemaVersion()) || !StringUtils.hasText(envelope.getSchemaVersion())
|| envelope.getTs() == null || envelope.getTs() == null
@ -106,21 +283,115 @@ public class EventIngestServiceImpl implements EventIngestService {
} }
/** /**
* 投递事件到 MQ对接点 * 信封 原始事件 DO落库前组装props 透传原始 JSON 由前端保证为 JSON 文本
*
* TODO 对接点MQ 生产此处应调用 RocketMQ 生产者投递 topic=telemetry-event
* - 幂等键 = (traceId, event, ts)对应原始表 uk_dedup
* - 投递失败需本地落兜底表后定时补投不丢事件
* - 消费侧落 game_telemetry_event + 增量聚合 game_telemetry_game_stat + 回灌 feed/project
* 骨架阶段仅记 DEBUG 日志占位 MQ producer 就绪后替换为真实投递
* *
* @param envelope 单条信封 * @param envelope 单条信封
* @param userId 当前登录用户 ID补全信封 user 匿名为 null * @param userId 当前登录用户 ID匿名为 null
* @return 原始事件 DOprocess_status 初始 0 待聚合由同步写置 1
*/ */
private void dispatchToMq(EnvelopeReqVO envelope, Long userId) { private TelemetryEventDO buildEventDO(EnvelopeReqVO envelope, Long userId) {
// TODO 对接点rocketMQTemplate.asyncSend("telemetry-event", buildMessage(envelope, userId), callback) TelemetryEventDO eventDO = new TelemetryEventDO();
log.debug("[dispatchToMq] TODO 待投递 MQ event={}, traceId={}, userId={}", eventDO.setEventId(envelope.getEventId());
envelope.getEvent(), envelope.getTraceId(), userId); eventDO.setEvent(envelope.getEvent());
eventDO.setSchemaVersion(envelope.getSchemaVersion());
eventDO.setTs(envelope.getTs());
eventDO.setTraceId(envelope.getTraceId());
eventDO.setSessionId(StringUtils.hasText(envelope.getSessionId()) ? envelope.getSessionId() : "");
// user_id优先信封 user.userId回退当前登录态 userId匿名为 null
eventDO.setUserId(resolveUserId(envelope, userId));
eventDO.setAnonId(envelope.getUser() != null && StringUtils.hasText(envelope.getUser().getAnonId())
? envelope.getUser().getAnonId() : "");
if (envelope.getContext() != null) {
eventDO.setGameId(parseLong(envelope.getContext().getGameId()));
eventDO.setVersionId(parseLong(envelope.getContext().getVersionId()));
eventDO.setChannel(StringUtils.hasText(envelope.getContext().getChannel()) ? envelope.getContext().getChannel() : "");
eventDO.setDeviceType(StringUtils.hasText(envelope.getContext().getDeviceType()) ? envelope.getContext().getDeviceType() : "");
} else {
eventDO.setChannel("");
eventDO.setDeviceType("");
}
// propsDO 以字符串承载DB JSON MVP 不深解析存原始 toString前端按 #5 JSON
eventDO.setProps(envelope.getProps() == null ? null : envelope.getProps().toString());
eventDO.setProcessStatus(EventProcessStatusEnum.PENDING.getStatus());
return eventDO;
}
/**
* 解析事件 user_id优先信封 user.userIdstringLong回退当前登录 userId
*/
private Long resolveUserId(EnvelopeReqVO envelope, Long userId) {
if (envelope.getUser() != null && StringUtils.hasText(envelope.getUser().getUserId())) {
Long parsed = parseLong(envelope.getUser().getUserId());
if (parsed != null) {
return parsed;
}
}
return userId;
}
/**
* props.completed 是否为真兼容 Boolean / "true" 字符串
*/
private boolean isCompleted(Map<String, Object> props) {
if (props == null) {
return false;
}
Object completed = props.get("completed");
if (completed instanceof Boolean) {
return (Boolean) completed;
}
return completed != null && Boolean.parseBoolean(completed.toString());
}
/**
* 解析 props.duration_ms兼容 Number / 数字字符串缺失或非法记 0
*/
private long parseDurationMs(Map<String, Object> props) {
if (props == null) {
return 0L;
}
Object duration = props.get("duration_ms");
if (duration instanceof Number) {
return ((Number) duration).longValue();
}
if (duration != null) {
try {
return Long.parseLong(duration.toString());
} catch (NumberFormatException ignored) {
return 0L;
}
}
return 0L;
}
/**
* 比率 = 分子/分母DECIMAL 安全分母0 0避免除零保留 6 位中间精度
*/
private BigDecimal ratio(Long numerator, Long denominator) {
long den = nz(denominator);
if (den <= 0L) {
return BigDecimal.ZERO;
}
return BigDecimal.valueOf(nz(numerator)).divide(BigDecimal.valueOf(den), 6, RoundingMode.HALF_UP);
}
/** Long 空安全转 longnull→0 */
private static long nz(Long v) {
return v == null ? 0L : v;
}
/**
* 字符串安全转 Long/非法返回 null信封 context.gameId/versionIduser.userId 均为 string
*/
private static Long parseLong(String s) {
if (!StringUtils.hasText(s)) {
return null;
}
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException ex) {
return null;
}
} }
/** /**

View File

@ -3,22 +3,38 @@ package cn.wanxiang.game.module.telemetry.service.event;
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO; import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EnvelopeReqVO;
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO; import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchReqVO;
import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO; import cn.wanxiang.game.module.telemetry.controller.app.event.vo.EventBatchResultVO;
import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO;
import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO;
import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper;
import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper;
import cn.wanxiang.game.module.feed.api.FeedApi;
import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO;
import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID;
import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED; import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED;
import static org.junit.jupiter.api.Assertions.*; 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.Mockito.*;
/** /**
* {@link EventIngestServiceImpl} 单元测试 Mockito不依赖 DB/MQ * {@link EventIngestServiceImpl} 单元测试 Mockito不依赖 DB/MQ
* *
* 覆盖核心摄取规则守卫批量上限信封轻量校验事件名注册表/必填部分成功计数beacon 恒受理 * 覆盖黄金闭环 §3.5 C5 同步写批量上限信封轻量校验eventId/event 必填 + 注册表部分成功计数
* 这些规则是上报通道的契约语义必须有测试兜底MQ 投递为对接点仅日志占位不在单测断言范围 * eventId 幂等跳过计数累加quality_score 算分 + feed 回灌beacon 恒受理
* *
* @author 绘境AI * @author 绘境AI
*/ */
@ -27,11 +43,18 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks @InjectMocks
private EventIngestServiceImpl eventIngestService; private EventIngestServiceImpl eventIngestService;
@Mock
private TelemetryEventMapper telemetryEventMapper;
@Mock
private GameStatMapper gameStatMapper;
@Mock
private FeedApi feedApi; // §3.5 算分回灌 seam
// ============================== ingestBatch 批量上限 ============================== // ============================== ingestBatch 批量上限 ==============================
@Test @Test
void testIngestBatch_sizeExceeded() { void testIngestBatch_sizeExceeded() {
// 201 > 上限 200 整体拒绝 // 201 > 上限 200 整体拒绝在落库前即抛 DB 交互
EventBatchReqVO reqVO = new EventBatchReqVO(); EventBatchReqVO reqVO = new EventBatchReqVO();
List<EnvelopeReqVO> batch = new ArrayList<>(); List<EnvelopeReqVO> batch = new ArrayList<>();
for (int i = 0; i < 201; i++) { for (int i = 0; i < 201; i++) {
@ -42,21 +65,23 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> eventIngestService.ingestBatch(reqVO, 99L)); () -> eventIngestService.ingestBatch(reqVO, 99L));
assertEquals(TELEMETRY_BATCH_SIZE_EXCEEDED.getCode(), ex.getCode()); assertEquals(TELEMETRY_BATCH_SIZE_EXCEEDED.getCode(), ex.getCode());
verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class));
} }
// ============================== ingestBatch 部分成功注册表校验 ============================== // ============================== ingestBatch 部分成功eventId/注册表/必填校验 ==============================
@Test @Test
void testIngestBatch_partialAcceptedRejected() { void testIngestBatch_partialAcceptedRejected() {
// 2 条已登记事件 + 1 条未登记事件 + 1 条信封字段缺失 accepted=2, rejected=2 // 2 条合法 game_id仅落事件不聚合+ 1 条未登记 + 1 eventId 缺失 accepted=2, rejected=2
when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null); // 均为真插入
EventBatchReqVO reqVO = new EventBatchReqVO(); EventBatchReqVO reqVO = new EventBatchReqVO();
List<EnvelopeReqVO> batch = new ArrayList<>(); List<EnvelopeReqVO> batch = new ArrayList<>();
batch.add(validEnvelope("game_play_start", "t1")); // 已登记 batch.add(validEnvelope("game_play_start", "t1")); // 已登记 + eventId 齐全
batch.add(validEnvelope("like", "t2")); // 已登记 batch.add(validEnvelope("like", "t2")); // 已登记 + eventId 齐全
batch.add(validEnvelope("not_registered_evt", "t3")); // 未登记 rejected batch.add(validEnvelope("not_registered_evt", "t3")); // 未登记 rejected
EnvelopeReqVO missing = validEnvelope("share", "t4"); EnvelopeReqVO missingEventId = validEnvelope("share", "t4");
missing.setTraceId(""); // traceId 缺失 rejected missingEventId.setEventId(""); // eventId 缺失 rejected§3.5 幂等真身必填
batch.add(missing); batch.add(missingEventId);
reqVO.setBatch(batch); reqVO.setBatch(batch);
EventBatchResultVO result = eventIngestService.ingestBatch(reqVO, 99L); EventBatchResultVO result = eventIngestService.ingestBatch(reqVO, 99L);
@ -64,28 +89,115 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
assertEquals(2, result.getAccepted()); assertEquals(2, result.getAccepted());
assertEquals(2, result.getRejected()); assertEquals(2, result.getRejected());
assertEquals("t1", result.getTraceId()); // 回执复用首条 traceId assertEquals("t1", result.getTraceId()); // 回执复用首条 traceId
verify(telemetryEventMapper, times(2)).insert(any(TelemetryEventDO.class)); // 2 条真插入
verifyNoInteractions(feedApi); // game_id 不回灌
}
// ============================== eventId 幂等重放不重复计数 ==============================
@Test
void testIngestOne_idempotentSkipOnDuplicateEventId() {
// eventId 已落库 非真插入跳过不再 insert不累加不回灌重放同批次不重复计数
EnvelopeReqVO env = gameEnvelope("game_play_start", "t1", "1024", "2048", null);
when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(new TelemetryEventDO());
EventBatchResultVO result = eventIngestService.ingestBatch(batchOf(env), 99L);
assertEquals(1, result.getAccepted()); // 校验通过仍计 accepted已受理但不重复写
verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class));
verify(gameStatMapper, never()).insert(any(GameStatDO.class));
verify(gameStatMapper, never()).updateById(any(GameStatDO.class));
verifyNoInteractions(feedApi);
}
// ============================== 计数累加 + 算分 + 回灌 ==============================
@Test
void testIngestOne_playEndAccumulatesAndRefreshesFeed() {
// game_play_end(completed=true,duration=5000) 首事件 stat 算分回灌 feed(QUALITY_REFRESH)
Map<String, Object> props = new HashMap<>();
props.put("completed", true);
props.put("duration_ms", 5000);
EnvelopeReqVO env = gameEnvelope("game_play_end", "t1", "1024", "2048", props);
when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null);
when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(null); // 首事件无聚合行
eventIngestService.ingestBatch(batchOf(env), 99L);
// stat play_end_count=1, completed_count=1, total_duration_ms=5000
ArgumentCaptor<GameStatDO> statCaptor = ArgumentCaptor.forClass(GameStatDO.class);
verify(gameStatMapper).insert(statCaptor.capture());
GameStatDO stat = statCaptor.getValue();
assertEquals(1024L, stat.getGameId());
assertEquals(1L, stat.getPlayEndCount());
assertEquals(1L, stat.getCompletedCount());
assertEquals(5000L, stat.getTotalDurationMs());
// 回灌 feedmode=QUALITY_REFRESHgameId/versionId 透传quality_score=60×完玩率(1.0)=60
ArgumentCaptor<FeedRankUpsertReqDTO> feedCaptor = ArgumentCaptor.forClass(FeedRankUpsertReqDTO.class);
verify(feedApi).upsertRank(feedCaptor.capture());
FeedRankUpsertReqDTO feedReq = feedCaptor.getValue();
assertEquals(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH, feedReq.getMode());
assertEquals(1024L, feedReq.getGameId());
assertEquals(2048L, feedReq.getVersionId());
assertEquals(60.0D, feedReq.getQualityScore(), 0.001); // 完玩率 1/1=1 60×1=60
// 置事件已聚合
verify(telemetryEventMapper).updateById(any(TelemetryEventDO.class));
}
@Test
void testIngestOne_likeAccumulatesOnExistingStat() {
// like 事件命中既有 statplay_count=2,like_count=1 like_count 累加为 2quality_score like
GameStatDO existing = new GameStatDO();
existing.setId(7L);
existing.setPlayCount(2L);
existing.setPlayEndCount(2L);
existing.setCompletedCount(0L);
existing.setLikeCount(1L);
existing.setShareCount(0L);
existing.setTotalDurationMs(0L);
EnvelopeReqVO env = gameEnvelope("like", "t1", "1024", "2048", null);
when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null);
when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(existing);
eventIngestService.ingestBatch(batchOf(env), 99L);
// 累加like_count 12仅累加 like 涉及列
ArgumentCaptor<GameStatDO> statCaptor = ArgumentCaptor.forClass(GameStatDO.class);
// 两次 updateById一次累加计数一次写 quality_score捕获首个累加调用
verify(gameStatMapper, atLeastOnce()).updateById(statCaptor.capture());
GameStatDO accUpdate = statCaptor.getAllValues().get(0);
assertEquals(7L, accUpdate.getId());
assertEquals(2L, accUpdate.getLikeCount()); // like 累加
assertEquals(2L, accUpdate.getPlayCount()); // play_count 保持显式回写既有值
verify(feedApi).upsertRank(any(FeedRankUpsertReqDTO.class)); // game_id 回灌
} }
// ============================== ingestBeacon 恒受理 ============================== // ============================== ingestBeacon 恒受理 ==============================
@Test @Test
void testIngestBeacon_validAlwaysTrue() { void testIngestBeacon_invalidStillTrue() {
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("perf_first_screen", "t-beacon"), 99L); // 未登记事件beacon 通道不抛错仍恒返回 true不阻塞前端卸载 DB 交互
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("bad_event", "t-beacon"), null);
assertTrue(ok); assertTrue(ok);
verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class));
} }
@Test @Test
void testIngestBeacon_invalidStillTrue() { void testIngestBeacon_validNoGameIdAcceptedTrue() {
// 未登记事件beacon 通道不抛错仍恒返回 true不阻塞前端卸载 // 合法但无 game_id落事件置已聚合不回灌返回 true
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("bad_event", "t-beacon"), null); when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null);
Boolean ok = eventIngestService.ingestBeacon(validEnvelope("perf_first_screen", "t-beacon"), 99L);
assertTrue(ok); assertTrue(ok);
verify(telemetryEventMapper).insert(any(TelemetryEventDO.class));
verifyNoInteractions(feedApi);
} }
// ============================== 测试夹具 ============================== // ============================== 测试夹具 ==============================
/** 构造一条信封必填项齐全的事件 */ /** 构造一条信封必填项齐全的事件(含 eventId无 context不触发聚合 */
private static EnvelopeReqVO validEnvelope(String event, String traceId) { private static EnvelopeReqVO validEnvelope(String event, String traceId) {
EnvelopeReqVO envelope = new EnvelopeReqVO(); EnvelopeReqVO envelope = new EnvelopeReqVO();
envelope.setEventId(UUID.randomUUID().toString());
envelope.setEvent(event); envelope.setEvent(event);
envelope.setSchemaVersion("v1"); envelope.setSchemaVersion("v1");
envelope.setTs(1733600000000L); envelope.setTs(1733600000000L);
@ -93,4 +205,24 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest {
return envelope; return envelope;
} }
/** 构造带 context(game_id/version_id) + props 的事件(触发聚合 + 回灌) */
private static EnvelopeReqVO gameEnvelope(String event, String traceId, String gameId, String versionId, Map<String, Object> props) {
EnvelopeReqVO envelope = validEnvelope(event, traceId);
EnvelopeReqVO.ContextVO ctx = new EnvelopeReqVO.ContextVO();
ctx.setGameId(gameId);
ctx.setVersionId(versionId);
envelope.setContext(ctx);
envelope.setProps(props);
return envelope;
}
/** 单条信封打成批量入参 */
private static EventBatchReqVO batchOf(EnvelopeReqVO env) {
EventBatchReqVO reqVO = new EventBatchReqVO();
List<EnvelopeReqVO> batch = new ArrayList<>();
batch.add(env);
reqVO.setBatch(batch);
return reqVO;
}
} }