From 640d9268c06ccdbe343a6746b7c4b445e5571b56 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 22 Jul 2026 21:37:43 -0700 Subject: [PATCH] feat(release): enforce dogfood publish safety loop --- contracts/api-schemas/runtime.yaml | 14 +++-- ...3.0.0__review_record_add_artifact_hash.sql | 3 + .../project/enums/ErrorCodeConstants.java | 4 ++ .../dal/dataobject/review/ReviewRecordDO.java | 2 + .../service/project/ProjectServiceImpl.java | 26 ++++++++- .../publish/PublishOrchestrationService.java | 14 ++--- .../PublishOrchestrationServiceImpl.java | 11 ++-- .../project/ProjectServiceImplTest.java | 42 +++++++++++++- .../PublishOrchestrationServiceImplTest.java | 22 +++++++- .../module/runtime/api/RuntimePackageApi.java | 7 ++- .../runtime/enums/ErrorCodeConstants.java | 6 ++ .../runtime/api/RuntimePackageApiImpl.java | 10 +++- .../admin/runtime/AdminRuntimeController.java | 14 ++++- .../service/pkg/RuntimePackageService.java | 5 +- .../pkg/RuntimePackageServiceImpl.java | 28 +++++++++- .../AdminRuntimeControllerDogfoodTest.java | 44 +++++++++++++++ .../pkg/RuntimePackageServiceImplTest.java | 55 +++++++++++++++++-- ...3.0.0__review_record_add_artifact_hash.sql | 3 + 18 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 contracts/db-schemas/V33.0.0__review_record_add_artifact_hash.sql create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeControllerDogfoodTest.java create mode 100644 game-cloud/huijing-server/src/main/resources/db/migration/V33.0.0__review_record_add_artifact_hash.sql diff --git a/contracts/api-schemas/runtime.yaml b/contracts/api-schemas/runtime.yaml index b82a4277..719efd60 100644 --- a/contracts/api-schemas/runtime.yaml +++ b/contracts/api-schemas/runtime.yaml @@ -33,7 +33,8 @@ paths: 宿主据返回的 manifest(入口/包URL/checksum/预加载策略)加载游戏并做完整性校验(门禁 Manifest 完整性 T-RT-15)。 取包门禁判定字段 = game_runtime_package.status(运行包状态,非 project.game_version.status): scene=preview 放行 status∈{0 预览就绪, 1 已发布},并校验调用者为版本 owner(创作者本人预览未发布版本); - scene=play 仅放行 status=1(已发布)。无对应就绪运行包返回 1-102-001-001。 + scene=play 仅放行 status=1(已发布);status=2(已失效/下架)继续返回未发布错误 1-102-001-002。 + 无对应就绪运行包返回 1-102-001-001。 parameters: - { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: 版本 ID(project.game_version.id) } - { name: scene, in: query, required: false, schema: { type: string, enum: [preview, play], default: play }, description: 'preview=创作者预览未发布版本 / play=玩家试玩已发布版本' } @@ -154,16 +155,17 @@ paths: tags: [admin-runtime] summary: 发布态回写对接点(编译就绪运行包 → 置为已发布,补全发布态写入链路) description: > - 由 project.publish 统一发布编排(T-PRJ-08)成功后调用(admin-api,RBAC / 内部编排调用,非用户直接触发)。 - 作用:置 game_runtime_package.status=1(已发布,玩家可试玩),并回写 project.game_version.status=3(已发布), - 补全「编译就绪 → 统一发布编排 → 发布态写入」链路的最后一跳,消除原先发布态无写入对接点的断点。 + 非狗粮环境供管理端运营发布;dogfood=true 时该 HTTP 入口固定拒绝并返回 1-102-001-005, + 必须由 project 审核通过后经 RuntimePackageApi 本地编排发布,防止绕过审核绑定。 + 作用:仅置 game_runtime_package.status=1(已发布,玩家可试玩);project.game_version.status=3 由 project 编排负责。 幂等:运行包已是 status=1 时重复调用直接返回成功,不二次写入。 前置:该 versionId 必须已编译成功且存在就绪运行包(status=0 预览就绪);否则返回 1-102-001-001。 parameters: - - { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: '版本 ID(project.game_version.id);其余入参由路径参数即可承载,无需请求体' } + - { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: '版本 ID(project.game_version.id)' } + - { name: expectedArtifactHash, in: query, required: true, schema: { type: string, pattern: '^[0-9a-f]{64}$' }, description: '审核绑定的运行产物 SHA-256;与运行包 checksum 不一致时拒发' } responses: '200': - description: 成功(运行包已置已发布、版本状态已回写) + description: 成功(运行包已置已发布) content: application/json: schema: { $ref: '#/components/schemas/CommonResultBoolean' } diff --git a/contracts/db-schemas/V33.0.0__review_record_add_artifact_hash.sql b/contracts/db-schemas/V33.0.0__review_record_add_artifact_hash.sql new file mode 100644 index 00000000..aadab963 --- /dev/null +++ b/contracts/db-schemas/V33.0.0__review_record_add_artifact_hash.sql @@ -0,0 +1,3 @@ +-- 审核批准绑定实际发布产物的 SHA-256;历史记录及拒绝/下架保持空串。 +ALTER TABLE game_review_record + ADD COLUMN artifact_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '审核批准绑定的运行产物 SHA-256'; diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java index 154bd9ec..778a36e7 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java @@ -25,5 +25,9 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_REVIEW_STATUS_INVALID = new ErrorCode(1_100_000_004, "项目当前状态不可审核"); /** 发布编排前置校验:当前生效版本无就绪运行包(status=0 预览就绪),不可发布(§3.2 C2,校验失败回滚事务、审核不落) */ ErrorCode PROJECT_PUBLISH_PACKAGE_NOT_READY = new ErrorCode(1_100_000_005, "当前版本运行包未就绪,不可发布"); + /** 审核批准绑定的版本不是项目当前版本,或版本不存在、不属于该项目。 */ + ErrorCode PROJECT_REVIEW_VERSION_INVALID = new ErrorCode(1_100_000_006, "审核版本与项目当前版本不一致"); + /** 审核批准版本缺少合法的小写 SHA-256 产物摘要。 */ + ErrorCode PROJECT_REVIEW_ARTIFACT_HASH_INVALID = new ErrorCode(1_100_000_007, "审核版本产物摘要无效"); } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/dataobject/review/ReviewRecordDO.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/dataobject/review/ReviewRecordDO.java index 183d1df0..11a90247 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/dataobject/review/ReviewRecordDO.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/dal/dataobject/review/ReviewRecordDO.java @@ -31,6 +31,8 @@ public class ReviewRecordDO extends TenantBaseDO { * 版本 ID */ private Long versionId; + /** 审核批准时绑定的运行产物 SHA-256;拒绝和下架为空串。 */ + private String artifactHash; /** * 审核人用户 ID(人工审核) */ diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java index 912234c1..78786645 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImpl.java @@ -12,10 +12,12 @@ import com.wanxiang.huijing.game.module.project.controller.app.project.vo.GateRe import com.wanxiang.huijing.game.module.project.convert.project.ProjectConvert; import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO; import com.wanxiang.huijing.game.module.project.dal.dataobject.review.ReviewRecordDO; +import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectFeedMetaDTO; import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.review.ReviewRecordMapper; +import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; import com.wanxiang.huijing.game.module.project.enums.ProjectStatusEnum; import com.wanxiang.huijing.game.module.project.enums.ReviewDecisionEnum; import com.wanxiang.huijing.game.module.project.service.publish.PublishOrchestrationService; @@ -62,6 +64,9 @@ public class ProjectServiceImpl implements ProjectService { @Resource private ReviewRecordMapper reviewRecordMapper; + @Resource + private GameVersionMapper gameVersionMapper; + /** * 合规锁风门 Gate(compliance → project.publish seam,R1)。 * 同进程内由 compliance-server 的 ComplianceGateApiImpl(@RestController @Primary) 就地解析(仿 huijing DictDataApi);拆微服务后走真实 Feign。 @@ -255,9 +260,25 @@ public class ProjectServiceImpl implements ProjectService { throw exception(PROJECT_NOT_EXISTS); } boolean isApprove = Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision()); + // 先保持既有状态机错误语义,再做批准版本与产物校验;两者都不产生写入。 + Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus()); + String artifactHash = ""; + if (isApprove) { + // 批准必须精确绑定项目当前版本及其产物摘要;所有校验均早于任何写入。 + if (!Objects.equals(reqVO.getVersionId(), project.getCurrentVersionId())) { + throw exception(PROJECT_REVIEW_VERSION_INVALID); + } + GameVersionDO version = gameVersionMapper.selectById(reqVO.getVersionId()); + if (version == null || !Objects.equals(version.getGameId(), project.getId())) { + throw exception(PROJECT_REVIEW_VERSION_INVALID); + } + artifactHash = version.getChecksum(); + if (artifactHash == null || !artifactHash.matches("[0-9a-f]{64}")) { + throw exception(PROJECT_REVIEW_ARTIFACT_HASH_INVALID); + } + } // 决策 → 目标状态(含合法流转校验):APPROVE 仍校验「仅审核中可通过」,但 α 下项目态不停留 APPROVED(2),由发布编排直翻 PUBLISHED(4); // UNLIST 在此校验「仅 PUBLISHED(4) 可下架」——非法流转在任何写发生之前抛 PROJECT_REVIEW_STATUS_INVALID(重复下架无副作用残留) - Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus()); if (Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.UNLIST.getDecision())) { // 下架编排(数据回路小波·修3):项目态 4→5 + feed_rank 全分区 status=0,同一本地事务,任一步失败全回滚(含审核记录) publishOrchestrationService.unlist(project); @@ -273,6 +294,7 @@ public class ProjectServiceImpl implements ProjectService { ReviewRecordDO record = new ReviewRecordDO(); record.setGameId(reqVO.getGameId()); record.setVersionId(reqVO.getVersionId()); + record.setArtifactHash(artifactHash); record.setReviewerUserId(reviewerUserId); record.setDecision(reqVO.getDecision()); record.setGateResult(isApprove ? "pass" : "block"); @@ -281,7 +303,7 @@ public class ProjectServiceImpl implements ProjectService { // D-PUB=α(§1/§3.2 C2):审核通过即自动发布——在同一本地事务内调发布编排,使项目 REVIEWING(1)→PUBLISHED(4)(跳过常驻 APPROVED(2))。 // 编排注入 RuntimePackageApi/FeedApi 的 @Primary 本地 bean(同进程,非 Feign),任一步失败整体回滚(审核记录与状态同回滚)。 if (isApprove) { - publishOrchestrationService.publish(project); + publishOrchestrationService.publish(project, artifactHash); } } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationService.java index 2b419eae..0700db12 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationService.java @@ -20,20 +20,20 @@ public interface PublishOrchestrationService { * 执行发布编排(在调用方本地事务内串联,§3.2 C2 六步) * * @param project 待发布项目 DO(须已查出,提供 id/currentVersionId/launchZoneId) + * @param expectedArtifactHash 审核批准绑定的运行产物 SHA-256 */ - void publish(ProjectDO project); + void publish(ProjectDO project, String expectedArtifactHash); /** * 执行下架编排(review decision=3 UNLIST,发布编排的反向操作,数据回路小波·修3) * - * 两步同一本地事务(与调用方 reviewProject 的 @Transactional 合并,任一步失败全回滚含审核记录): - * ① 翻项目态 status=UNLISTED(5)(合法性「仅 PUBLISHED(4) 可下架」已由调用方 resolveReviewTargetStatus 校验, + * 三步同一本地事务(与调用方 reviewProject 的 @Transactional 合并,任一步失败全回滚含审核记录): + * ① RuntimePackageApi.invalidate(versionId) 将已发布运行包置为已失效,阻断深链试玩; + * ② 翻项目态 status=UNLISTED(5)(合法性「仅 PUBLISHED(4) 可下架」已由调用方 resolveReviewTargetStatus 校验, * 与 publish 不重复校验项目态的既有分工对称); - * ② FeedApi.offlineRank(gameId) 全分区下线 feed_rank(status=0,对齐 V4 DDL「下架联动」设计意图,封死写侧残留)。 + * ③ FeedApi.offlineRank(gameId) 全分区下线 feed_rank(status=0,对齐 V4 DDL「下架联动」设计意图,封死写侧残留)。 * - * 裁决边界(2026-06-10 数据回路小波·修3):game_runtime_package.status 不动(2=已失效语义是包本体失效且不可逆, - * 下架=可见性决策,存量直链经 play 门禁仍可玩属可接受残余);game_version.status 不动(版本状态机无下架态, - * 版本是构建产物事实记录,强行回翻=伪造历史)。 + * game_version.status 不回翻:版本仍是已发布过的构建产物事实,当前可玩性由 runtime status=2 与 feed 下线共同封闭。 * 幂等:状态机拒绝重复——第二次 decision=3 时项目已 UNLISTED(5)≠PUBLISHED,调用方在任何写发生之前抛 * PROJECT_REVIEW_STATUS_INVALID;offlineRank 自身天然幂等(已 0 置 0)。 * diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImpl.java index dda68cb8..3fc35f88 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImpl.java @@ -56,7 +56,7 @@ public class PublishOrchestrationServiceImpl implements PublishOrchestrationServ @Override @Transactional(rollbackFor = Exception.class) // 与调用方 reviewProject 事务合并(REQUIRED);六步任一失败全回滚 - public void publish(ProjectDO project) { + public void publish(ProjectDO project, String expectedArtifactHash) { Long versionId = project.getCurrentVersionId(); log.info("[publish] 发布编排开始 gameId={}, currentVersionId={}", project.getId(), versionId); @@ -71,7 +71,7 @@ public class PublishOrchestrationServiceImpl implements PublishOrchestrationServ } // ② 翻包 0→1 已发布(复用 runtime publishPackage 逻辑;version/project 回写不在 runtime,归本编排,Codex H6) - runtimePackageApi.publish(versionId); + runtimePackageApi.publish(versionId, expectedArtifactHash); // ③ 翻版本态 game_version(versionId).status=3 已发布(版本权威归 project,本编排显式写,不依赖 runtime TODO) gameVersionService.markPublished(versionId); @@ -123,14 +123,17 @@ public class PublishOrchestrationServiceImpl implements PublishOrchestrationServ public void unlist(ProjectDO project) { log.info("[unlist] 下架编排开始 gameId={}", project.getId()); - // ① 翻项目态 project.status=UNLISTED(5) + // 先封死 runtime 深链;失败时后续项目态与 feed 均不写,事务整体回滚。 + runtimePackageApi.invalidate(project.getCurrentVersionId()).getCheckedData(); + + // ② 翻项目态 project.status=UNLISTED(5) // 合法性「仅 PUBLISHED(4) 可下架」已由调用方 resolveReviewTargetStatus 校验(与 publish 不重复校验项目态的既有分工对称) ProjectDO update = new ProjectDO(); update.setId(project.getId()); update.setStatus(ProjectStatusEnum.UNLISTED.getStatus()); projectMapper.updateById(update); - // ② feed_rank 全分区下线(status=0,对齐 V4 DDL「下架联动」设计意图,封死写侧残留) + // ③ feed_rank 全分区下线(status=0,对齐 V4 DDL「下架联动」设计意图,封死写侧残留) // 经 FeedApi @Primary 本地 bean 事务内本地调用(非 Feign);feed 侧抛错则本编排抛出 → reviewProject 事务整体回滚(含审核记录)。 // 行数 0 也合法(无基线行的存量数据),记审计日志不抛错;裁决:runtime_package/version 状态不动(见接口 javadoc)。 Integer offlinedRows = feedApi.offlineRank(project.getId()).getCheckedData(); diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImplTest.java index 9ae072e2..3768c6c5 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/project/ProjectServiceImplTest.java @@ -9,8 +9,10 @@ import com.wanxiang.huijing.game.module.project.controller.app.project.vo.Projec import com.wanxiang.huijing.game.module.project.controller.app.project.vo.PublicCreatorProjectRespVO; import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO; import com.wanxiang.huijing.game.module.project.dal.dataobject.review.ReviewRecordDO; +import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.review.ReviewRecordMapper; +import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; import com.wanxiang.huijing.game.module.project.dto.CreatorPublicSummaryDTO; import com.wanxiang.huijing.game.module.project.enums.ProjectStatusEnum; import com.wanxiang.huijing.game.module.project.service.publish.PublishOrchestrationService; @@ -56,6 +58,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Mock private ReviewRecordMapper reviewRecordMapper; @Mock + private GameVersionMapper gameVersionMapper; + @Mock private ComplianceGateApi complianceGateApi; // R1 合规锁风门(发布前注入) @Mock private PublishOrchestrationService publishOrchestrationService; // §3.2 C2 发布编排(α auto-publish,APPROVE 时调用) @@ -268,7 +272,9 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { void testReviewProject_approveFromReviewing_autoPublish() { // D-PUB=α:APPROVE 不直接写 APPROVED(2),而是落 pass 审核记录 + 调发布编排(编排内写 PUBLISHED,本测试 mock 编排) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); + project.setCurrentVersionId(2048L); when(projectMapper.selectById(1L)).thenReturn(project); + when(gameVersionMapper.selectById(2048L)).thenReturn(version(2048L, 1L, "a".repeat(64))); ReviewReqVO reqVO = reviewReq(1L, 1); // 1=通过 projectService.reviewProject(reqVO, 7L); @@ -278,12 +284,38 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { verify(reviewRecordMapper).insert(rc.capture()); assertEquals("pass", rc.getValue().getGateResult()); assertEquals(7L, rc.getValue().getReviewerUserId()); + assertEquals("a".repeat(64), rc.getValue().getArtifactHash()); // α:在同一事务内委托发布编排(项目态 → PUBLISHED 由编排承载) - verify(publishOrchestrationService).publish(project); + verify(publishOrchestrationService).publish(project, "a".repeat(64)); // APPROVE 分支不再由本方法直接写项目状态(不调 projectMapper.updateById;PUBLISHED 写入在编排内,已 mock) verify(projectMapper, never()).updateById(any(ProjectDO.class)); } + @Test + void testReviewProject_approveVersionMismatchRejectsBeforeWrites() { + ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); + project.setCurrentVersionId(4096L); + when(projectMapper.selectById(1L)).thenReturn(project); + + assertThrows(ServiceException.class, () -> projectService.reviewProject(reviewReq(1L, 1), 7L)); + + verifyNoInteractions(gameVersionMapper, reviewRecordMapper, publishOrchestrationService); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + } + + @Test + void testReviewProject_approveInvalidChecksumRejectsBeforeWrites() { + ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); + project.setCurrentVersionId(2048L); + when(projectMapper.selectById(1L)).thenReturn(project); + when(gameVersionMapper.selectById(2048L)).thenReturn(version(2048L, 1L, "ABC")); + + assertThrows(ServiceException.class, () -> projectService.reviewProject(reviewReq(1L, 1), 7L)); + + verifyNoInteractions(reviewRecordMapper, publishOrchestrationService); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + } + @Test void testReviewProject_approveOnDraftRejected() { // 草稿态不可被"通过" → 非法流转 @@ -510,4 +542,12 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { return reqVO; } + private static GameVersionDO version(Long id, Long gameId, String checksum) { + GameVersionDO version = new GameVersionDO(); + version.setId(id); + version.setGameId(gameId); + version.setChecksum(checksum); + return version; + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java index 46a8e596..c97df342 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java @@ -47,6 +47,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { void testUnlist_flipsStatusAndOfflinesFeed() { // 两步编排:① projectMapper.updateById(status=UNLISTED(5)) ② feedApi.offlineRank(gameId) 全分区下线 when(feedApi.offlineRank(1L)).thenReturn(CommonResult.success(2)); // 桩:下线 2 行(发布基线 + 精选分区) + when(runtimePackageApi.invalidate(1024L)).thenReturn(CommonResult.success(true)); publishOrchestrationService.unlist(project(1L)); @@ -55,6 +56,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { verify(projectMapper).updateById(captor.capture()); assertEquals(1L, captor.getValue().getId()); assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus()); + verify(runtimePackageApi).invalidate(1024L); // ② feed_rank 全分区下线(status=0) verify(feedApi).offlineRank(1L); } @@ -63,6 +65,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { void testUnlist_zeroOfflinedRowsAccepted() { // 行数 0 也合法(无基线行的存量数据):记日志不抛错,编排正常完成 when(feedApi.offlineRank(1L)).thenReturn(CommonResult.success(0)); + when(runtimePackageApi.invalidate(1024L)).thenReturn(CommonResult.success(true)); assertDoesNotThrow(() -> publishOrchestrationService.unlist(project(1L))); @@ -74,12 +77,24 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { void testUnlist_feedOfflineFailurePropagates() { // feed 侧抛错 → 编排抛出(向上传播使 reviewProject 事务整体回滚;回滚整体性由 staging 冒烟③验证,mock 层只断言传播) when(feedApi.offlineRank(1L)).thenThrow(new RuntimeException("feed 下线失败")); + when(runtimePackageApi.invalidate(1024L)).thenReturn(CommonResult.success(true)); RuntimeException ex = assertThrows(RuntimeException.class, () -> publishOrchestrationService.unlist(project(1L))); assertEquals("feed 下线失败", ex.getMessage()); } + @Test + void testUnlist_runtimeInvalidateFailureStopsProjectAndFeedWrites() { + when(runtimePackageApi.invalidate(1024L)).thenThrow(new RuntimeException("运行包失效失败")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> publishOrchestrationService.unlist(project(1L))); + + assertEquals("运行包失效失败", ex.getMessage()); + verifyNoInteractions(projectMapper, feedApi); + } + // ============================== publish 发布编排:feed 基线双写(创始人 2026-07-05「新游戏发布汇入主混合流」) ============================== @Test @@ -87,7 +102,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { // 创作者选了非零专区 2:应双写——zone0 主混合流基线(汇入,内测默认流可见)+ zone2 专区基线(保留必填选择器语义) when(runtimePackageApi.getStatus(1024L)).thenReturn(CommonResult.success(0)); // 0=预览就绪 PACKAGE_STATUS_PREVIEW_READY - publishOrchestrationService.publish(publishableProject(1L, 1024L, 2L)); + publishOrchestrationService.publish(publishableProject(1L, 1024L, 2L), "a".repeat(64)); ArgumentCaptor captor = ArgumentCaptor.forClass(FeedRankUpsertReqDTO.class); verify(feedApi, times(2)).upsertRank(captor.capture()); @@ -108,7 +123,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { // 创作者就选 zone0 主流:单写一条,不重复写 when(runtimePackageApi.getStatus(1024L)).thenReturn(CommonResult.success(0)); - publishOrchestrationService.publish(publishableProject(1L, 1024L, 0L)); + publishOrchestrationService.publish(publishableProject(1L, 1024L, 0L), "a".repeat(64)); ArgumentCaptor captor = ArgumentCaptor.forClass(FeedRankUpsertReqDTO.class); verify(feedApi, times(1)).upsertRank(captor.capture()); @@ -120,7 +135,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { // launch_zone_id 缺省(存量/未选):回落 zone0 主混合流单写 when(runtimePackageApi.getStatus(1024L)).thenReturn(CommonResult.success(0)); - publishOrchestrationService.publish(publishableProject(1L, 1024L, null)); + publishOrchestrationService.publish(publishableProject(1L, 1024L, null), "a".repeat(64)); ArgumentCaptor captor = ArgumentCaptor.forClass(FeedRankUpsertReqDTO.class); verify(feedApi, times(1)).upsertRank(captor.capture()); @@ -134,6 +149,7 @@ class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { ProjectDO project = new ProjectDO(); project.setId(id); project.setStatus(ProjectStatusEnum.PUBLISHED.getStatus()); + project.setCurrentVersionId(1024L); return project; } diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java index e4e778c4..76fb871d 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java @@ -45,7 +45,12 @@ public interface RuntimePackageApi { @PostMapping(PREFIX + "/publish") @Operation(summary = "发布运行包(翻包 0→1 已发布)") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024") - CommonResult publish(@RequestParam("versionId") Long versionId); + CommonResult publish(@RequestParam("versionId") Long versionId, + @RequestParam("expectedArtifactHash") String expectedArtifactHash); + + /** 下架时使已发布运行包失效,阻断 runtime 深链试玩。 */ + @PostMapping(PREFIX + "/invalidate") + CommonResult invalidate(@RequestParam("versionId") Long versionId); /** * 按版本 ID 反查运行包状态机值(§3.1 C1 可见态第三条件:feed 写侧 PUBLISH_BASELINE enforce 依赖) diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java index fb9f8917..299d9b62 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java @@ -23,6 +23,12 @@ public interface ErrorCodeConstants { ErrorCode RUNTIME_PACKAGE_NOT_PUBLISHED = new ErrorCode(1_102_001_002, "运行包未发布,玩家暂不可试玩"); /** 预览归属校验:scene=preview 仅版本 owner(创作者本人)可预览未发布运行包 */ ErrorCode RUNTIME_PACKAGE_PREVIEW_NOT_OWNER = new ErrorCode(1_102_001_003, "无权预览他人未发布的运行包"); + /** 审核绑定的产物摘要与运行包摘要不一致。 */ + ErrorCode RUNTIME_PACKAGE_ARTIFACT_MISMATCH = new ErrorCode(1_102_001_004, "运行包产物与审核记录不一致"); + /** 狗粮态禁止从管理端直接发布运行包。 */ + ErrorCode RUNTIME_DOGFOOD_DIRECT_PUBLISH_DISABLED = new ErrorCode(1_102_001_005, "狗粮态禁止直接发布运行包"); + /** 运行包不存在或不处于可失效状态。 */ + ErrorCode RUNTIME_PACKAGE_INVALIDATE_REJECTED = new ErrorCode(1_102_001_006, "运行包不存在或状态不可失效"); // ========== 编译 1-102-002-*** (对齐契约 #1 BuildRespVO.failCode)========== /** GameConfig 静态校验失败(门禁 T-RT-16,编译期对接点判定) */ diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java index 6e3e807c..1aec8310 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java @@ -28,9 +28,15 @@ public class RuntimePackageApiImpl implements RuntimePackageApi { private RuntimePackageService runtimePackageService; @Override - public CommonResult publish(Long versionId) { + public CommonResult publish(Long versionId, String expectedArtifactHash) { // 发布翻包:委托 RuntimePackageService.publishPackage(0→1 已发布;幂等;未就绪抛 RUNTIME_PACKAGE_NOT_READY) - runtimePackageService.publishPackage(versionId); + runtimePackageService.publishPackage(versionId, expectedArtifactHash); + return success(Boolean.TRUE); + } + + @Override + public CommonResult invalidate(Long versionId) { + runtimePackageService.invalidate(versionId); return success(Boolean.TRUE); } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeController.java index d7d5817c..56f58186 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeController.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeController.java @@ -13,6 +13,7 @@ import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageServic import com.wanxiang.huijing.game.module.runtime.service.session.RuntimeSessionService; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.pojo.PageResult; +import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,6 +24,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_DOGFOOD_DIRECT_PUBLISH_DISABLED; /** * 管理后台(game-admin)- runtime 控制器(编译编排 + 编译状态 + 发布态回写 + 会话观测) @@ -45,6 +47,9 @@ public class AdminRuntimeController { @Resource private RuntimePackageService runtimePackageService; + @Resource + private DogfoodAccessGuard dogfoodAccessGuard; + @Resource private RuntimeSessionService runtimeSessionService; @@ -65,11 +70,14 @@ public class AdminRuntimeController { } @PostMapping("/package/{versionId}/publish") - @Operation(summary = "发布态回写对接点", description = "编译就绪运行包→置已发布,并回写 project.game_version.status=3(补全发布态写入链路);幂等") + @Operation(summary = "发布态回写对接点", description = "非狗粮环境将编译就绪运行包置为已发布;狗粮环境必须经 project 审核编排;幂等") @PreAuthorize("@ss.hasPermission('runtime:package:publish')") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") - public CommonResult publishPackage(@PathVariable("versionId") Long versionId) { - runtimePackageService.publishPackage(versionId); + public CommonResult publishPackage(@PathVariable("versionId") Long versionId, + @RequestParam("expectedArtifactHash") String expectedArtifactHash) { + // 仅 HTTP 管理入口受狗粮门约束;project 的本地 API 编排不经过此控制器。 + dogfoodAccessGuard.rejectInDogfood("runtime.admin.package.publish", RUNTIME_DOGFOOD_DIRECT_PUBLISH_DISABLED); + runtimePackageService.publishPackage(versionId, expectedArtifactHash); return success(true); } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java index 3f5300ad..7978379b 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java @@ -37,7 +37,10 @@ public interface RuntimePackageService { * * @param versionId 版本 ID */ - void publishPackage(Long versionId); + void publishPackage(Long versionId, String expectedArtifactHash); + + /** 将已发布运行包置为已失效;已失效时幂等返回。 */ + void invalidate(Long versionId); /** * 取版本运行包状态机值(§3.1 C1 可见态第三条件:供 feed 写侧 PUBLISH_BASELINE enforce) diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java index 165d632e..24bea9dd 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java @@ -17,6 +17,8 @@ import java.util.Objects; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_PUBLISHED; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_PREVIEW_NOT_OWNER; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_INVALIDATE_REJECTED; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; /** @@ -73,12 +75,16 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { } @Override - public void publishPackage(Long versionId) { + public void publishPackage(Long versionId, String expectedArtifactHash) { RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); // 前置:必须已编译成功且存在就绪运行包,否则对齐契约 #1:返回 1-102-001-001 if (pkg == null) { throw exception(RUNTIME_PACKAGE_NOT_READY); } + if (!Objects.equals(pkg.getChecksum(), expectedArtifactHash)) { + log.warn("[publishPackage] 产物摘要不一致,拒绝发布 versionId={}", versionId); + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } // 幂等:已是 status=1 已发布,重复调用直接返回,不二次写入 if (PackageStatusEnum.isPublished(pkg.getStatus())) { log.info("[publishPackage] 运行包已发布,幂等返回 versionId={}", versionId); @@ -99,6 +105,26 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { versionId, pkg.getId()); } + @Override + public void invalidate(Long versionId) { + RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); + if (pkg == null) { + log.warn("[invalidatePackage] 运行包不存在,拒绝失效 versionId={}", versionId); + throw exception(RUNTIME_PACKAGE_INVALIDATE_REJECTED); + } + if (PackageStatusEnum.EXPIRED.getStatus().equals(pkg.getStatus())) { + return; + } + if (!PackageStatusEnum.PUBLISHED.getStatus().equals(pkg.getStatus())) { + log.warn("[invalidatePackage] 非法状态,拒绝失效 versionId={}, status={}", versionId, pkg.getStatus()); + throw exception(RUNTIME_PACKAGE_INVALIDATE_REJECTED); + } + RuntimePackageDO update = new RuntimePackageDO(); + update.setId(pkg.getId()); + update.setStatus(PackageStatusEnum.EXPIRED.getStatus()); + runtimePackageMapper.updateById(update); + } + @Override public Integer getPackageStatus(Long versionId) { // §3.1 C1 可见态第三条件只读:取该版本运行包 status;无就绪运行包返回 null(feed 写侧据此拒写) diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeControllerDogfoodTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeControllerDogfoodTest.java new file mode 100644 index 00000000..66697e49 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/admin/runtime/AdminRuntimeControllerDogfoodTest.java @@ -0,0 +1,44 @@ +package com.wanxiang.huijing.game.module.runtime.controller.admin.runtime; + +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard; +import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_DOGFOOD_DIRECT_PUBLISH_DISABLED; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** 管理端运行包发布入口的狗粮安全边界测试。 */ +class AdminRuntimeControllerDogfoodTest extends BaseMockitoUnitTest { + + @InjectMocks + private AdminRuntimeController controller; + @Mock + private RuntimePackageService runtimePackageService; + @Mock + private DogfoodAccessGuard dogfoodAccessGuard; + + @Test + void dogfoodRejectsDirectPublishBeforeService() { + doThrow(new ServiceException(RUNTIME_DOGFOOD_DIRECT_PUBLISH_DISABLED)) + .when(dogfoodAccessGuard).rejectInDogfood("runtime.admin.package.publish", + RUNTIME_DOGFOOD_DIRECT_PUBLISH_DISABLED); + + assertThrows(ServiceException.class, + () -> controller.publishPackage(2048L, "a".repeat(64))); + verifyNoInteractions(runtimePackageService); + } + + @Test + void nonDogfoodKeepsOriginalPublishCall() { + assertDoesNotThrow(() -> controller.publishPackage(2048L, "a".repeat(64))); + verify(runtimePackageService).publishPackage(2048L, "a".repeat(64)); + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java index 39640b77..f2652736 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java @@ -68,6 +68,14 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { assertSame(p, got); } + @Test + void testGetPackage_playRejectsExpiredAfterUnlist() { + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.EXPIRED.getStatus())); + ServiceException ex = assertThrows(ServiceException.class, + () -> runtimePackageService.getPackageManifest(2048L, RuntimeSceneEnum.PLAY.getScene(), 99L)); + assertEquals(RUNTIME_PACKAGE_NOT_PUBLISHED.getCode(), ex.getCode()); + } + @Test void testGetPackage_previewReadyOwnerOk() { // scene=preview + status=0 预览就绪 + 登录用户存在(骨架占位放行)→ 放行 @@ -93,9 +101,10 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { // status=0 预览就绪 → 置 status=1 已发布 RuntimePackageDO p = pkg(PackageStatusEnum.PREVIEW_READY.getStatus()); p.setId(11L); + p.setChecksum("a".repeat(64)); when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); - runtimePackageService.publishPackage(2048L); + runtimePackageService.publishPackage(2048L, "a".repeat(64)); ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimePackageDO.class); verify(runtimePackageMapper).updateById(captor.capture()); @@ -106,9 +115,11 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { @Test void testPublishPackage_alreadyPublishedIdempotent() { // 已发布再次调用 → 幂等返回,不二次写入 - when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.PUBLISHED.getStatus())); + RuntimePackageDO p = pkg(PackageStatusEnum.PUBLISHED.getStatus()); + p.setChecksum("a".repeat(64)); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); - runtimePackageService.publishPackage(2048L); + runtimePackageService.publishPackage(2048L, "a".repeat(64)); verify(runtimePackageMapper, never()).updateById(any(RuntimePackageDO.class)); // 带类型消歧 } @@ -118,10 +129,46 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { // 无就绪运行包 → 1-102-001-001 when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(null); ServiceException ex = assertThrows(ServiceException.class, - () -> runtimePackageService.publishPackage(2048L)); + () -> runtimePackageService.publishPackage(2048L, "a".repeat(64))); assertEquals(RUNTIME_PACKAGE_NOT_READY.getCode(), ex.getCode()); } + @Test + void testPublishPackage_checksumMismatchRejectedWithoutWrite() { + RuntimePackageDO p = pkg(PackageStatusEnum.PREVIEW_READY.getStatus()); + p.setChecksum("b".repeat(64)); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); + ServiceException ex = assertThrows(ServiceException.class, + () -> runtimePackageService.publishPackage(2048L, "a".repeat(64))); + assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode()); + verify(runtimePackageMapper, never()).updateById(any(RuntimePackageDO.class)); + } + + @Test + void testInvalidate_publishedToExpiredAndExpiredIdempotent() { + RuntimePackageDO published = pkg(PackageStatusEnum.PUBLISHED.getStatus()); + published.setId(11L); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(published); + runtimePackageService.invalidate(2048L); + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimePackageDO.class); + verify(runtimePackageMapper).updateById(captor.capture()); + assertEquals(PackageStatusEnum.EXPIRED.getStatus(), captor.getValue().getStatus()); + + reset(runtimePackageMapper); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.EXPIRED.getStatus())); + runtimePackageService.invalidate(2048L); + verify(runtimePackageMapper, never()).updateById(any(RuntimePackageDO.class)); + } + + @Test + void testInvalidate_absentOrPreviewReadyFailsClosed() { + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(null); + assertThrows(ServiceException.class, () -> runtimePackageService.invalidate(2048L)); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.PREVIEW_READY.getStatus())); + assertThrows(ServiceException.class, () -> runtimePackageService.invalidate(2048L)); + verify(runtimePackageMapper, never()).updateById(any(RuntimePackageDO.class)); + } + // ============================== storeForVersion 落包幂等三分支(§8.5)============================== @Test diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V33.0.0__review_record_add_artifact_hash.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V33.0.0__review_record_add_artifact_hash.sql new file mode 100644 index 00000000..aadab963 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V33.0.0__review_record_add_artifact_hash.sql @@ -0,0 +1,3 @@ +-- 审核批准绑定实际发布产物的 SHA-256;历史记录及拒绝/下架保持空串。 +ALTER TABLE game_review_record + ADD COLUMN artifact_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '审核批准绑定的运行产物 SHA-256';