diff --git a/.gitignore b/.gitignore index b10d7050..c018bce2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,15 +17,18 @@ coverage/ .DS_Store .gstack/ -# ── game-admin(yudao 前端 fork)作用域例外 ── -# yudao 约定将 build/(vite 插件配置源码)与 .env*(前端构建配置,非密钥)纳入版本库; -# 上面为 Java 产物/密钥设计的笼统 build/、.env、.env.* 规则会误吞它们, -# 导致克隆后缺源码(vite.config 解析 ./build/vite 失败)无法构建。故在 game-admin 子目录内豁免, -# 不影响后端模块的 build/ 产物忽略。 +# ── 例外 1:源码树(src/)下名为 build 的目录是合法源码,绝非构建产物 ── +# 第 5 行笼统的 build/(为构建产物设计)会误吞 Java/前端源码中名为 build 的包/目录,例如: +# 后端 cn.wanxiang.game.module.runtime.{dal.dataobject,dal.mysql,service}.build(编译任务域,4 文件)、 +# 前端 game-admin/src/views/infra/build。 +# 构建产物绝不落在 src/ 下,故一律豁免 src/ 下的 build/,避免克隆/CI 缺源码而编译失败。 +!**/src/**/build/ +!**/src/**/build/** + +# ── 例外 2:game-admin(yudao 前端 fork)模块根级例外 ── +# yudao 约定将「模块根」build/(vite 插件配置源码,不在 src 下,故需单独豁免)与 .env*(前端构建配置,非密钥)纳入版本库。 !game-admin/build/ !game-admin/build/** -!game-admin/src/views/infra/build/ -!game-admin/src/views/infra/build/** !game-admin/.env !game-admin/.env.dev !game-admin/.env.local diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/build/RuntimeBuildDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/build/RuntimeBuildDO.java new file mode 100644 index 00000000..a0c2eb0f --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/dataobject/build/RuntimeBuildDO.java @@ -0,0 +1,78 @@ +package cn.wanxiang.game.module.runtime.dal.dataobject.build; + +import cn.wanxiang.game.module.runtime.enums.BuildStatusEnum; +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; + +import java.time.LocalDateTime; + +/** + * 编译任务 DO(对应表 game_runtime_build,GameConfig→版本化运行包,异步幂等任务) + * + * 继承 {@link TenantBaseDO}:自动携带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列。 + * 状态机合法性由 RuntimeBuildService 校验,DO 层只承载数据。 + * 编译消费该 version 的 GamePackage(#4),做 GameConfig 静态校验(T-RT-16)/体积门禁(T-RT-08)/打包(T-RT-02)/版本化上传(T-RT-03)——这些为外部对接点。 + * + * @author 绘境AI + */ +@TableName("game_runtime_build") +@KeySequence("game_runtime_build_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略 +@Data +@EqualsAndHashCode(callSuper = true) +public class RuntimeBuildDO extends TenantBaseDO { + + /** + * 编译任务 ID(buildId) + */ + private Long id; + /** + * 游戏 ID(project.game_project.id) + */ + private Long gameId; + /** + * 版本 ID(project.game_version.id = GamePackage.versionId) + */ + private Long versionId; + /** + * 玩法模板 ID(决定 Runtime 容器 / 校验规则) + */ + private String templateId; + /** + * 编译状态机 + * + * 枚举 {@link BuildStatusEnum}:0排队 1编译中 2成功 3失败 + */ + private Integer status; + /** + * 产出 GamePackage(manifest)OSS/CDN URL(成功时回写,对齐 #4 manifest;失败为空) + */ + private String packageUrl; + /** + * 包总字节(体积门禁 T-RT-08:≤10MB;首屏≤2MB 由编译期单独校验) + */ + private Long bundleSize; + /** + * 整包 sha256(hex,#4 manifest.checksum,完整性校验) + */ + private String checksum; + /** + * 链路追踪 trace_id(贯穿 生成→编译→加载→运行,T-RT-28) + */ + private String traceId; + /** + * 失败错误码(如 1-102-002-001 GameConfig 校验失败 / 1-102-002-002 体积超限;成功为空) + */ + private String failCode; + /** + * 失败原因(回填运营,成功为空) + */ + private String failReason; + /** + * 编译完成时间(成功/失败均记) + */ + private LocalDateTime finishTime; + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/mysql/build/RuntimeBuildMapper.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/mysql/build/RuntimeBuildMapper.java new file mode 100644 index 00000000..0d724e19 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/dal/mysql/build/RuntimeBuildMapper.java @@ -0,0 +1,39 @@ +package cn.wanxiang.game.module.runtime.dal.mysql.build; + +import cn.wanxiang.game.module.runtime.dal.dataobject.build.RuntimeBuildDO; +import cn.wanxiang.game.module.runtime.enums.BuildStatusEnum; +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.util.Arrays; + +/** + * 编译任务 Mapper + * + * @author 绘境AI + */ +@Mapper +public interface RuntimeBuildMapper extends BaseMapperX { + + /** + * 查同版本「可复用」的编译任务(进行中=排队/编译中 或 已成功),用于编译幂等 + * + * 命中 idx_version_status 索引;取最新一条。 + * + * @param versionId 版本 ID + * @return 可复用任务;无则 null + */ + default RuntimeBuildDO selectReusableByVersionId(Long versionId) { + return selectOne(new LambdaQueryWrapperX() + .eq(RuntimeBuildDO::getVersionId, versionId) + // 排队/编译中/成功 三态可复用(失败不复用,需重编) + .in(RuntimeBuildDO::getStatus, Arrays.asList( + BuildStatusEnum.QUEUING.getStatus(), + BuildStatusEnum.BUILDING.getStatus(), + BuildStatusEnum.SUCCESS.getStatus())) + .orderByDesc(RuntimeBuildDO::getId) + .last("LIMIT 1")); + } + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildService.java new file mode 100644 index 00000000..c2d15a8a --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildService.java @@ -0,0 +1,35 @@ +package cn.wanxiang.game.module.runtime.service.build; + +import cn.wanxiang.game.module.runtime.controller.admin.runtime.vo.CompileReqVO; +import cn.wanxiang.game.module.runtime.controller.admin.runtime.vo.CompileRespVO; +import cn.wanxiang.game.module.runtime.dal.dataobject.build.RuntimeBuildDO; + +/** + * 编译任务 Service 接口 + * + * 承载编译幂等(同版本进行中/已成功任务复用)、编译状态机; + * 真正的编译执行(GameConfig 校验 / 体积门禁 / 打包 / 版本化上传)为外部对接点,骨架不实现。 + * + * @author 绘境AI + */ +public interface RuntimeBuildService { + + /** + * 触发编译(GameConfig→版本化运行包,消费 aigc 产物 ←#4) + * + * 异步幂等:同一 versionId 重复触发命中进行中/已成功任务返回同一 buildId(force=true 时强制重编)。 + * + * @param reqVO 版本 ID + force + * @return 编译任务 ID + 是否复用 + */ + CompileRespVO triggerCompile(CompileReqVO reqVO); + + /** + * 查询编译任务(发布编排/运营轮询) + * + * @param buildId 编译任务 ID + * @return 编译任务 DO + */ + RuntimeBuildDO getBuild(Long buildId); + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildServiceImpl.java new file mode 100644 index 00000000..7af60557 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildServiceImpl.java @@ -0,0 +1,74 @@ +package cn.wanxiang.game.module.runtime.service.build; + +import cn.wanxiang.game.module.runtime.controller.admin.runtime.vo.CompileReqVO; +import cn.wanxiang.game.module.runtime.controller.admin.runtime.vo.CompileRespVO; +import cn.wanxiang.game.module.runtime.dal.dataobject.build.RuntimeBuildDO; +import cn.wanxiang.game.module.runtime.dal.mysql.build.RuntimeBuildMapper; +import cn.wanxiang.game.module.runtime.enums.BuildStatusEnum; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import static cn.wanxiang.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_BUILD_NOT_EXISTS; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 编译任务 Service 实现 + * + * 业务规则(编译幂等 / 状态机)在此承载,可单测、可追溯。 + * 真正的编译执行(拉取 GamePackage #4 → GameConfig 校验 → 体积门禁 → 打包 → 版本化上传 → 回写运行包) + * 为外部对接点,骨架阶段只落「排队」任务并返回 buildId,不驱动执行引擎。 + * + * @author 绘境AI + */ +@Slf4j +@Service +public class RuntimeBuildServiceImpl implements RuntimeBuildService { + + @Resource + private RuntimeBuildMapper runtimeBuildMapper; + + @Override + public CompileRespVO triggerCompile(CompileReqVO reqVO) { + CompileRespVO resp = new CompileRespVO(); + // 幂等:force=false 时,命中同版本「进行中/已成功」任务则复用(避免重复编译,对齐契约 #1 异步幂等语义) + boolean force = Boolean.TRUE.equals(reqVO.getForce()); + if (!force) { + RuntimeBuildDO reusable = runtimeBuildMapper.selectReusableByVersionId(reqVO.getVersionId()); + if (reusable != null) { + log.info("[triggerCompile] 命中可复用编译任务,复用 buildId={}, versionId={}", + reusable.getId(), reqVO.getVersionId()); + resp.setBuildId(reusable.getId()); + resp.setReused(true); + return resp; + } + } + // 落一条排队任务(status=0 排队) + RuntimeBuildDO build = new RuntimeBuildDO(); + build.setVersionId(reqVO.getVersionId()); + build.setStatus(BuildStatusEnum.QUEUING.getStatus()); + build.setBundleSize(0L); + // TODO 对接点【编译执行引擎】:此处仅落排队任务,未真正触发编译。待编译执行引擎(异步 Job/MQ 消费者)就绪后: + // 1) 读取该 versionId 的 GamePackage(#4),回填 gameId/templateId/traceId; + // 2) 执行 GameConfig 静态校验(T-RT-16)/体积门禁(T-RT-08,超限抛 RUNTIME_BUILD_BUNDLE_SIZE_EXCEED); + // 3) Manifest 生成与打包(T-RT-02)、资源版本化上传 OSS/CDN(T-RT-03); + // 4) 成功置 status=2 并回写 package_url/checksum/bundle_size, + // 同时 upsert game_runtime_package(status=0 预览就绪)+ 按决策5 回写 project.game_version 三字段;失败置 status=3 回填 fail_code/fail_reason。 + runtimeBuildMapper.insert(build); + log.info("[triggerCompile] 新建编译任务 buildId={}, versionId={}(骨架仅排队,待执行引擎驱动)", + build.getId(), reqVO.getVersionId()); + resp.setBuildId(build.getId()); + resp.setReused(false); + return resp; + } + + @Override + public RuntimeBuildDO getBuild(Long buildId) { + RuntimeBuildDO build = runtimeBuildMapper.selectById(buildId); + if (build == null) { + throw exception(RUNTIME_BUILD_NOT_EXISTS); + } + return build; + } + +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildServiceImplTest.java new file mode 100644 index 00000000..173fc489 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/cn/wanxiang/game/module/runtime/service/build/RuntimeBuildServiceImplTest.java @@ -0,0 +1,111 @@ +package cn.wanxiang.game.module.runtime.service.build; + +import cn.wanxiang.game.module.runtime.controller.admin.runtime.vo.CompileReqVO; +import cn.wanxiang.game.module.runtime.controller.admin.runtime.vo.CompileRespVO; +import cn.wanxiang.game.module.runtime.dal.dataobject.build.RuntimeBuildDO; +import cn.wanxiang.game.module.runtime.dal.mysql.build.RuntimeBuildMapper; +import cn.wanxiang.game.module.runtime.enums.BuildStatusEnum; +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 static cn.wanxiang.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_BUILD_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link RuntimeBuildServiceImpl} 单元测试(纯 Mockito,不依赖 DB) + * + * 覆盖核心业务规则守卫:编译幂等(命中复用)、新建排队任务、编译任务不存在。 + * + * @author 绘境AI + */ +class RuntimeBuildServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private RuntimeBuildServiceImpl runtimeBuildService; + + @Mock + private RuntimeBuildMapper runtimeBuildMapper; + + // ============================== triggerCompile 幂等 ============================== + + @Test + void testTriggerCompile_reuseInProgress() { + // 命中同版本可复用任务(进行中/已成功)→ 复用同一 buildId,不新建 + RuntimeBuildDO reusable = new RuntimeBuildDO(); + reusable.setId(555L); + reusable.setVersionId(2048L); + reusable.setStatus(BuildStatusEnum.BUILDING.getStatus()); + when(runtimeBuildMapper.selectReusableByVersionId(2048L)).thenReturn(reusable); + CompileReqVO reqVO = compileReq(2048L, false); + + CompileRespVO resp = runtimeBuildService.triggerCompile(reqVO); + + assertEquals(555L, resp.getBuildId()); + assertTrue(resp.getReused()); + // 复用不应再 insert(带类型消歧) + verify(runtimeBuildMapper, never()).insert(any(RuntimeBuildDO.class)); + } + + @Test + void testTriggerCompile_newQueuing() { + // 无可复用任务 → 新建排队任务(status=0 排队) + when(runtimeBuildMapper.selectReusableByVersionId(2048L)).thenReturn(null); + doAnswer(inv -> { + RuntimeBuildDO d = inv.getArgument(0); + d.setId(777L); + return 1; + }).when(runtimeBuildMapper).insert(any(RuntimeBuildDO.class)); + + CompileRespVO resp = runtimeBuildService.triggerCompile(compileReq(2048L, false)); + + assertEquals(777L, resp.getBuildId()); + assertFalse(resp.getReused()); + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeBuildDO.class); + verify(runtimeBuildMapper).insert(captor.capture()); + assertEquals(BuildStatusEnum.QUEUING.getStatus(), captor.getValue().getStatus()); // 初始排队 + assertEquals(2048L, captor.getValue().getVersionId()); + } + + @Test + void testTriggerCompile_forceSkipsReuse() { + // force=true 强制重编:跳过复用查询,直接新建 + doAnswer(inv -> { + RuntimeBuildDO d = inv.getArgument(0); + d.setId(888L); + return 1; + }).when(runtimeBuildMapper).insert(any(RuntimeBuildDO.class)); + + CompileRespVO resp = runtimeBuildService.triggerCompile(compileReq(2048L, true)); + + assertEquals(888L, resp.getBuildId()); + assertFalse(resp.getReused()); + // force 不查复用 + verify(runtimeBuildMapper, never()).selectReusableByVersionId(any()); + } + + // ============================== getBuild ============================== + + @Test + void testGetBuild_notExists() { + when(runtimeBuildMapper.selectById(1L)).thenReturn(null); + ServiceException ex = assertThrows(ServiceException.class, + () -> runtimeBuildService.getBuild(1L)); + assertEquals(RUNTIME_BUILD_NOT_EXISTS.getCode(), ex.getCode()); + } + + // ============================== 测试夹具 ============================== + + private static CompileReqVO compileReq(Long versionId, boolean force) { + CompileReqVO reqVO = new CompileReqVO(); + reqVO.setVersionId(versionId); + reqVO.setForce(force); + return reqVO; + } + +}