feat(release): enforce dogfood publish safety loop

This commit is contained in:
lili 2026-07-22 21:37:43 -07:00
parent 926a0b4d4f
commit 640d9268c0
18 changed files with 275 additions and 35 deletions

View File

@ -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' }

View File

@ -0,0 +1,3 @@
-- 审核批准绑定实际发布产物的 SHA-256;历史记录及拒绝/下架保持空串。
ALTER TABLE game_review_record
ADD COLUMN artifact_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '审核批准绑定的运行产物 SHA-256';

View File

@ -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, "审核版本产物摘要无效");
}

View File

@ -31,6 +31,8 @@ public class ReviewRecordDO extends TenantBaseDO {
* 版本 ID
*/
private Long versionId;
/** 审核批准时绑定的运行产物 SHA-256;拒绝和下架为空串。 */
private String artifactHash;
/**
* 审核人用户 ID(人工审核)
*/

View File

@ -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);
}
}

View File

@ -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)。
*

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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<FeedRankUpsertReqDTO> 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<FeedRankUpsertReqDTO> 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<FeedRankUpsertReqDTO> 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;
}

View File

@ -45,7 +45,12 @@ public interface RuntimePackageApi {
@PostMapping(PREFIX + "/publish")
@Operation(summary = "发布运行包(翻包 0→1 已发布)")
@Parameter(name = "versionId", description = "版本 ID", required = true, example = "1024")
CommonResult<Boolean> publish(@RequestParam("versionId") Long versionId);
CommonResult<Boolean> publish(@RequestParam("versionId") Long versionId,
@RequestParam("expectedArtifactHash") String expectedArtifactHash);
/** 下架时使已发布运行包失效,阻断 runtime 深链试玩。 */
@PostMapping(PREFIX + "/invalidate")
CommonResult<Boolean> invalidate(@RequestParam("versionId") Long versionId);
/**
* 按版本 ID 反查运行包状态机值(§3.1 C1 可见态第三条件:feed 写侧 PUBLISH_BASELINE enforce 依赖)

View File

@ -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,编译期对接点判定) */

View File

@ -28,9 +28,15 @@ public class RuntimePackageApiImpl implements RuntimePackageApi {
private RuntimePackageService runtimePackageService;
@Override
public CommonResult<Boolean> publish(Long versionId) {
public CommonResult<Boolean> 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<Boolean> invalidate(Long versionId) {
runtimePackageService.invalidate(versionId);
return success(Boolean.TRUE);
}

View File

@ -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<Boolean> publishPackage(@PathVariable("versionId") Long versionId) {
runtimePackageService.publishPackage(versionId);
public CommonResult<Boolean> 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);
}

View File

@ -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)

View File

@ -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 写侧据此拒写)

View File

@ -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));
}
}

View File

@ -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<RuntimePackageDO> 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<RuntimePackageDO> 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

View File

@ -0,0 +1,3 @@
-- 审核批准绑定实际发布产物的 SHA-256;历史记录及拒绝/下架保持空串。
ALTER TABLE game_review_record
ADD COLUMN artifact_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '审核批准绑定的运行产物 SHA-256';