From 1740d8c226cb1667dc9ea1e5efdd1ea530ff2996 Mon Sep 17 00:00:00 2001 From: zizi Date: Tue, 9 Jun 2026 14:21:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(B1):=20=E9=BB=84=E9=87=91=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E5=90=8E=E7=AB=AF=E5=AE=9E=E7=8E=B0(step=E2=91=A1)=20?= =?UTF-8?q?=E2=80=94=20=E5=8F=91=E5=B8=83=E7=BC=96=E6=8E=92/=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=86=99=E6=95=B0=E6=8D=AE=E5=9B=9E=E8=B7=AF/PackageS?= =?UTF-8?q?tore/version=20=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../aigc/service/task/AigcTaskService.java | 13 + .../service/task/AigcTaskServiceImpl.java | 25 ++ .../module/feed/enums/ErrorCodeConstants.java | 8 +- .../game-module-feed-server/pom.xml | 9 +- .../game/module/feed/api/FeedApiImpl.java | 37 ++ .../module/feed/service/feed/FeedService.java | 15 + .../feed/service/feed/FeedServiceImpl.java | 175 ++++++++- .../service/feed/FeedServiceImplTest.java | 131 ++++++- .../game/module/project/api/ProjectApi.java | 15 + .../project/enums/ProjectStatusEnum.java | 4 + .../module/project/api/ProjectApiImpl.java | 6 + .../project/api/ProjectVersionApiImpl.java | 35 ++ .../dal/dataobject/version/GameVersionDO.java | 61 +++ .../dal/mysql/project/ProjectMapper.java | 14 + .../dal/mysql/version/GameVersionMapper.java | 29 ++ .../service/project/ProjectService.java | 10 + .../service/project/ProjectServiceImpl.java | 42 ++- .../publish/PublishOrchestrationService.java | 26 ++ .../PublishOrchestrationServiceImpl.java | 102 +++++ .../service/version/GameVersionService.java | 44 +++ .../version/GameVersionServiceImpl.java | 73 ++++ .../project/ProjectServiceImplTest.java | 14 +- .../module/runtime/api/RuntimePackageApi.java | 16 + .../runtime/api/RuntimePackageApiImpl.java | 41 +++ .../app/runtime/AppRuntimeController.java | 33 ++ .../convert/runtime/RuntimeConvert.java | 23 +- .../dal/dataobject/pkg/RuntimePackageDO.java | 8 + .../service/pkg/RuntimePackageService.java | 11 + .../pkg/RuntimePackageServiceImpl.java | 14 +- .../service/pkg/store/DbPackageStore.java | 47 +++ .../service/pkg/store/PackageStore.java | 33 ++ .../convert/runtime/RuntimeConvertTest.java | 17 + .../app/event/vo/EnvelopeReqVO.java | 4 + .../dataobject/event/TelemetryEventDO.java | 7 + .../dal/mysql/event/TelemetryEventMapper.java | 19 +- .../dal/mysql/stat/GameStatMapper.java | 17 + .../service/event/EventIngestServiceImpl.java | 347 ++++++++++++++++-- .../event/EventIngestServiceImplTest.java | 166 ++++++++- 38 files changed, 1600 insertions(+), 91 deletions(-) create mode 100644 game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectVersionApiImpl.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/dataobject/version/GameVersionDO.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/version/GameVersionMapper.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionService.java create mode 100644 game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionServiceImpl.java create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApiImpl.java create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/DbPackageStore.java create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/PackageStore.java diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskService.java index 06c0cff8..c2531b57 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskService.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskService.java @@ -78,4 +78,17 @@ public interface AigcTaskService { */ PageResult 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 产物落地版本 ID(project.game_version.id,由 ProjectVersionApi.createForPackage 产出) + */ + void completeWithVersion(Long taskId, Long versionId); + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java index d1a71b5f..cdc66de2 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java @@ -140,6 +140,31 @@ public class AigcTaskServiceImpl implements AigcTaskService { return aigcTaskMapper.selectAdminPage(reqVO); } + @Override + public void completeWithVersion(Long taskId, Long versionId) { + // §3.3 C3:PackageFactory 落包后回填——置 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); + } + // ============================== 私有校验/工具 ============================== /** diff --git a/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/enums/ErrorCodeConstants.java b/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/enums/ErrorCodeConstants.java index 96677aad..d8a31a44 100644 --- a/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/enums/ErrorCodeConstants.java @@ -7,7 +7,7 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode; * * 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 */ @@ -31,4 +31,10 @@ public interface ErrorCodeConstants { /** 精选目标非法:仅 project status=4 已发布的游戏可入精选(对齐契约 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, "排序行不满足可见态条件,拒绝写入发布基线"); + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/pom.xml b/game-cloud/game-module-feed/game-module-feed-server/pom.xml index 31253cbf..de1d7c89 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/pom.xml +++ b/game-cloud/game-module-feed/game-module-feed-server/pom.xml @@ -31,13 +31,20 @@ ${revision} - + cn.iocoder.cloud game-module-project-api ${revision} + + + cn.iocoder.cloud + game-module-runtime-api + ${revision} + + cn.iocoder.cloud diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java new file mode 100644 index 00000000..78e8131d --- /dev/null +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java @@ -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 upsertRank(FeedRankUpsertReqDTO req) { + // 按 mode 分流:PUBLISH_BASELINE(发布基线,写前 enforce 可见态三条件)/ QUALITY_REFRESH(仅刷分,保留 boost/pinned/status) + feedService.upsertRank(req); + return success(Boolean.TRUE); + } + +} diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java index 851b5936..6e3b123b 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java @@ -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.ZoneStreamReqVO; 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 java.util.List; @@ -83,4 +84,18 @@ public interface FeedService { */ 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_REFRESH(telemetry 算分回灌,§3.5 C5):仅更 quality_score/sort_score,保留 boost/pinned/status(不覆盖运营加权与在流态)。 + * + * 同步事务语义:由调用方在其本地 @Transactional 内调用,本方法抛错则上游整体回滚。 + * + * @param req 排序行 upsert 入参(含 mode 分流) + */ + void upsertRank(FeedRankUpsertReqDTO req); + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java index 45b4926e..92ba84e6 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java @@ -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.mysql.interact.FeedInteractLogMapper; import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper; +import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.wanxiang.game.module.feed.enums.FeedActionEnum; 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 jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -25,8 +29,11 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import static cn.wanxiang.game.module.feed.enums.ErrorCodeConstants.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -56,11 +63,20 @@ public class FeedServiceImpl implements FeedService { * project 模块 RPC 契约(仅依赖 -api) * * GAP-3:分享元数据回填当前生效版本 ID(getCurrentVersionId),供分享落地页直达即玩加载。 + * §3.1/§3.2 可见态 enforce:getStatus 读 project.status(写侧第一条件 + 读侧二次过滤);getCurrentVersionId 用于写侧第二条件。 * MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。 */ @Resource 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 @@ -89,15 +105,44 @@ public class FeedServiceImpl implements FeedService { */ private FeedStreamRespVO buildStream(Long zoneId, Integer size, Long userId) { List ranks = feedRankMapper.selectStreamByZone(zoneId, size); + // 读侧可见态 enforce(§3.1 C1 / Codex H9):rank.status=1 只是覆盖层在流态,仍可能因手工写错/项目降级而与权威态不一致, + // 故对 rank 结果批查 project.status 二次过滤——仅 project.status==PUBLISHED(4) 的游戏出流,未发布/降级(下架/封禁/审核中)不出流。 + List visibleRanks = filterByProjectPublished(ranks); FeedStreamRespVO resp = new FeedStreamRespVO(); - resp.setList(FeedConvert.toCardList(ranks)); + resp.setList(FeedConvert.toCardList(visibleRanks)); // TODO 跨模块回填:本体元信息读 project(同库直查或 RPC);登录态用户互动 liked/favorited 读 game_feed_interact_log。 // TODO cursor 续接:以末条 sortScore 编码 nextCursor;不足 size 条则 hasMore=false。骨架先返回空串占位。 resp.setNextCursor(""); - resp.setHasMore(ranks.size() >= size); + // hasMore 以二次过滤后的真实出流条数判定(避免被过滤掉的行误判还有更多) + resp.setHasMore(visibleRanks.size() >= size); 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 filterByProjectPublished(List ranks) { + if (ranks.isEmpty()) { + return ranks; + } + // 去重 gameId → 批量读 project.status(缓存到 map,避免同一游戏多次 RPC) + Map 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 public List getZones() { // TODO 跨模块对接 project:读 game_zone(type=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); + } + } + + /** + * 发布基线 upsert(mode=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()); + } + + /** + * 算分回灌 upsert(mode=QUALITY_REFRESH,§3.5 C5):仅更 quality_score/sort_score,保留 boost/pinned/status(守门③) + * + * 仅对已存在的排序行做刷新(纯 UPDATE,不新建)——未发布游戏无发布基线行则无可刷新(telemetry 只对已发布游戏回灌有意义)。 + * 仅设置 quality_score/sort_score 两个字段(其余字段不置入 update DO,MyBatis-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 + 既有 boost(boost 权威归 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 排序值 → BigDecimal(feed_rank 列为 DECIMAL10,4;null 安全缺省 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)。 - * 设计为可重写 seam:骨架阶段默认放行(true),实际接入时替换为真实查询。 + * §3.1 C1:去掉旧「骨架默认放行 true」——经 ProjectApi 本地 bean 读 project.status,仅 PUBLISHED(4) 为 true。 + * 仍保留 protected seam 便于单测桩控(@Spy)。 * * @param gameId 游戏 ID - * @return true=已发布 + * @return true=已发布(project.status==PUBLISHED(4)) */ protected boolean isGamePublished(Long gameId) { - // TODO 跨模块对接 project:查 game_project where id=gameId and status=4。 - // MVP 同库可直查,或待 project -api RPC 就绪后改 Feign(含已删/封禁过滤)。 - // 骨架阶段默认放行,避免阻断联调;集成阶段必须替换为真实校验。 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()); } } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java index 590ed4a6..be7cfb81 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java @@ -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.mysql.interact.FeedInteractLogMapper; import cn.wanxiang.game.module.feed.dal.mysql.rank.FeedRankMapper; +import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.wanxiang.game.module.project.api.ProjectApi; +import cn.wanxiang.game.module.runtime.api.RuntimePackageApi; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; @@ -49,7 +51,9 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { @Mock private FeedInteractLogMapper feedInteractLogMapper; @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( rank(1L, 10L, 0L, new BigDecimal("90")), 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(); reqVO.setSize(2); @@ -70,6 +77,24 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { assertEquals("", resp.getNextCursor()); // 骨架阶段 cursor 占位空串 } + @Test + void testGetFeedStream_filterOutNonPublished() { + // §3.1 C1 读侧 enforce:rank 在流(status=1)但 project 非 PUBLISHED 的行必须被剔除(手工写错/降级不出流) + when(feedRankMapper.selectStreamByZone(eq(0L), eq(2))).thenReturn(Arrays.asList( + rank(1L, 10L, 0L, new BigDecimal("90")), // project 已发布 → 出流 + rank(2L, 11L, 0L, new BigDecimal("80")))); // project 已下架(5) → 剔除 + 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 @@ -231,6 +256,84 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { 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 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 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(保持 null,MyBatis-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; } + /** 构造发布基线 upsert 入参(mode=PUBLISH_BASELINE,分数/boost/sort=0,status=1,pinned=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; + } + } diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java index 5d5e973a..b090491a 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java @@ -51,6 +51,21 @@ public interface ProjectApi { @Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024") CommonResult 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 游戏项目 ID(game_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 getStatus(@RequestParam("gameId") Long gameId); + /** * 创建项目(studio 写类 RPC,建草稿;显式传 creatorUserId 作 owner) * diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/enums/ProjectStatusEnum.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/enums/ProjectStatusEnum.java index dc50e29e..65c66e73 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/enums/ProjectStatusEnum.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/enums/ProjectStatusEnum.java @@ -12,6 +12,10 @@ import java.util.Objects; * 合法流转:0草稿 →1审核中 →(2已通过 / 3已拒绝) →4已发布 →(5已下架 / 6已封禁)。 * 注意:状态机校验在 ProjectService 承载,DO 层不做校验(见 db-schemas 注释)。 * + * D-PUB=α auto-publish(黄金闭环 §1,2026-06-09 拍板):审核通过即自动发布—— + * 人工 reviewProject(APPROVE) 在同一本地事务内经发布编排把项目态直接 REVIEWING(1)→PUBLISHED(4), + * 故 APPROVED(2) 仅作审核记录的决策值(game_review_record.decision=通过),项目态不停留 APPROVED(2)。 + * * @author 绘境AI */ @Getter diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java index 9d3d32be..373a3bd5 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java @@ -38,6 +38,12 @@ public class ProjectApiImpl implements ProjectApi { return success(projectService.getCurrentVersionId(gameId)); } + @Override + public CommonResult getStatus(Long gameId) { + // §3.1/§3.2 可见态 enforce:按 game_project.id 返回 status;不存在返回 null(由 feed 写侧拒写、读侧过滤) + return success(projectService.getStatus(gameId)); + } + @Override public CommonResult createProject(Long creatorUserId, String title, String templateId) { // studio 写类 RPC:RPC 无登录上下文,owner=显式 creatorUserId(调用方受信传入),不裸暴露给前端 diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectVersionApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectVersionApiImpl.java new file mode 100644 index 00000000..fc43d8e6 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectVersionApiImpl.java @@ -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 createForPackage(ProjectVersionCreateForPackageReqDTO req) { + // 落包建版本:委托 GameVersionService 创建/复用 game_version(status=2 预览就绪),按 genTaskId 幂等 + return success(gameVersionService.createForPackage(req)); + } + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/dataobject/version/GameVersionDO.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/dataobject/version/GameVersionDO.java new file mode 100644 index 00000000..b9b78d26 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/dataobject/version/GameVersionDO.java @@ -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_version,GameVersion 版本管理 + 草稿/发布,黄金闭环 §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; + /** + * 所属游戏 ID(game_project.id) + */ + private Long gameId; + /** + * 版本号(同游戏内递增) + */ + private Integer versionNo; + /** + * 生成任务 ID(aigc taskId);落包建版本幂等键:同 gen_task_id 复用已建版本,不重复建 + */ + private String genTaskId; + /** + * GamePackage(manifest)OSS URL;MVP 无 OSS 包存 DB(退场契约 §3.4),为空落空串 + */ + private String packageUrl; + /** + * 包总字节(编译期门禁) + */ + private Long bundleSize; + /** + * 整包 sha256(hex,Manifest 完整性校验;与 manifest 端点原样响应字节同源计算 §3.4 C4) + */ + private String checksum; + /** + * 版本状态:0草稿 1已编译 2预览就绪 3已发布 + * + * 落包建版本写 2(预览就绪);发布编排成功后由本编排显式置 3(已发布,§3.2 C2 步骤④,不依赖 runtime TODO,Codex H6)。 + */ + private Integer status; + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java index a21441ed..079bd282 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/project/ProjectMapper.java @@ -70,4 +70,18 @@ public interface ProjectMapper extends BaseMapperX { .eq(ProjectDO::getId, id)); // 命中主键 } + /** + * 按游戏 ID 查项目状态机值(§3.1/§3.2 可见态 enforce,跨模块 Feign:feed 读写双侧校验) + * + * 只取 status 单列、命中主键(id)——显式列、不裸 select*、不加载整行,最小化跨模块查询开销。 + * + * @param id 游戏项目 ID(主键) + * @return 仅含 status 的 DO;游戏不存在返回 null + */ + default ProjectDO selectStatusById(Long id) { + return selectOne(new LambdaQueryWrapperX() + .select(ProjectDO::getStatus) // 显式只查状态机所需列 + .eq(ProjectDO::getId, id)); // 命中主键 + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/version/GameVersionMapper.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/version/GameVersionMapper.java new file mode 100644 index 00000000..a7c88e50 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/dal/mysql/version/GameVersionMapper.java @@ -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 { + + /** + * 按生成任务 ID 取已建版本(落包建版本幂等:同 gen_task_id 复用,不重复建) + * + * 命中 idx_game_version 不直接适用,按 gen_task_id 过滤取一行(同任务对应一版本,业务量级下行数有限)。 + * + * @param genTaskId 生成任务 ID(aigc taskId) + * @return 已建版本;不存在返回 null + */ + default GameVersionDO selectByGenTaskId(String genTaskId) { + return selectOne(new LambdaQueryWrapperX() + .eq(GameVersionDO::getGenTaskId, genTaskId)); + } + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java index 0457ce23..3938b9c5 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java @@ -103,6 +103,16 @@ public interface ProjectService { */ Long getCurrentVersionId(Long gameId); + /** + * 按游戏 ID 反查项目状态机值(§3.1/§3.2 可见态 enforce,供 feed 跨模块 Feign 读写双侧校验) + * + * 只读、显式只查 status 单列、命中主键;游戏不存在返回 null(由调用方处理,如 feed 写侧视为不满足可见态拒写)。 + * + * @param gameId 游戏项目 ID(game_project.id) + * @return 项目状态机值;游戏不存在返回 null + */ + Integer getStatus(Long gameId); + /** * 封禁联动下架(R3,供 compliance 跨模块调用) * diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java index c9a9c301..e898db37 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java @@ -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.enums.ProjectStatusEnum; 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.dto.ComplianceGateReqDTO; import cn.wanxiang.game.module.compliance.dto.GateVerdict; @@ -62,6 +63,13 @@ public class ProjectServiceImpl implements ProjectService { @Resource private ComplianceGateApi complianceGateApi; + /** + * 发布编排 Service(§3.2 C2,α auto-publish)。 + * reviewProject(APPROVE) 在同一本地事务内委托其串联「校验包→翻包→翻版本态→翻项目态 PUBLISHED→写 feed 基线」,任一步失败整体回滚。 + */ + @Resource + private PublishOrchestrationService publishOrchestrationService; + @Override public PageResult getMyProjectPage(ProjectPageReqVO reqVO, Long userId) { return projectMapper.selectMyPage(reqVO, userId); @@ -181,28 +189,37 @@ public class ProjectServiceImpl implements ProjectService { } @Override - @Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录,需同事务 + @Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录 +(APPROVE)发布编排,需同一本地事务(§3.2 C2 事务原子) public void reviewProject(ReviewReqVO reqVO, Long reviewerUserId) { ProjectDO project = projectMapper.selectById(reqVO.getGameId()); if (project == null) { 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()); - // 更新项目状态 - ProjectDO update = new ProjectDO(); - update.setId(project.getId()); - update.setStatus(targetStatus); - projectMapper.updateById(update); - // 落审核记录(锁风门 Gate 二态:通过=pass,拒绝/下架=block) + if (!isApprove) { + // REJECT/UNLIST 分支不变:直接写目标状态(REJECTED(3) / UNLISTED(5)) + ProjectDO update = new ProjectDO(); + update.setId(project.getId()); + update.setStatus(targetStatus); + projectMapper.updateById(update); + } + // 落审核记录(锁风门 Gate 二态:通过=pass,拒绝/下架=block)。 + // D-PUB=α:APPROVE 先落 APPROVED 审核记录(decision 仍为通过、gate=pass),项目态不停留 APPROVED——见 ProjectStatusEnum 注释。 ReviewRecordDO record = new ReviewRecordDO(); record.setGameId(reqVO.getGameId()); record.setVersionId(reqVO.getVersionId()); record.setReviewerUserId(reviewerUserId); record.setDecision(reqVO.getDecision()); - record.setGateResult(Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision()) ? "pass" : "block"); + record.setGateResult(isApprove ? "pass" : "block"); record.setReason(reqVO.getReason()); 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 @@ -219,6 +236,13 @@ public class ProjectServiceImpl implements ProjectService { return project == null ? null : project.getCurrentVersionId(); } + @Override + public Integer getStatus(Long gameId) { + // §3.1/§3.2 可见态 enforce 只读:显式只查 status 单列、命中主键;游戏不存在返回 null(feed 写侧据此拒写、读侧据此过滤) + ProjectDO project = projectMapper.selectStatusById(gameId); + return project == null ? null : project.getStatus(); + } + @Override public boolean unlistIfPublished(Long gameId) { // R3 封禁联动下架:状态查询 + 下架决策均收敛在 project 权威侧,复用既有 reviewProject 状态机(不重写=D2) diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java new file mode 100644 index 00000000..71a5357f --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java @@ -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 核心,新增) + * + * 职责:把「发布」串成一个本地事务原子操作——校验包就绪 → 翻包 0→1 → 翻版本态 3 → 项目态 PUBLISHED(4) → 写 feed 发布基线。 + * 触发点(α auto-publish,D-PUB §1):人工 {@code ProjectServiceImpl.reviewProject(APPROVE)} 在同一事务内调用本编排。 + * + * 事务原子硬约束(Codex H5,B1 必守):本编排注入 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); + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java new file mode 100644 index 00000000..c8fa4bc3 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java @@ -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(仅依赖 -api)。MVP 单体由 runtime 模块 RuntimePackageApiImpl(@RestController @Primary) 就地解析, + * 事务内本地调用(非 Feign),抛错则发布事务整体回滚。 + */ + @Resource + private RuntimePackageApi runtimePackageApi; + + /** + * feed 写排序 seam(仅依赖 -api)。MVP 单体由 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); + } + + // ② 翻包 0→1 已发布(复用 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 或 0;quality_score/boost/sort_score=0、status=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()); + } + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionService.java new file mode 100644 index 00000000..9849338d --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionService.java @@ -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 权威归 project(Codex 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); + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionServiceImpl.java new file mode 100644 index 00000000..89ee01c6 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/version/GameVersionServiceImpl.java @@ -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); + } + +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java index c2d235b3..6a990468 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java @@ -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.review.ReviewRecordMapper; 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.dto.GateVerdict; import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum; @@ -45,6 +46,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { private ReviewRecordMapper reviewRecordMapper; @Mock private ComplianceGateApi complianceGateApi; // R1 合规锁风门(发布前注入) + @Mock + private PublishOrchestrationService publishOrchestrationService; // §3.2 C2 发布编排(α auto-publish,APPROVE 时调用) // ============================== createProject ============================== @@ -218,22 +221,23 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { // ============================== reviewProject 流转 ============================== @Test - void testReviewProject_approveFromReviewing() { + void testReviewProject_approveFromReviewing_autoPublish() { + // D-PUB=α:APPROVE 不直接写 APPROVED(2),而是落 pass 审核记录 + 调发布编排(编排内写 PUBLISHED,本测试 mock 编排) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); when(projectMapper.selectById(1L)).thenReturn(project); ReviewReqVO reqVO = reviewReq(1L, 1); // 1=通过 projectService.reviewProject(reqVO, 7L); - // 项目状态 → 已通过 - ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectDO.class); - verify(projectMapper).updateById(captor.capture()); - assertEquals(ProjectStatusEnum.APPROVED.getStatus(), captor.getValue().getStatus()); // 落审核记录(pass) ArgumentCaptor rc = ArgumentCaptor.forClass(ReviewRecordDO.class); verify(reviewRecordMapper).insert(rc.capture()); assertEquals("pass", rc.getValue().getGateResult()); assertEquals(7L, rc.getValue().getReviewerUserId()); + // α:在同一事务内委托发布编排(项目态 → PUBLISHED 由编排承载) + verify(publishOrchestrationService).publish(project); + // APPROVE 分支不再由本方法直接写项目状态(不调 projectMapper.updateById;PUBLISHED 写入在编排内,已 mock) + verify(projectMapper, never()).updateById(any(ProjectDO.class)); } @Test diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApi.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApi.java index 83c88496..185531e8 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApi.java +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApi.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; 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.RequestParam; @@ -43,4 +44,19 @@ public interface RuntimePackageApi { @Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024") CommonResult 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 返回 null(feed 写侧视为不满足可见态拒写)。 + * 状态值语义见 {@link cn.wanxiang.game.module.runtime.enums.PackageStatusEnum}(0预览就绪 1已发布 2已失效)。 + * + * @param versionId 版本 ID(project.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 getStatus(@RequestParam("versionId") Long versionId); + } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApiImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApiImpl.java new file mode 100644 index 00000000..3a48d066 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/api/RuntimePackageApiImpl.java @@ -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 翻包 0→1(不在此回写 version/project,那归 project 编排,Codex H6);getStatus 供 feed 写侧可见态 enforce。 + * + * @author 造梦AI + */ +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析,发布编排事务内本地调用) +public class RuntimePackageApiImpl implements RuntimePackageApi { + + @Resource + private RuntimePackageService runtimePackageService; + + @Override + public CommonResult publish(Long versionId) { + // 发布翻包:委托 RuntimePackageService.publishPackage(0→1 已发布;幂等;未就绪抛 RUNTIME_PACKAGE_NOT_READY) + runtimePackageService.publishPackage(versionId); + return success(Boolean.TRUE); + } + + @Override + public CommonResult getStatus(Long versionId) { + // §3.1 C1 可见态第三条件:返回 game_runtime_package(versionId).status;无就绪运行包返回 null(feed 写侧据此拒写) + return success(runtimePackageService.getPackageStatus(versionId)); + } + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java index b0b99f4e..1c8c6486 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/controller/app/runtime/AppRuntimeController.java @@ -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.enums.RuntimeSceneEnum; 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.iocoder.yudao.framework.common.pojo.CommonResult; 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 jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; 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; /** @@ -43,6 +48,12 @@ public class AppRuntimeController { @Resource private RuntimeSessionService runtimeSessionService; + /** + * 整包 manifest 存储抽象(§3.4 C4):MVP=DbPackageStore(读 game_runtime_package.package_json),M3 换 OSS impl 调用方不变。 + */ + @Resource + private PackageStore packageStore; + @GetMapping("/package/{versionId}") @Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") @@ -56,6 +67,28 @@ public class AppRuntimeController { 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") @Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等") public CommonResult startSession(@Valid @RequestBody SessionStartReqVO reqVO) { diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java index 764056e3..f61dbc44 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvert.java @@ -41,9 +41,11 @@ public class RuntimeConvert { } // 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷) RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class); - // GAP-2 分支A(纯派生,零 DDL):manifestUrl 由 packageUrl 同前缀派生 .../manifest.json, - // 宿主据此先 fetch manifest 并按 checksum 做 sha256 完整性校验,通过后再注入运行容器(T-RT-15)。 - vo.setManifestUrl(deriveManifestUrl(pkg.getPackageUrl())); + // manifestUrl 解析(§3.4 C4 退场契约): + // - MVP 无 OSS(packageUrl 空,整包存 DB):指向 manifest 端点 /app-api/runtime/package/{versionId}/manifest, + // 宿主 fetch 该端点取原始 JSON、对响应文本算 sha256 与 checksum 比对后注入(相对 URL 由前端 resolve 到 API base,§2.5); + // - M3 接 OSS(packageUrl 非空):回落 GAP-2 分支A 由 packageUrl 同前缀派生 .../manifest.json(OSS/CDN 版本化 URL)。 + vo.setManifestUrl(resolveManifestUrl(pkg)); // 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List SandboxPolicyVO sandbox = new SandboxPolicyVO(); sandbox.setSandboxAttr(pkg.getSandboxAttr()); @@ -52,6 +54,21 @@ public class RuntimeConvert { return vo; } + /** + * 解析 manifestUrl(§3.4 C4 退场契约:MVP 走 DB 端点 / M3 走 OSS 派生) + * + * @param pkg 运行包 DO(取 packageUrl 判 MVP/OSS,versionId 拼端点路径) + * @return manifestUrl:packageUrl 空 → manifest 端点相对路径;非空 → 同前缀派生 .json + */ + public static String resolveManifestUrl(RuntimePackageDO pkg) { + if (!StringUtils.hasText(pkg.getPackageUrl())) { + // MVP 无 OSS:整包存 DB,manifestUrl 指向原样服务端点(相对路径,前端 resolve 到 API base) + return "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest"; + } + // M3 OSS:保留 GAP-2 分支A 派生 + return deriveManifestUrl(pkg.getPackageUrl()); + } + /** * 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列) * diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java index 9777244d..75033909 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java @@ -79,5 +79,13 @@ public class RuntimePackageDO extends TenantBaseDO { * 0→1 由 publish 对接点回写,并同步置 project.game_version.status=3。 */ private Integer status; + /** + * 整包 manifest 原始 JSON(黄金闭环 §3.4 C4,V10 新增 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; } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageService.java index f78964ba..44c64895 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageService.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageService.java @@ -38,4 +38,15 @@ public interface RuntimePackageService { */ void publishPackage(Long versionId); + /** + * 取版本运行包状态机值(§3.1 C1 可见态第三条件:供 feed 写侧 PUBLISH_BASELINE enforce) + * + * 只读:显式取 game_runtime_package(versionId).status;该版本无就绪运行包返回 null(feed 写侧据此视为不满足可见态拒写)。 + * 状态值语义见 {@link cn.wanxiang.game.module.runtime.enums.PackageStatusEnum}。 + * + * @param versionId 版本 ID + * @return 运行包状态机值;无就绪运行包返回 null + */ + Integer getPackageStatus(Long versionId); + } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java index 1e3e9b1b..e0f760bc 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java @@ -74,13 +74,19 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { update.setId(pkg.getId()); update.setStatus(PackageStatusEnum.PUBLISHED.getStatus()); runtimePackageMapper.updateById(update); - // TODO 对接点【project 发布编排回写】:发布态写入链路最后一跳——回写 project.game_version.status=3(已发布)。 - // 待 project 模块 -api(Feign:ProjectVersionApi#updateVersionStatusToPublished(versionId))就绪后在此补全; - // 注意:跨模块远程调用须放在本地事务提交之后,并做超时/失败补偿(重试或运营告警)。 - log.info("[publishPackage] 运行包已置已发布 versionId={}, pkgId={}(project.game_version 回写为 TODO 对接点)", + // 注(黄金闭环 §3.2 C2 / Codex H6):version/project 回写不在此——发布态写入链路由 project 的 PublishOrchestrationService 承载, + // 其在同一本地事务内先调本方法翻包 0→1,再显式置 game_version.status=3 与 project.status=PUBLISHED(4),本模块只负责翻包态。 + log.info("[publishPackage] 运行包已置已发布 versionId={}, pkgId={}(version/project 回写归 project 发布编排)", versionId, pkg.getId()); } + @Override + public Integer getPackageStatus(Long versionId) { + // §3.1 C1 可见态第三条件只读:取该版本运行包 status;无就绪运行包返回 null(feed 写侧据此拒写) + RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); + return pkg == null ? null : pkg.getStatus(); + } + /** * 预览归属校验:仅版本 owner(创作者本人)可预览未发布运行包 * diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/DbPackageStore.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/DbPackageStore.java new file mode 100644 index 00000000..ce514d75 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/DbPackageStore.java @@ -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 C4,MVP 无 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) { + // 防御:无运行包行无法落 manifest(PackageFactory 应先建 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(); + } + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/PackageStore.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/PackageStore.java new file mode 100644 index 00000000..ba6bf09d --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/pkg/store/PackageStore.java @@ -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 C4):putManifest 写入与 getManifest 读出必须是同一原始字节串, + * 不得 parse/re-serialize/加换行——manifest 端点据此原样返回,宿主对响应文本算 sha256 与 checksum 严格比对。 + * + * @author 造梦AI + */ +public interface PackageStore { + + /** + * 写入指定版本的整包 manifest 原始 JSON(PackageFactory 落包时调用) + * + * @param versionId 版本 ID(定位 game_runtime_package 行) + * @param json manifest 原始 JSON 字符串(原样字节,调用方据相同字节计算 checksum) + */ + void putManifest(Long versionId, String json); + + /** + * 读取指定版本的整包 manifest 原始 JSON(manifest 端点服务时调用) + * + * @param versionId 版本 ID + * @return manifest 原始 JSON 字符串;该版本无运行包或未写入 package_json 时返回 null + */ + String getManifest(Long versionId); + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java index 1941cb4b..39d87a7c 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/convert/runtime/RuntimeConvertTest.java @@ -74,4 +74,21 @@ class RuntimeConvertTest { assertNull(RuntimeConvert.toPackageRespVO(null)); } + // ============================== §3.4 C4:MVP 无 OSS manifestUrl 指向 DB 端点 ============================== + + @Test + void testToPackageRespVO_mvpEmptyPackageUrl_manifestUrlPointsToEndpoint() { + // MVP 无 OSS(packageUrl 空,整包存 DB):manifestUrl 指向 /app-api/runtime/package/{versionId}/manifest 端点 + RuntimePackageDO pkg = new RuntimePackageDO(); + pkg.setGameId(1024L); + pkg.setVersionId(2048L); + pkg.setPackageUrl(""); // MVP:包存 DB,packageUrl 为空 + pkg.setSandboxAttr("allow-scripts"); + pkg.setAllowOrigins(""); + + RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg); + + assertEquals("/app-api/runtime/package/2048/manifest", vo.getManifestUrl()); + } + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java index deedddc2..65bbeca0 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/controller/app/event/vo/EnvelopeReqVO.java @@ -20,6 +20,10 @@ import java.util.Map; @Data 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") @NotBlank(message = "事件名不能为空") private String event; diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/dataobject/event/TelemetryEventDO.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/dataobject/event/TelemetryEventDO.java index 6cd7c22b..6506a2ba 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/dataobject/event/TelemetryEventDO.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/dataobject/event/TelemetryEventDO.java @@ -26,6 +26,13 @@ public class TelemetryEventDO extends TenantBaseDO { * 事件记录 ID */ private Long id; + /** + * 事件实例 UUID(黄金闭环 §3.5 C5,V10 新增 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}) */ diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java index c0ff9880..c5916480 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/event/TelemetryEventMapper.java @@ -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.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import org.apache.ibatis.annotations.Mapper; /** * 遥测原始事件 Mapper * - * 消费侧幂等落库的写入入口;幂等去重依赖表 uk_dedup(trace_id, event, ts), - * 重复信封落库时的 INSERT IGNORE 语义在消费侧实现(留 MQ 对接点)。 + * 黄金闭环 §3.5 C5 同步写:幂等去重依赖表 uk_event_id(event_id)(V10 起替换旧 uk_dedup)。 + * 消费侧(同步写本地事务)据「按 event_id 预查 + 真插入」判定幂等:同 eventId 重复投递只落一条、只聚合一次。 * * @author 绘境AI */ @Mapper public interface TelemetryEventMapper extends BaseMapperX { + + /** + * 按事件实例 UUID 取原始事件(§3.5 C5 幂等预查:命中即已落库,跳过累加,避免重放重计数) + * + * 命中 uk_event_id 唯一键;同一信封重复投递时返回已落行,消费侧据此判定「非真插入」不再累加。 + * + * @param eventId 事件实例 UUID + * @return 已落原始事件;不存在返回 null + */ + default TelemetryEventDO selectByEventId(String eventId) { + return selectOne(new LambdaQueryWrapperX() + .eq(TelemetryEventDO::getEventId, eventId)); + } + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java index 0343b1db..dfe83051 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java @@ -7,6 +7,8 @@ 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 java.time.LocalDate; + /** * 游戏维度聚合 Mapper(看板数据源只读端) * @@ -46,4 +48,19 @@ public interface GameStatMapper extends BaseMapperX { .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() + .eq(GameStatDO::getGameId, gameId) + .eq(GameStatDO::getStatDate, statDate)); + } + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java index 3943cdba..3240848a 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java @@ -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.EventBatchReqVO; 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.feed.api.FeedApi; +import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; +import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; 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 static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; /** - * 事件摄取 Service 实现(上报通道) + * 事件摄取 Service 实现(上报通道,黄金闭环 §3.5 C5 同步写本地事务,最核心数据回路) * - * 全部轻量校验规则(批量上限/信封必填/事件名注册表)在此承载,可单测、可追溯。 - * MQ 投递为外部依赖,本骨架留清晰中文 TODO 对接点(不 mock、不实现); - * 落原始表与增量聚合在消费侧异步完成,详见 mq.consumer 对接点说明。 + * v1→v2 关键改动(HJ-EXEC-006 §3.5):把原 dispatchToMq() 占位换成同步本地 @Transactional 落库 + 聚合 + 回灌: + * 逐信封按 event_id INSERT(uk_event_id 幂等)→ 仅「真插入(affected>0)」才累加 game_telemetry_game_stat + * → 算 quality_score → 调 FeedApi.upsertRank(QUALITY_REFRESH) 回灌 feed rank → 置该事件 process_status=1 已聚合; + * 任一步失败整体回滚(事件不计)。MQ producer/consumer 仅留契约 + TODO(不实现)。 * * @author 绘境AI */ @@ -29,16 +45,33 @@ public class EventIngestServiceImpl implements EventIngestService { /** 单次批量上限(契约 maxItems=200;超限整体 rejected,前端分批重传) */ private static final int MAX_BATCH_SIZE = 200; + /** quality_score 权重(§3.5 口径):完玩率 60 + like 率 20 + share 率 20,clamp[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。 - * message body = { envelope: EnvelopeReqVO },keys=traceId,tag=event|perf,orderly=false; - * 投递失败本地落兜底表后定时补投(不丢事件)。待 yudao-spring-boot-starter-mq 的 producer 就绪后注入。 - * 见 contracts/api-schemas/telemetry.yaml#x-mq-contracts.producer。 + * feed 算分回灌 seam(仅依赖 -api,§3.5 C5)。MVP 单体由 feed 模块 FeedApiImpl(@RestController @Primary) 就地解析, + * 在本同步写 @Transactional 内本地调用(非 Feign),抛错则整批回滚。 + */ + @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-contracts。MVP 同步写已满足闭环,不引入 MQ。 */ - // @Resource - // private RocketMQTemplate rocketMQTemplate; @Override + @Transactional(rollbackFor = Exception.class) // §3.5 C5 同步写:落库+聚合+回灌+置已聚合在同一本地事务,任一步失败整批回滚 public EventBatchResultVO ingestBatch(EventBatchReqVO reqVO, Long userId) { // 批量上限:超限整体拒绝(统一错误码出口,前端据此分批重传) if (reqVO.getBatch().size() > MAX_BATCH_SIZE) { @@ -49,18 +82,17 @@ public class EventIngestServiceImpl implements EventIngestService { int accepted = 0; int rejected = 0; - // 部分成功语义:逐条轻量校验,通过则投递 MQ,失败计 rejected(不中断整批) + // 部分成功语义:逐条轻量校验,通过则同步写(落库+聚合+回灌),失败计 rejected(不中断整批) for (EnvelopeReqVO envelope : reqVO.getBatch()) { if (!isEnvelopeValid(envelope)) { rejected++; continue; } - // 投递 MQ(快速 ACK 后异步入库 + 聚合,幂等键 = traceId+event+ts) - dispatchToMq(envelope, userId); + ingestOne(envelope, userId); accepted++; } // 关键链路日志:受理成功记 INFO,含回执 traceId 与计数,便于全链路排障 - log.info("[ingestBatch] 批量受理完成 batchTraceId={}, accepted={}, rejected={}", batchTraceId, accepted, rejected); + log.info("[ingestBatch] 批量同步写完成 batchTraceId={}, accepted={}, rejected={}", batchTraceId, accepted, rejected); EventBatchResultVO result = new EventBatchResultVO(); result.setAccepted(accepted); @@ -70,31 +102,176 @@ public class EventIngestServiceImpl implements EventIngestService { } @Override + @Transactional(rollbackFor = Exception.class) // 兜底通道单条同步写:与 ingestBatch 同口径(落库+聚合+回灌原子) public Boolean ingestBeacon(EnvelopeReqVO reqVO, Long userId) { - // 兜底通道:页面卸载场景,永远受理成功(不阻塞前端卸载) - // 校验失败也不抛错,仅记日志/丢弃,避免 sendBeacon 因 4xx 触发前端异常 - if (isEnvelopeValid(reqVO)) { - dispatchToMq(reqVO, userId); - } else { - log.warn("[ingestBeacon] 信封校验未通过,丢弃 event={}, traceId={}", reqVO.getEvent(), reqVO.getTraceId()); + // 兜底通道:页面卸载场景,校验失败不抛错(返回受理成功,不触发前端异常); + // 校验通过则同步写——若同步写抛错(极少:DB 故障),由全局异常处理返回错误响应, + // 但 sendBeacon 为 fire-and-forget 不读响应、不阻塞卸载(事务该回滚仍回滚,保证不脏写)。 + if (!isEnvelopeValid(reqVO)) { + log.warn("[ingestBeacon] 信封校验未通过,丢弃 eventId={}, event={}, traceId={}", + reqVO == null ? null : reqVO.getEventId(), + reqVO == null ? null : reqVO.getEvent(), + reqVO == null ? null : reqVO.getTraceId()); + return Boolean.TRUE; } + ingestOne(reqVO, userId); return Boolean.TRUE; } - // ============================== 私有校验/投递辅助 ============================== + // ============================== 同步写核心(§3.5 C5)============================== /** - * 信封轻量校验:必填字段非空 + 事件名在注册表登记 + * 单条信封同步写:落原始事件(eventId 幂等)→ 仅真插入才累加 stat → 算 quality_score → 回灌 feed → 置已聚合 * - * 注:必填字段(event/schemaVersion/ts/traceId)已由 VO @NotBlank/@NotNull 在 Controller 层校验; - * 此处再做一次"事件名注册表"业务校验,未登记即视为 rejected(与契约 rejected 语义一致)。 + * 调用前提:处于 @Transactional 内(由 ingestBatch / ingestOneTx 提供),任一步抛错则整体回滚。 + * + * @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_score(DECIMAL5,2)写回 stat + BigDecimal qualityScore = computeQualityScore(stat); + GameStatDO scoreUpdate = new GameStatDO(); + scoreUpdate.setId(stat.getId()); + scoreUpdate.setQualityScore(qualityScore); + gameStatMapper.updateById(scoreUpdate); + // 5) 回灌 feed rank(QUALITY_REFRESH):quality_score 精度转 DECIMAL10,4;sort_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 → Double,feed 侧落 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_count;game_play_end → play_end_count(+completed_count if completed)+total_duration_ms; + * like → like_count;share → 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 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_count、share率 = share_count / play_count(每次试玩的互动密度;分母为 0 则该率记 0,避免除零)。 + * 注:like率/share率分母口径(play_count)为本实现按"互动密度"取定,§3.5 未显式给出分母——见交付说明待主 agent 复核。 + * + * @param stat 聚合行 + * @return quality_score(DECIMAL5,2,clamp[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 单条信封 - * @return true=通过可投递;false=拒绝 + * @return true=通过可同步写;false=拒绝 */ private boolean isEnvelopeValid(EnvelopeReqVO envelope) { - // 信封必填兜底(beacon 通道未走 @Valid 级联时也安全) + // 信封必填兜底(eventId 缺失计 rejected,§3.5 幂等真身必填) if (envelope == null + || !StringUtils.hasText(envelope.getEventId()) || !StringUtils.hasText(envelope.getEvent()) || !StringUtils.hasText(envelope.getSchemaVersion()) || envelope.getTs() == null @@ -106,21 +283,115 @@ public class EventIngestServiceImpl implements EventIngestService { } /** - * 投递事件到 MQ(对接点) - * - * TODO 对接点(MQ 生产):此处应调用 RocketMQ 生产者投递 topic=telemetry-event。 - * - 幂等键 = (traceId, event, ts),对应原始表 uk_dedup; - * - 投递失败需本地落兜底表后定时补投(不丢事件); - * - 消费侧落 game_telemetry_event + 增量聚合 game_telemetry_game_stat + 回灌 feed/project。 - * 骨架阶段仅记 DEBUG 日志占位,待 MQ producer 就绪后替换为真实投递。 + * 信封 → 原始事件 DO(落库前组装;props 透传原始 JSON 由前端保证为 JSON 文本) * * @param envelope 单条信封 - * @param userId 当前登录用户 ID(补全信封 user 用,匿名为 null) + * @param userId 当前登录用户 ID(匿名为 null) + * @return 原始事件 DO(process_status 初始 0 待聚合,由同步写置 1) */ - private void dispatchToMq(EnvelopeReqVO envelope, Long userId) { - // TODO 对接点:rocketMQTemplate.asyncSend("telemetry-event", buildMessage(envelope, userId), callback) - log.debug("[dispatchToMq] TODO 待投递 MQ event={}, traceId={}, userId={}", - envelope.getEvent(), envelope.getTraceId(), userId); + private TelemetryEventDO buildEventDO(EnvelopeReqVO envelope, Long userId) { + TelemetryEventDO eventDO = new TelemetryEventDO(); + eventDO.setEventId(envelope.getEventId()); + 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(""); + } + // props:DO 以字符串承载(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.userId(string→Long),回退当前登录 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 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 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 空安全转 long(null→0) */ + private static long nz(Long v) { + return v == null ? 0L : v; + } + + /** + * 字符串安全转 Long(空/非法返回 null;信封 context.gameId/versionId、user.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; + } } /** diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java index d909f0aa..3bb43b19 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java @@ -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.EventBatchReqVO; 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.test.core.ut.BaseMockitoUnitTest; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; +import org.mockito.Mock; +import java.math.BigDecimal; import java.util.ArrayList; +import java.util.HashMap; 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 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) * - * 覆盖核心摄取规则守卫:批量上限、信封轻量校验(事件名注册表/必填)、部分成功计数、beacon 恒受理。 - * 这些规则是上报通道的契约语义,必须有测试兜底。MQ 投递为对接点(仅日志占位),不在单测断言范围。 + * 覆盖黄金闭环 §3.5 C5 同步写:批量上限、信封轻量校验(eventId/event 必填 + 注册表)、部分成功计数、 + * eventId 幂等跳过、计数累加、quality_score 算分 + feed 回灌、beacon 恒受理。 * * @author 绘境AI */ @@ -27,11 +43,18 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { @InjectMocks private EventIngestServiceImpl eventIngestService; + @Mock + private TelemetryEventMapper telemetryEventMapper; + @Mock + private GameStatMapper gameStatMapper; + @Mock + private FeedApi feedApi; // §3.5 算分回灌 seam + // ============================== ingestBatch 批量上限 ============================== @Test void testIngestBatch_sizeExceeded() { - // 201 条(> 上限 200)→ 整体拒绝 + // 201 条(> 上限 200)→ 整体拒绝(在落库前即抛,无 DB 交互) EventBatchReqVO reqVO = new EventBatchReqVO(); List batch = new ArrayList<>(); for (int i = 0; i < 201; i++) { @@ -42,21 +65,23 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { ServiceException ex = assertThrows(ServiceException.class, () -> eventIngestService.ingestBatch(reqVO, 99L)); assertEquals(TELEMETRY_BATCH_SIZE_EXCEEDED.getCode(), ex.getCode()); + verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class)); } - // ============================== ingestBatch 部分成功(注册表校验) ============================== + // ============================== ingestBatch 部分成功(eventId/注册表/必填校验) ============================== @Test 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(); List batch = new ArrayList<>(); - batch.add(validEnvelope("game_play_start", "t1")); // 已登记 - batch.add(validEnvelope("like", "t2")); // 已登记 - batch.add(validEnvelope("not_registered_evt", "t3")); // 未登记 → rejected - EnvelopeReqVO missing = validEnvelope("share", "t4"); - missing.setTraceId(""); // traceId 缺失 → rejected - batch.add(missing); + batch.add(validEnvelope("game_play_start", "t1")); // 已登记 + eventId 齐全 + batch.add(validEnvelope("like", "t2")); // 已登记 + eventId 齐全 + batch.add(validEnvelope("not_registered_evt", "t3")); // 未登记 → rejected + EnvelopeReqVO missingEventId = validEnvelope("share", "t4"); + missingEventId.setEventId(""); // eventId 缺失 → rejected(§3.5 幂等真身必填) + batch.add(missingEventId); reqVO.setBatch(batch); EventBatchResultVO result = eventIngestService.ingestBatch(reqVO, 99L); @@ -64,28 +89,115 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { assertEquals(2, result.getAccepted()); assertEquals(2, result.getRejected()); 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 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 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()); + // 回灌 feed:mode=QUALITY_REFRESH,gameId/versionId 透传;quality_score=60×完玩率(1.0)=60 + ArgumentCaptor 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 事件命中既有 stat(play_count=2,like_count=1)→ like_count 累加为 2;quality_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 1→2(仅累加 like 涉及列) + ArgumentCaptor 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 恒受理 ============================== @Test - void testIngestBeacon_validAlwaysTrue() { - Boolean ok = eventIngestService.ingestBeacon(validEnvelope("perf_first_screen", "t-beacon"), 99L); + void testIngestBeacon_invalidStillTrue() { + // 未登记事件:beacon 通道不抛错,仍恒返回 true(不阻塞前端卸载,无 DB 交互) + Boolean ok = eventIngestService.ingestBeacon(validEnvelope("bad_event", "t-beacon"), null); assertTrue(ok); + verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class)); } @Test - void testIngestBeacon_invalidStillTrue() { - // 未登记事件:beacon 通道不抛错,仍恒返回 true(不阻塞前端卸载) - Boolean ok = eventIngestService.ingestBeacon(validEnvelope("bad_event", "t-beacon"), null); + void testIngestBeacon_validNoGameIdAcceptedTrue() { + // 合法但无 game_id:落事件、置已聚合、不回灌,返回 true + when(telemetryEventMapper.selectByEventId(anyString())).thenReturn(null); + Boolean ok = eventIngestService.ingestBeacon(validEnvelope("perf_first_screen", "t-beacon"), 99L); assertTrue(ok); + verify(telemetryEventMapper).insert(any(TelemetryEventDO.class)); + verifyNoInteractions(feedApi); } // ============================== 测试夹具 ============================== - /** 构造一条信封必填项齐全的事件 */ + /** 构造一条信封必填项齐全的事件(含 eventId;无 context,不触发聚合) */ private static EnvelopeReqVO validEnvelope(String event, String traceId) { EnvelopeReqVO envelope = new EnvelopeReqVO(); + envelope.setEventId(UUID.randomUUID().toString()); envelope.setEvent(event); envelope.setSchemaVersion("v1"); envelope.setTs(1733600000000L); @@ -93,4 +205,24 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { return envelope; } + /** 构造带 context(game_id/version_id) + props 的事件(触发聚合 + 回灌) */ + private static EnvelopeReqVO gameEnvelope(String event, String traceId, String gameId, String versionId, Map 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 batch = new ArrayList<>(); + batch.add(env); + reqVO.setBatch(batch); + return reqVO; + } + }