feat(project): bind runtime artifacts to versions

This commit is contained in:
lili 2026-07-23 00:42:40 -07:00
parent 9fb669c4fa
commit f7d0942ab6
7 changed files with 178 additions and 0 deletions

View File

@ -1,10 +1,12 @@
package com.wanxiang.huijing.game.module.project.api; package com.wanxiang.huijing.game.module.project.api;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import com.wanxiang.huijing.game.module.project.enums.ApiConstants; import com.wanxiang.huijing.game.module.project.enums.ApiConstants;
import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@ -40,4 +42,17 @@ public interface ProjectVersionApi {
@Operation(summary = "落包建版本(创建/复用 game_version返回 versionId") @Operation(summary = "落包建版本(创建/复用 game_version返回 versionId")
CommonResult<Long> createForPackage(@RequestBody ProjectVersionCreateForPackageReqDTO req); CommonResult<Long> createForPackage(@RequestBody ProjectVersionCreateForPackageReqDTO req);
/**
* runtime 已持久化的权威产物摘要绑定到版本
*
* <p>MVP 单体内由 callback 的本地事务调用落包版本摘要回写任务完成任一步失败都会整体回滚
* 审核门继续只读取 game_version.checksum不接受调用方临时摘要或放宽格式
*
* @param req 版本 ID权威 SHA-256 和包字节数
* @return true 表示版本已完成绑定
*/
@PostMapping(PREFIX + "/version/bind-runtime-artifact")
@Operation(summary = "绑定版本运行产物摘要")
CommonResult<Boolean> bindRuntimeArtifact(@RequestBody @Valid ProjectVersionBindArtifactReqDTO req);
} }

View File

@ -0,0 +1,31 @@
package com.wanxiang.huijing.game.module.project.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import lombok.Data;
/**
* 运行包产物绑定入参
*
* <p>callback 在取得 versionId 后才能生成最终 GamePackage 摘要runtime 成功持久化该权威产物后
* 通过本契约把同一摘要和字节数同步到 game_version project 审核门绑定发布产物
*/
@Data
public class ProjectVersionBindArtifactReqDTO {
/** 已由 callback 创建或幂等复用的版本 ID。 */
@NotNull(message = "versionId 不能为空")
private Long versionId;
/** runtime 已持久化的 GamePackage 原文 SHA-256小写 64 位十六进制。 */
@NotBlank(message = "checksum 不能为空")
@Pattern(regexp = "^[a-f0-9]{64}$", message = "checksum 必须为 64 位小写十六进制 sha256")
private String checksum;
/** runtime 已持久化的 GamePackage 字节数。 */
@NotNull(message = "bundleSize 不能为空")
@Positive(message = "bundleSize 必须大于 0")
private Long bundleSize;
}

View File

@ -29,5 +29,7 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_REVIEW_VERSION_INVALID = new ErrorCode(1_100_000_006, "审核版本与项目当前版本不一致"); ErrorCode PROJECT_REVIEW_VERSION_INVALID = new ErrorCode(1_100_000_006, "审核版本与项目当前版本不一致");
/** 审核批准版本缺少合法的小写 SHA-256 产物摘要。 */ /** 审核批准版本缺少合法的小写 SHA-256 产物摘要。 */
ErrorCode PROJECT_REVIEW_ARTIFACT_HASH_INVALID = new ErrorCode(1_100_000_007, "审核版本产物摘要无效"); ErrorCode PROJECT_REVIEW_ARTIFACT_HASH_INVALID = new ErrorCode(1_100_000_007, "审核版本产物摘要无效");
/** callback 绑定的运行产物摘要非法,或目标版本不存在。 */
ErrorCode PROJECT_VERSION_ARTIFACT_BIND_FAILED = new ErrorCode(1_100_000_008, "版本运行产物摘要绑定失败");
} }

View File

@ -1,5 +1,6 @@
package com.wanxiang.huijing.game.module.project.api; package com.wanxiang.huijing.game.module.project.api;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import com.wanxiang.huijing.game.module.project.service.version.GameVersionService; import com.wanxiang.huijing.game.module.project.service.version.GameVersionService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.pojo.CommonResult;
@ -32,4 +33,11 @@ public class ProjectVersionApiImpl implements ProjectVersionApi {
return success(gameVersionService.createForPackage(req)); return success(gameVersionService.createForPackage(req));
} }
@Override
public CommonResult<Boolean> bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req) {
// 同进程调用加入 callback 既有事务服务异常直接向上冒泡确保落包与版本摘要不会半成功
gameVersionService.bindRuntimeArtifact(req);
return success(Boolean.TRUE);
}
} }

View File

@ -1,6 +1,7 @@
package com.wanxiang.huijing.game.module.project.service.version; package com.wanxiang.huijing.game.module.project.service.version;
import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
/** /**
@ -24,6 +25,13 @@ public interface GameVersionService {
*/ */
Long createForPackage(ProjectVersionCreateForPackageReqDTO req); Long createForPackage(ProjectVersionCreateForPackageReqDTO req);
/**
* runtime 成功持久化的权威摘要同步到版本行
*
* @param req 版本 ID权威 SHA-256 和包字节数
*/
void bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req);
/** /**
* 取版本 DO发布编排取当前生效版本以做版本态流转 * 取版本 DO发布编排取当前生效版本以做版本态流转
* *

View File

@ -4,12 +4,18 @@ import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO
import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; 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.project.ProjectMapper;
import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.regex.Pattern;
import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/** /**
* 游戏版本 Service 实现黄金闭环 §3.3 C3 新增 * 游戏版本 Service 实现黄金闭环 §3.3 C3 新增
* *
@ -27,6 +33,8 @@ public class GameVersionServiceImpl implements GameVersionService {
private static final int STATUS_PUBLISHED = 3; private static final int STATUS_PUBLISHED = 3;
/** 落包建版本默认版本号MVP 单版本场景;多版本递增留后续) */ /** 落包建版本默认版本号MVP 单版本场景;多版本递增留后续) */
private static final int DEFAULT_VERSION_NO = 1; private static final int DEFAULT_VERSION_NO = 1;
/** 审核门认可的权威产物摘要格式,与 runtime 落包契约保持一致。 */
private static final Pattern SHA256_PATTERN = Pattern.compile("^[a-f0-9]{64}$");
@Resource @Resource
private GameVersionMapper gameVersionMapper; private GameVersionMapper gameVersionMapper;
@ -69,6 +77,29 @@ public class GameVersionServiceImpl implements GameVersionService {
return version.getId(); return version.getId();
} }
@Override
public void bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req) {
// API 校验可能因同进程直接调用方式而被绕过服务可信边界再次校验禁止非法摘要进入审核门
if (req == null || req.getVersionId() == null || req.getBundleSize() == null || req.getBundleSize() <= 0
|| !StringUtils.hasText(req.getChecksum()) || !SHA256_PATTERN.matcher(req.getChecksum()).matches()) {
log.warn("[bindRuntimeArtifact] 拒绝非法版本产物绑定 versionId={}, checksum={}, bundleSize={}",
req == null ? null : req.getVersionId(), req == null ? null : req.getChecksum(),
req == null ? null : req.getBundleSize());
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
}
// 仅更新审核所需的产物元数据状态和其它版本字段继续由原有状态机负责
GameVersionDO update = new GameVersionDO();
update.setId(req.getVersionId());
update.setChecksum(req.getChecksum());
update.setBundleSize(req.getBundleSize());
if (gameVersionMapper.updateById(update) != 1) {
log.error("[bindRuntimeArtifact] 目标版本不存在或更新异常 versionId={}", req.getVersionId());
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
}
log.info("[bindRuntimeArtifact] 版本已绑定 runtime 权威产物 versionId={}, checksum={}, bundleSize={}",
req.getVersionId(), req.getChecksum(), req.getBundleSize());
}
/** /**
* 回写项目当前生效版本ID HJ-FE-WALK-001 Bug A * 回写项目当前生效版本ID HJ-FE-WALK-001 Bug A
* *

View File

@ -0,0 +1,83 @@
package com.wanxiang.huijing.game.module.project.service.version;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
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.version.GameVersionMapper;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link GameVersionServiceImpl} 运行产物绑定测试
*
* <p>callback 先取得 versionId再由 runtime 持久化权威摘要本服务负责把同一摘要写回 game_version
* {@code ProjectServiceImpl.reviewProject} 的既有 64hex 审核门读取测试只覆盖该直接相关写入边界
*/
class GameVersionServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private GameVersionServiceImpl gameVersionService;
@Mock
private GameVersionMapper gameVersionMapper;
@Mock
private ProjectMapper projectMapper;
@Test
void bindRuntimeArtifact_updatesAuthoritativeChecksumForApproveGate() {
ProjectVersionBindArtifactReqDTO req = artifactReq("a".repeat(64));
when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(1);
gameVersionService.bindRuntimeArtifact(req);
ArgumentCaptor<GameVersionDO> captor = ArgumentCaptor.forClass(GameVersionDO.class);
verify(gameVersionMapper).updateById(captor.capture());
assertEquals(2048L, captor.getValue().getId());
assertEquals("a".repeat(64), captor.getValue().getChecksum());
assertEquals(4096L, captor.getValue().getBundleSize());
}
@Test
void bindRuntimeArtifact_rejectsInvalidChecksumWithoutWriting() {
ProjectVersionBindArtifactReqDTO req = artifactReq("ABC");
ServiceException error = assertThrows(ServiceException.class,
() -> gameVersionService.bindRuntimeArtifact(req));
assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode());
verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class));
}
@Test
void bindRuntimeArtifact_rejectsMissingVersion() {
ProjectVersionBindArtifactReqDTO req = artifactReq("b".repeat(64));
when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(0);
ServiceException error = assertThrows(ServiceException.class,
() -> gameVersionService.bindRuntimeArtifact(req));
assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode());
}
/** 构造 callback 已完成 runtime 落包后的产物绑定入参。 */
private static ProjectVersionBindArtifactReqDTO artifactReq(String checksum) {
ProjectVersionBindArtifactReqDTO req = new ProjectVersionBindArtifactReqDTO();
req.setVersionId(2048L);
req.setChecksum(checksum);
req.setBundleSize(4096L);
return req;
}
}