feat(studio): U2 源项目落库事务(B8)+ V19/V20 数据完整性

6c6g 后端 Tier0 计划 U2(docs/plans/2026-06-17-001-...)。execution §5.2 源项目工件落库 + status 机器态串联。
- GameSourceProjectDO/Mapper/SourceProjectService(studio,§12-①)+ SourceProjectApi seam(land/markBuilt/markOrphanById)
- 落库挂回调外层 DifyCallbackServiceImpl(非内层 tx)——守 §5.2「源落库与建包非同一大事务」
  (land 建包前独立 commit/markBuilt 成功后/markOrphan 失败补偿)
- status 0 草稿→1 已构建(回填 version_id)/2 孤儿(不建包不动 currentVersion);
  DifyCallbackReqVO additive sourceProject;dispatcher 从图终态抽 K_SOURCE_PROJECT
- 数据完整性(codex 评审 2 P0+2 P1 全收口):
  · P0 终态任务重复回调跳过 land(防版本血缘污染)· P0 去重按 game_id+source_hash + markBuilt 校 gameId(防跨游戏复用)
  · P1 markBuilt 与 notify 解耦(notify 失败不停 status=0)· P1 markOrphanById 精确标 + V20 唯一键 uk_game_source_hash(并发兜底)
  · post-commit best-effort 包兜底 try(防挂点失败反触写链补偿误置 failed)
- 诚实边界:现图只产最小骨架源(gameDefinition 待生成 agent 真填,跨单元),终态有什么落什么不伪造

验证:mini-desktop 编译绿 + 33 单测全绿(DifyCallback 19/SourceProject 14,含跨游戏/gameId校验/终态重复/notify解耦/markOrphanById);
V19+V20 SQL 结构绿(Flyway-apply 留部署验);codex NO-MERGE→2 P0+2 P1 全修后绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-17 21:46:38 +00:00
parent d64dcc9080
commit d57032a6d3
18 changed files with 1642 additions and 18 deletions

View File

@ -42,6 +42,23 @@ public enum AigcTaskStatusEnum {
return Objects.equals(QUEUED.status, status) || Objects.equals(RUNNING.status, status);
}
/**
* 是否为终态(succeeded/failed/timed_out/canceled)——终态任务的回调应视为重复/迟到,不应再触发副作用。
*
* <p>U2/B8 P0-1(版本血缘污染防护):已成功任务收到带不同 sourceProject 的重复回调时,外层落源须先判终态——
* 终态(含 queued/running 之外的全部)→ 跳过落源(不落新草稿、markBuilt 自然旁路),仅非终态才落源。
* 语义上 {@code isFinal == !isCancelable}(queued/running=非终态可取消;其余=终态),独立命名以表「终态」语义自洽。
*
* @param status 待判断状态值(null 视为非终态,交由上游 selectByTraceId 命中校验决断)
* @return true=终态(成功/失败/超时/已取消)
*/
public static boolean isFinal(Integer status) {
return Objects.equals(SUCCEEDED.status, status)
|| Objects.equals(FAILED.status, status)
|| Objects.equals(TIMED_OUT.status, status)
|| Objects.equals(CANCELED.status, status);
}
/**
* 是否为终态失败(failed/timed_out)——仅终态失败可重试
*

View File

@ -74,6 +74,15 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 studio 的 -api:源项目落库 seam(U2/B8 §5.2 源落库事务)。源项目工件归属 studio(编排层,§12-①),
aigc 回调链在「源 JSON 可得(图终态)+ versionId 已生(建包后)」两处 best-effort 调 SourceProjectApi 落源/回填/标孤儿。
跨模块只依赖对方 -api(守门④);本依赖无环:studio-api 不反依赖 aigc(仅 studio-server→aigc-api 单向)。 -->
<dependency>
<groupId>com.wanxiang</groupId>
<artifactId>game-module-studio-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 回调落包链依赖 runtime 的 -api:storeForVersion 建运行包行 + 写整包 manifest(跨模块只依赖对方 -api,禁依赖 -server,守门④;HJ-AGENT-LOOP-EXEC-001 §8.2) -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -83,4 +83,24 @@ public class DifyCallbackReqVO {
+ "固定架构扩展键(§5.8,additive 开放 Map):modelTier/escalationEvents/cacheHit/giveupDumpPath/cost")
private Map<String, Object> trace;
/**
* 源项目工件 JSON 全文(U2/B8 §5.2 additive 字段;「改源不改产物」基座的源侧载体)。
*
* <p>形态:SAA 图终态 {@code sourceProject} state 键(contracts/agent-loop/source-project.schema.json)——
* 由 {@code SaaGraphDispatcher.buildCallbackReqVO} 从图终态 best-effort 抽取后携带进来,
* 回调外层 {@code DifyCallbackServiceImpl} 经 {@code SourceProjectApi} 落 {@code game_source_project}(status=0),
* 建包成功后回填 version_id + status=1、失败标 status=2 孤儿。
*
* <p><b>诚实边界(核实结论)</b>:当前 SAA happy create 路图终态的 sourceProject 仅为 asset 节点产的<b>最小骨架</b>
* (schemaVersion + assets[6] +(空/缺)gameDefinition),<b>非</b>实体/场景/规则齐全的完整源项目(真完整源依赖生成 agent
* 真填 gameDefinition,属跨单元 U1 已接线 state 键 + 生成 agent 真产)。本字段「图终态有什么就带什么」,不伪造。
*
* <p><b>非阻断/兼容</b>:additive 可选——存量回调/桩不带本字段时为 null,源落库整段旁路(不落 game_source_project),
* 既有发布链(建版本→组包→落包→回填三表)字节零变化;源落库失败<b>不得阻断</b>现有发布链(best-effort 范式)。
*/
@Schema(description = "源项目工件 JSON 全文(U2/B8 additive;SAA 图终态 sourceProject 键,→ best-effort 落 game_source_project;"
+ "存量回调不带则 null、源落库旁路、字节零变;非阻断不得拦主链)",
example = "{\"schemaVersion\":\"1.0\",\"assets\":[],\"gameDefinition\":{}}")
private String sourceProject;
}

View File

@ -448,6 +448,17 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
if (trace != null) {
reqVO.setTrace(trace);
}
// ── U2/B8(§5.2 源落库):从图终态抽 sourceProject(K_SOURCE_PROJECT)携带进 reqVO,供回调外层落 game_source_project ──
// 严格 additive:抽不到/空串 → 不 set → reqVO.sourceProject 留 null → 回调外层源落库整段旁路(现行字节零变)。
// 诚实边界:happy create 路图终态的 sourceProject 现为 asset 节点产的最小骨架(schemaVersion+assets[6]+空 gameDefinition),
// 非完整源(真完整源依赖生成 agent 真填 gameDefinition,跨单元);本处「图终态有什么带什么」,不伪造。
// 失败路同样携带:失败前若已产源骨架亦有追溯价值,落 status=0 后由补偿标 status=2 孤儿(§5.2 步骤④)。
String sourceProject = (s == null) ? null
: s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).filter(v -> !v.isBlank()).orElse(null);
if (sourceProject != null) {
reqVO.setSourceProject(sourceProject);
}
return reqVO;
}

View File

@ -5,11 +5,16 @@ import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper;
import com.wanxiang.huijing.game.module.aigc.enums.AigcTaskStatusEnum;
import com.wanxiang.huijing.game.module.community.api.CommunityNotifyApi;
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Objects;
import java.util.Set;
@ -68,46 +73,225 @@ public class DifyCallbackServiceImpl implements DifyCallbackService {
@Resource
private CommunityNotifyApi communityNotifyApi;
/**
* 源项目落库 -api(U2/B8 §5.2 源落库事务;studio 模块 @Primary 本地实现就地解析,跨模块只依赖 -api)。
*
* <p><b>为何挂在外层非事务编排(命门,事务边界)</b>:本类 {@link #handleCallback} 不开事务,{@code SourceProjectApi}
* 各方法在 studio 侧自开<b>独立短事务</b>(REQUIRED 传播;本类无事务可加入 → 必开新事务、独立 commit)。这正是
* §5.2「源落库与建包<b>非同一大事务</b>」要求:若挂在内层 {@code handleCallbackTx}(@Transactional),land 会被
* REQUIRED 并入建包大事务、随建包失败一起回滚 → 孤儿源行无法留存、status=2 无从标。故落源/标孤儿必须在<b>本外层</b>
* (建包提交前落源、建包失败后标孤儿、建包成功后回填),与既有「提交后 notify」best-effort 挂点同层。
*/
@Resource
private SourceProjectApi sourceProjectApi;
@Override
public Boolean handleCallback(DifyCallbackReqVO reqVO) {
log.info("[handleCallback] 收到 Dify 回调 traceId={}, status={}, templateId={}",
reqVO.getTraceId(), reqVO.getStatus(), reqVO.getTemplateId());
// ===== U2/B8 §5.2 步骤①:建包前落源(status=0 草稿,独立短事务,best-effort 非阻断)=====
// 挂在建包大事务【之前 + 外层非事务编排】:land 在 studio 侧独立 commit(§5.2「源落库与建包非同一大事务」),
// 即便后续建包失败回滚,源行仍留存(可标孤儿 status=2)。landing 持本次落源结果(id+hash),供成功回填/失败标孤儿复用。
SourceLanding landing = landSourceQuietly(reqVO);
try {
// 经 Spring 代理调用内层事务方法(三表同事务写入链,§8.4 七步序列)
Boolean result = txService.handleCallbackTx(reqVO);
// ===== Wave4 挂点1(P-NTF-02 生成完成通知)=====
// 内层 @Transactional 已提交,此处在【提交后】按 traceId 回查任务:
// 仅 SUCCEEDED 终态且 versionId 已回填 = 真实成功(天然排除 config_invalid/failed/幂等短路无新版本——
// 它们也 return TRUE,故 result==TRUE 不能作判据,§6.5 挂点1)。
// 三实参全取自回查任务(不走 projectApi.getCreatorUserId)。通知失败不回滚业务、仅记 error log。
try {
AigcTaskDO task = aigcTaskMapper.selectByTraceId(reqVO.getTraceId());
if (task != null
&& Objects.equals(task.getStatus(), AigcTaskStatusEnum.SUCCEEDED.getStatus())
&& task.getVersionId() != null) {
communityNotifyApi.notifyGenerateDone(task.getCreatorUserId(), task.getGameId(), task.getVersionId());
}
} catch (Exception notifyEx) {
log.error("[handleCallback] 生成完成通知失败(不回滚业务)traceId={}", reqVO.getTraceId(), notifyEx);
}
// ===== 提交后 best-effort 挂点(命门):整段独立兜底,与写链补偿路彻底隔离 =====
// 内层事务已成功提交——本段(回查/通知/源态回填)失败绝不能反向触发写链补偿(compensate/markOrphan),
// 否则会把已成功的任务误置 failed。故整段包一层 try 吞异常(与既有「提交后 notify」best-effort 同性);
// 下方 notify 与 markBuilt 再各自独立 try(P1-1 解耦:通知失败不跳过源态回填)。
postCommitBestEffort(reqVO, landing);
return result;
} catch (ServiceException e) {
if (NO_COMPENSATE_CODES.contains(e.getCode())) {
// 业务校验异常:不补偿,原样上抛(任务不存在/终态拒重入/参数非法——见类注释)
// 业务校验异常:不补偿,原样上抛(任务不存在/终态拒重入/参数非法——见类注释)。
// 注:参数非法等属写链前置校验失败、任务回原态可被重新回调,源草稿应留存待重试,不在此标孤儿。
throw e;
}
// 写链 ServiceException(如 createForPackage/storeForVersion 经 getCheckedData 抛出):补偿后原样上抛
// 写链 ServiceException(如 createForPackage/storeForVersion 经 getCheckedData 抛出):补偿后原样上抛。
// §5.2 步骤④:建包失败 → 源标 status=2 孤儿(随补偿同路,best-effort)。
compensateQuietly(reqVO.getTraceId(), "写链 ServiceException code=" + e.getCode() + ", msg=" + e.getMessage());
markSourceOrphanQuietly(reqVO.getTraceId(), landing);
throw e;
} catch (Exception e) {
// 写链非业务异常(组包序列化失败/NPE 等):补偿后包装为 ServiceException 上抛(编排器按非 0 code 处理)
// 写链非业务异常(组包序列化失败/NPE 等):补偿后包装为 ServiceException 上抛(编排器按非 0 code 处理)。
compensateQuietly(reqVO.getTraceId(), "写链异常 " + e.getClass().getSimpleName() + ": " + e.getMessage());
markSourceOrphanQuietly(reqVO.getTraceId(), landing); // §5.2 步骤④:建包失败标孤儿
log.error("[handleCallback] 回调写链异常(三表已回滚,已补偿置 failed)traceId={}", reqVO.getTraceId(), e);
throw new ServiceException(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(),
"回调写链异常:" + e.getMessage());
}
}
/**
* 提交后 best-effort 挂点(命门):内层事务已提交后执行「回查任务 → 生成完成通知 → 源态回填」。
*
* <p><b>整段独立兜底(与写链补偿路彻底隔离)</b>:本方法内任何异常一律吞 + error log,<b>绝不外抛</b>——
* 内层事务已成功提交,本段失败不应反向触发写链补偿(compensateQuietly/markSourceOrphanQuietly 会把已成功任务误置 failed),
* 亦不应让 handleCallback 改返已提交的 result。这与既有「提交后 notify」best-effort 挂点同性(§6.5 挂点1)。
*
* <p><b>真实成功判据</b>:仅 SUCCEEDED 终态且 versionId 已回填 = 真实成功(天然排除 config_invalid/failed/幂等短路无新版本——
* 它们也 return TRUE,故 result==TRUE 不能作判据)。version_id 取自此回查任务,notify 与 markBuilt 同源。
*
* <p><b>P1-1(通知/回填解耦)</b>:notify 与 markBuilt 各自独立 try——原同 try 下 notify 在前抛异常会跳过 markBuilt,
* 致源行永停 status=0。拆开后通知失败仅记 error log、绝不影响源态回填(数据完整性优先),二者互不连坐。
*
* @param reqVO 回调入参(取 traceId 回查任务)
* @param landing 本次落源结果(null=未落源 / 终态跳过,markBuilt 自然旁路)
*/
private void postCommitBestEffort(DifyCallbackReqVO reqVO, SourceLanding landing) {
try {
AigcTaskDO task = aigcTaskMapper.selectByTraceId(reqVO.getTraceId());
boolean realSuccess = task != null
&& Objects.equals(task.getStatus(), AigcTaskStatusEnum.SUCCEEDED.getStatus())
&& task.getVersionId() != null;
if (!realSuccess) {
return; // 非真实成功(失败/幂等短路无新版本):不通知、不回填
}
// ① Wave4 挂点1(P-NTF-02 生成完成通知):三实参全取自回查任务(不走 projectApi.getCreatorUserId)。
// 独立 try(P1-1):通知失败仅记 error log,绝不影响下方源态回填。
try {
communityNotifyApi.notifyGenerateDone(task.getCreatorUserId(), task.getGameId(), task.getVersionId());
} catch (Exception notifyEx) {
log.error("[handleCallback] 生成完成通知失败(不回滚业务、不影响源态回填)traceId={}", reqVO.getTraceId(), notifyEx);
}
// ② U2/B8 §5.2 步骤③:建包成功 → 回填源行 version_id + status=1(独立短事务,best-effort,与 notify 解耦)
markSourceBuiltQuietly(landing, task.getVersionId());
} catch (Exception e) {
// 整段兜底:提交后回查/挂点异常一律吞(事务已提交,绝不反向触发写链补偿误置 failed),仅留痕排障
log.error("[handleCallback] 提交后挂点失败(事务已提交,不补偿、不阻断主链)traceId={}", reqVO.getTraceId(), e);
}
}
/**
* 本次落源结果(U2/B8 §5.2):持源行 ID + gameId + sourceHash,供「建包成功回填 version_id」「建包失败标孤儿」复用。
* landing 为 null(reqVO 无 sourceProject / 落源失败)→ 后续两步均旁路(best-effort,不阻断主链)。
*/
private record SourceLanding(Long sourceId, Long gameId, String sourceHash) {
}
/**
* best-effort 建包前落源(§5.2 步骤①):reqVO.sourceProject 存在 → 按 traceId 定位 gameId → 调 {@link SourceProjectApi#land}
* 落 game_source_project(status=0,source_hash 幂等去重)。
*
* <p><b>非阻断硬约束(命门)</b>:全程 try-catch 吞异常返 null(绝不外抛、绝不阻断主回调链)——
* 源落库是回调链旁路的 additive 存储面,缺它不影响既有发布链(三表写链)。reqVO.sourceProject 缺(存量回调/桩)
* → 直接返 null(源落库整段旁路,现行字节零变化)。task 缺/gameId 空 → 无归属可落,返 null + warn。
*
* @param reqVO 回调入参(sourceProject + traceId)
* @return 落源结果(含 sourceId/gameId/sourceHash);未落/失败返 null
*/
private SourceLanding landSourceQuietly(DifyCallbackReqVO reqVO) {
// 存量回调/桩不带 sourceProject → 源落库整段旁路(additive 天然兼容,现行字节零变化)
if (!StringUtils.hasText(reqVO.getSourceProject())) {
return null;
}
try {
// 落源需 gameId(game_source_project.game_id):按 traceId 定位任务取 gameId(只读、索引命中、与内层同源)
AigcTaskDO task = aigcTaskMapper.selectByTraceId(reqVO.getTraceId());
if (task == null || task.getGameId() == null) {
log.warn("[handleCallback] 源落库跳过:按 traceId 未定位到任务或任务缺 gameId(无归属可落)traceId={}", reqVO.getTraceId());
return null;
}
// ===== U2/B8 P0-1(版本血缘污染防护,命门)=====
// 仅对【非终态(queued/running)】任务落源。任务已终态(SUCCEEDED/failed/timed_out/canceled)= 本次回调是
// 重复/迟到回调——若仍落新源草稿,会与内层「SUCCEEDED+versionId 幂等短路」错配:内层短路不建新包、外层却把
// 新草稿挂上旧 versionId(markBuilt),造成版本血缘污染。故终态直接跳过落源返 null → landing=null →
// markBuilt 自然旁路(既有发布链零影响,仅源侧不落新草稿)。
if (AigcTaskStatusEnum.isFinal(task.getStatus())) {
log.info("[handleCallback] 源落库跳过:任务已终态(重复/迟到回调,防版本血缘污染)traceId={}, status={}",
reqVO.getTraceId(), task.getStatus());
return null;
}
SourceProjectLandReqDTO landReq = new SourceProjectLandReqDTO();
landReq.setGameId(task.getGameId());
landReq.setSourceJson(reqVO.getSourceProject());
// schemaVersion/buildProfile/baseVersionId 缺省由 studio 落库服务兜底(回调入参暂不携带,B6/B5 接线后可扩)
CommonResult<SourceProjectLandRespDTO> resp = sourceProjectApi.land(landReq);
if (resp == null || resp.getData() == null) {
log.warn("[handleCallback] 源落库返回空结果,跳过后续源态流转 traceId={}", reqVO.getTraceId());
return null;
}
SourceProjectLandRespDTO data = resp.getData();
log.info("[handleCallback] 源落库完成(status=0)traceId={}, gameId={}, sourceId={}, hash={}, reused={}",
reqVO.getTraceId(), task.getGameId(), data.getId(), data.getSourceHash(), data.getReused());
return new SourceLanding(data.getId(), task.getGameId(), data.getSourceHash());
} catch (Exception e) {
// 命门:源落库失败一律吞 + warn,返 null(→ 后续流转旁路),绝不阻断主回调链(三表写链照常)
log.warn("[handleCallback] 源落库失败(不阻断主链,源态流转旁路)traceId={}", reqVO.getTraceId(), e);
return null;
}
}
/**
* best-effort 建包成功回填源行 version_id + status=1(§5.2 步骤③)。
*
* <p>非阻断:landing 为 null(未落源)→ 旁路;调用异常吞 + error log(不回滚已提交的三表写链、不阻断主链,
* 与 notify best-effort 挂点同款)。
*
* @param landing 本次落源结果(null=未落源,旁路)
* @param versionId 建包成功建的产物版本 ID(提交后回查任务取得)
*/
private void markSourceBuiltQuietly(SourceLanding landing, Long versionId) {
if (landing == null || landing.sourceId() == null || versionId == null) {
return; // 未落源 / 无版本 → 旁路
}
try {
// P0-2:带 landing.gameId 供 studio 侧校验源行归属(防跨游戏回填);P2:取 CommonResult 返回值判真假,不盲打成功日志。
// 非法流转/gameId 不一致/不存在时 markBuilt 返 false(getCheckedData 不抛,data=false),此时打 warn 而非"完成"。
CommonResult<Boolean> result = sourceProjectApi.markBuilt(landing.sourceId(), versionId, landing.gameId());
if (result != null && Boolean.TRUE.equals(result.getCheckedData())) {
log.info("[handleCallback] 源态回填完成(version_id + status=1)sourceId={}, versionId={}, gameId={}",
landing.sourceId(), versionId, landing.gameId());
} else {
// 返 false(非法流转/gameId 不一致/源行不存在):源行未被回填,留痕待对账(不阻断主链)
log.warn("[handleCallback] 源态回填未生效(非法流转/归属不一致/源行不存在,源行留原态)sourceId={}, versionId={}, gameId={}",
landing.sourceId(), versionId, landing.gameId());
}
} catch (Exception e) {
// 非阻断:回填失败仅留痕(源行停留 status=0,可后续补算/对账),绝不回滚已落库的产物、不阻断主链
log.error("[handleCallback] 源态回填 status=1 失败(不阻断主链,源行留 status=0)sourceId={}, versionId={}",
landing.sourceId(), versionId, e);
}
}
/**
* best-effort 建包失败标源行孤儿 status=2(§5.2 步骤④)。
*
* <p>非阻断:landing 为 null / sourceId 空(未落源)→ 旁路(构建失败但源未落 = 无孤儿可标);调用异常吞 + error log
* (绝不掩盖原写链异常——原异常必须继续上抛给编排器,本方法只做 best-effort 源态标记)。
*
* <p><b>P1-2 标错行防护</b>:按 {@code landing.sourceId} 精确定位本次落的源行(外层已持),不靠 game+hash 取最新草稿
* (并发同 hash 多草稿会标错行)。
*
* @param traceId 回调 traceId(日志定位)
* @param landing 本次落源结果(null=未落源,旁路;非空持 sourceId 精确定位源行)
*/
private void markSourceOrphanQuietly(String traceId, SourceLanding landing) {
if (landing == null || landing.sourceId() == null) {
return; // 未落源 → 无孤儿可标,旁路
}
try {
// P1-2:按源行 ID 精确定位置 status=2(markOrphanById 内仅标 status=0 草稿,非草稿/不存在幂等无副作用);
// P2:取 CommonResult 返回值判真假——返 false(非草稿/不存在)打 info 留痕,不盲打"已标孤儿"。
CommonResult<Boolean> result = sourceProjectApi.markOrphanById(landing.sourceId());
if (result != null && Boolean.TRUE.equals(result.getCheckedData())) {
log.info("[handleCallback] 建包失败,源标孤儿 status=2 traceId={}, sourceId={}, gameId={}",
traceId, landing.sourceId(), landing.gameId());
} else {
// 返 false(源行非草稿/不存在):无草稿可标,留痕(不掩盖原异常)
log.info("[handleCallback] 源标孤儿无生效(源行非草稿/不存在,幂等无副作用)traceId={}, sourceId={}",
traceId, landing.sourceId());
}
} catch (Exception e) {
// 非阻断:标孤儿失败仅留痕(源行停留 status=0,可对账补标),绝不掩盖原写链异常(原异常已在调用处上抛)
log.error("[handleCallback] 源标孤儿 status=2 失败(不掩盖原异常,源行留 status=0)traceId={}, sourceId={}",
traceId, landing.sourceId(), e);
}
}
/**
* 静默补偿(补偿自身失败只留痕、不吞原异常——原写链异常必须继续向上抛给编排器)
*

View File

@ -5,10 +5,14 @@ import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper;
import com.wanxiang.huijing.game.module.aigc.enums.AigcTaskStatusEnum;
import com.wanxiang.huijing.game.module.aigc.service.task.AigcTaskService;
import com.wanxiang.huijing.game.module.community.api.CommunityNotifyApi;
import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi;
import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO;
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
@ -20,6 +24,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@ -32,6 +37,7 @@ import static com.wanxiang.huijing.game.module.aigc.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@ -66,6 +72,19 @@ class DifyCallbackServiceImplTest extends BaseMockitoUnitTest {
@Mock
private RuntimePackageApi runtimePackageApi;
/** 源项目落库 -api(U2/B8 §5.2;仅源落库相关用例显式反射注入外层,存量用例不带 sourceProject 故旁路不受扰) */
@Mock
private SourceProjectApi sourceProjectApi;
/**
* community 通知 -api(外层 @Resource 字段,Wave4 挂点1)。
* 源落库 happy 用例须显式反射注入外层:提交后回查命中真实成功(SUCCEEDED+versionId)后,
* 外层先调 {@code communityNotifyApi.notifyGenerateDone}(L111)再调 {@code markSourceBuiltQuietly}(L115,markBuilt 回填)——
* 二者在同一 try 块、notify 在前。若不注入本 mock,notify 触 NPE 被 L117 catch 吞 → markBuilt 永不执行(用例13 markBuilt 断言落空)。
*/
@Mock
private CommunityNotifyApi communityNotifyApi;
/** 外层编排(被测入口,手工组装持内层真实例) */
private DifyCallbackServiceImpl callbackService;
@ -400,8 +419,264 @@ class DifyCallbackServiceImplTest extends BaseMockitoUnitTest {
assertTrue(reqA.getBundleSize() > 0 && reqB.getBundleSize() > 0);
}
// ====================== 用例13(U2/B8 §5.2):源落库 → 建包成功 → markBuilt 0→1 回填 version_id ======================
/**
* happy(§5.2 步骤①+③):reqVO 带 sourceProject → 建包前落源(land,status=0)→ 建包成功 →
* 提交后回填源行 version_id + status=1(markBuilt)。
*
* 断言:① land 被调(建包前落源,gameId/sourceJson 透传);② markBuilt(landedId, versionId) 被调(回填);
* ③ 源落库失败不阻断主链——本用例正路,建包三表链照常完成(result=true、completeWithVersion 被调)。
*/
@Test
void testHandleCallback_sourceLandedThenBuilt() {
wireSourceLandingDeps();
// selectByTraceId 实际三次调用(带 sourceProject 故含落源那一次),顺序必须严格区分:
// ①landSourceQuietly 取 gameId(queued,gameId=5)→ land;
// ②内层 handleCallbackTx 步骤①定位(必须仍是 queued——若拿 SUCCEEDED+versionId 会触 L137 幂等短路、
// completeWithVersion 不调,下方 L450 断言崩);走完整三表写链;
// ③提交后回查(SUCCEEDED + versionId 回填)→ 驱动 notify + markBuilt(3001,2048) 真实成功判据。
// 故须三元序列,中间一次必为 queued;不可省成两元(两元会让②拿 succeeded 短路)。
when(aigcTaskMapper.selectByTraceId("aigc-trace-1"))
.thenReturn(queuedTask(), queuedTask(), succeededTaskWithVersion());
when(projectVersionApi.createForPackage(any(ProjectVersionCreateForPackageReqDTO.class)))
.thenReturn(CommonResult.success(2048L));
when(runtimePackageApi.storeForVersion(any(RuntimePackageStoreReqDTO.class)))
.thenReturn(CommonResult.success(99L));
// 落源返回新行 id=3001(status=0)
when(sourceProjectApi.land(any(SourceProjectLandReqDTO.class)))
.thenReturn(CommonResult.success(landResp(3001L, "c".repeat(64), false)));
when(sourceProjectApi.markBuilt(anyLong(), anyLong(), anyLong())).thenReturn(CommonResult.success(true));
Boolean result = callbackService.handleCallback(succeededReqWithSource());
assertTrue(result);
// ① 建包前落源:land 被调,入参 gameId/sourceJson 透传
ArgumentCaptor<SourceProjectLandReqDTO> landCaptor = ArgumentCaptor.forClass(SourceProjectLandReqDTO.class);
verify(sourceProjectApi).land(landCaptor.capture());
assertEquals(5L, landCaptor.getValue().getGameId());
assertEquals(SOURCE_JSON, landCaptor.getValue().getSourceJson());
// ② 建包成功后回填:markBuilt(landedId=3001, versionId=2048, gameId=5)(P0-2 带 gameId 校归属;versionId/gameId 取自回查任务)
verify(sourceProjectApi).markBuilt(3001L, 2048L, 5L);
// 建包三表链照常(源落库是旁路 additive,不影响产物链)
verify(aigcTaskService).completeWithVersion(77L, 2048L);
// 源落库正路无标孤儿
verify(sourceProjectApi, never()).markOrphanById(anyLong());
}
// ====================== 用例14(U2/B8 §5.2):源落库 → 建包失败 → 标 status=2 孤儿 ======================
/**
* error(§5.2 步骤④,集成:源落库与建包<b>非同一事务</b>):reqVO 带 sourceProject → 建包前落源(land,独立 commit)→
* 建包失败(createForPackage 抛写链异常,内层三表事务回滚)→ 源行标 status=2 孤儿(markOrphan)+ 补偿置任务 failed。
*
* 断言:① land 被调(源已独立落库,未随建包回滚——验两写边界独立);② 建包失败 → markOrphanById(sourceId) 被调标孤儿(P1-2 按 ID 精确定位);
* ③ 不建 package(落包/回填不触发);④ 不动 currentVersion(completeWithVersion 不调);⑤ 补偿置 failed(llm_error);
* ⑥ markBuilt 不被调(建包失败不回填)。
*/
@Test
void testHandleCallback_sourceLandedThenBuildFailed_markOrphan() {
wireSourceLandingDeps();
when(aigcTaskMapper.selectByTraceId("aigc-trace-1")).thenAnswer(inv -> queuedTask());
when(sourceProjectApi.land(any(SourceProjectLandReqDTO.class)))
.thenReturn(CommonResult.success(landResp(3002L, "d".repeat(64), false)));
when(sourceProjectApi.markOrphanById(anyLong())).thenReturn(CommonResult.success(true));
// 建包失败:建版本抛写链异常(内层三表事务回滚)
when(projectVersionApi.createForPackage(any(ProjectVersionCreateForPackageReqDTO.class)))
.thenThrow(new ServiceException(999_000_001, "建版本失败(模拟写链异常)"));
// 写链异常原样上抛(§8.7 注:异常传播 = 代理事务回滚把关)
ServiceException ex = assertThrows(ServiceException.class,
() -> callbackService.handleCallback(succeededReqWithSource()));
assertEquals(999_000_001, ex.getCode());
// ① 源已独立落库(land 被调)——与建包非同一事务,建包回滚不影响已 commit 的源行
verify(sourceProjectApi).land(any(SourceProjectLandReqDTO.class));
// ② 建包失败 → 标孤儿 status=2(P1-2:按落源返回的 sourceId=3002 精确定位,不靠 game+hash 取最新草稿)
verify(sourceProjectApi).markOrphanById(3002L);
// ③ 不建 package(落包不触发);④ 不动 currentVersion(回填不触发)
verifyNoInteractions(runtimePackageApi);
verify(aigcTaskService, never()).completeWithVersion(anyLong(), anyLong());
// ⑥ 建包失败不回填 status=1
verify(sourceProjectApi, never()).markBuilt(anyLong(), anyLong(), anyLong());
// ⑤ 补偿置 failed(llm_error):updateById 序列 = 受理置 1 → 补偿置失败
ArgumentCaptor<AigcTaskDO> captor = ArgumentCaptor.forClass(AigcTaskDO.class);
verify(aigcTaskMapper, times(2)).updateById(captor.capture());
AigcTaskDO last = captor.getAllValues().get(1);
assertEquals(AigcTaskStatusEnum.FAILED.getStatus(), last.getStatus());
assertEquals("llm_error", last.getFailureReason());
}
// ====================== 用例15(U2/B8 §5.2):存量回调不带 sourceProject → 源落库整段旁路(字节零变)======================
/**
* edge(additive 兼容):reqVO 不带 sourceProject(存量 M-b 回调/桩)→ 源落库整段旁路——
* land/markBuilt/markOrphan 均不被调,既有建包三表链行为零变化(result=true、completeWithVersion 被调)。
*
* 坐实「源落库是 additive 旁路,存量回调零行为变化」。
*/
@Test
void testHandleCallback_noSourceProject_bypassesSourcePersistence() {
wireSourceLandingDeps();
// 不带 sourceProject → landSourceQuietly 在 hasText 守卫即返回(不调 selectByTraceId):
// 故序列仅 ①内层步骤①(queued)→ ②提交后回查(SUCCEEDED + versionId)。
when(aigcTaskMapper.selectByTraceId("aigc-trace-1"))
.thenReturn(queuedTask(), succeededTaskWithVersion());
when(projectVersionApi.createForPackage(any(ProjectVersionCreateForPackageReqDTO.class)))
.thenReturn(CommonResult.success(2048L));
when(runtimePackageApi.storeForVersion(any(RuntimePackageStoreReqDTO.class)))
.thenReturn(CommonResult.success(99L));
// succeededReq() 不带 sourceProject(存量形态)
Boolean result = callbackService.handleCallback(succeededReq());
assertTrue(result);
// 源落库整段旁路:三方法均不被调
verifyNoInteractions(sourceProjectApi);
// 既有建包三表链照常(字节零变化)
verify(aigcTaskService).completeWithVersion(77L, 2048L);
}
// ====================== 用例16(U2/B8 §5.2):源落库失败不阻断主链(best-effort 非阻断)======================
/**
* error(非阻断硬约束):land 抛异常 → 源落库失败被吞,<b>主回调链照常完成</b>(建包三表链不受影响、result=true)。
* 坐实「源落库失败不得阻断现有发布链」。
*/
@Test
void testHandleCallback_sourceLandFailure_doesNotBlockMainChain() {
wireSourceLandingDeps();
// 序列:①landSourceQuietly 取 gameId(queued)→ land 抛异常被吞 → ②内层步骤①(queued)→ ③提交后回查(SUCCEEDED+version)。
when(aigcTaskMapper.selectByTraceId("aigc-trace-1"))
.thenReturn(queuedTask(), queuedTask(), succeededTaskWithVersion());
when(projectVersionApi.createForPackage(any(ProjectVersionCreateForPackageReqDTO.class)))
.thenReturn(CommonResult.success(2048L));
when(runtimePackageApi.storeForVersion(any(RuntimePackageStoreReqDTO.class)))
.thenReturn(CommonResult.success(99L));
// land 抛异常(源落库失败)
when(sourceProjectApi.land(any(SourceProjectLandReqDTO.class)))
.thenThrow(new RuntimeException("源落库 DB 异常(模拟)"));
// 主链不受源落库失败影响:照常完成
Boolean result = callbackService.handleCallback(succeededReqWithSource());
assertTrue(result);
// 建包三表链照常(源落库失败仅被吞,不阻断主链)
verify(aigcTaskService).completeWithVersion(77L, 2048L);
// land 失败 → landing=null → markBuilt 旁路(不调)
verify(sourceProjectApi, never()).markBuilt(anyLong(), anyLong(), anyLong());
}
// ====================== 用例17(U2/B8 P0-1):已终态任务重复回调带 sourceProject → 不 land、不 markBuilt ======================
/**
* P0-1(版本血缘污染防护):任务已 SUCCEEDED 终态时收到带 sourceProject 的重复/迟到回调——
* landSourceQuietly 判终态直接跳过(不落新源草稿)→ landing=null → markBuilt 自然旁路。
* 内层 handleCallbackTx 命中幂等短路(SUCCEEDED+versionId)不建新包。
*
* 断言:① land 不被调(终态不落新源);② markBuilt 不被调(landing=null 旁路,不把新草稿挂旧 versionId);
* ③ markOrphanById 不被调;④ 既有发布链零影响(幂等短路,completeWithVersion 不再调、无新写入)。
*/
@Test
void testHandleCallback_finalStateDuplicateCallbackWithSource_noLandNoMarkBuilt() {
wireSourceLandingDeps();
// 三次 selectByTraceId 均返回已 SUCCEEDED+versionId 的任务(终态):
// ①landSourceQuietly 判终态 → 不 land;②内层步骤① SUCCEEDED+versionId → L137 幂等短路;③提交后回查(realSuccess=true 但 landing=null)。
when(aigcTaskMapper.selectByTraceId("aigc-trace-1")).thenReturn(succeededTaskWithVersion());
Boolean result = callbackService.handleCallback(succeededReqWithSource());
assertTrue(result); // 幂等短路返 true
// ① 终态不落新源:land 不被调(防版本血缘污染源头)
verify(sourceProjectApi, never()).land(any(SourceProjectLandReqDTO.class));
// ② landing=null → markBuilt 旁路(绝不把新草稿挂旧 versionId)
verify(sourceProjectApi, never()).markBuilt(anyLong(), anyLong(), anyLong());
// ③ 正路无标孤儿
verify(sourceProjectApi, never()).markOrphanById(anyLong());
// ④ 既有发布链零影响:幂等短路不重复建包/回填
verify(aigcTaskService, never()).completeWithVersion(anyLong(), anyLong());
verifyNoInteractions(projectVersionApi, runtimePackageApi);
}
// ====================== 用例18(U2/B8 P1-1):notify 抛异常仍执行 markBuilt(通知/回填解耦)======================
/**
* P1-1(通知/回填解耦):提交后回查真实成功(SUCCEEDED+versionId),但 notifyGenerateDone 抛异常——
* notify 与 markBuilt 各自独立 try,notify 失败被吞、绝不跳过 markBuilt → 源态仍回填 status=1。
*
* 断言:① notify 被调(且抛异常);② markBuilt 仍被调回填(P1-1 核心:通知失败不影响源态回填);
* ③ 主链照常完成(result=true、completeWithVersion 被调)。
*/
@Test
void testHandleCallback_notifyThrows_stillMarksBuilt() {
wireSourceLandingDeps();
// 三元序列:①landSourceQuietly 取 gameId(queued)→ land;②内层步骤①(queued)走完整三表链;③提交后回查(SUCCEEDED+version)。
when(aigcTaskMapper.selectByTraceId("aigc-trace-1"))
.thenReturn(queuedTask(), queuedTask(), succeededTaskWithVersion());
when(projectVersionApi.createForPackage(any(ProjectVersionCreateForPackageReqDTO.class)))
.thenReturn(CommonResult.success(2048L));
when(runtimePackageApi.storeForVersion(any(RuntimePackageStoreReqDTO.class)))
.thenReturn(CommonResult.success(99L));
when(sourceProjectApi.land(any(SourceProjectLandReqDTO.class)))
.thenReturn(CommonResult.success(landResp(3003L, "e".repeat(64), false)));
when(sourceProjectApi.markBuilt(anyLong(), anyLong(), anyLong())).thenReturn(CommonResult.success(true));
// notify 抛异常(P1-1:原同 try 下会跳过 markBuilt,拆分后不应跳过)
doThrow(new RuntimeException("通知下发失败(模拟)"))
.when(communityNotifyApi).notifyGenerateDone(anyLong(), anyLong(), anyLong());
Boolean result = callbackService.handleCallback(succeededReqWithSource());
assertTrue(result); // 主链照常完成(通知失败被吞)
// ① notify 被调(抛异常);② markBuilt 仍被调回填(解耦核心)
verify(communityNotifyApi).notifyGenerateDone(anyLong(), anyLong(), anyLong());
verify(sourceProjectApi).markBuilt(3003L, 2048L, 5L); // 通知失败不影响源态回填
// ③ 建包三表链照常
verify(aigcTaskService).completeWithVersion(77L, 2048L);
}
// ============================== 测试夹具 ==============================
/** 源工件最小骨架 JSON(asset 节点口径:schemaVersion + 空 gameDefinition + assets[])。 */
private static final String SOURCE_JSON = "{\"schemaVersion\":\"1.0\",\"gameDefinition\":{},\"assets\":[]}";
/**
* 反射注入外层 {@link DifyCallbackServiceImpl} 的源落库相关 @Resource 字段(aigcTaskMapper + sourceProjectApi)。
* 仅源落库用例调用——存量用例不带 sourceProject、landSourceQuietly 在 hasText 守卫即返回,不触达这两字段,故无需注入。
*/
private void wireSourceLandingDeps() {
ReflectionTestUtils.setField(callbackService, "aigcTaskMapper", aigcTaskMapper);
ReflectionTestUtils.setField(callbackService, "sourceProjectApi", sourceProjectApi);
// 提交后回查命中真实成功时,外层先 notify 再 markBuilt(同 try 块、notify 在前):
// 不注入则 notify 触 NPE 被吞 → markBuilt 永不执行(用例13 happy 回填断言落空)。故须注入本 mock。
ReflectionTestUtils.setField(callbackService, "communityNotifyApi", communityNotifyApi);
}
/** 构造已成功终态 + versionId 回填的任务(驱动提交后 notify/markBuilt 真实成功判据;creatorUserId 非空以便 notify 三参 anyLong() 匹配)。 */
private static AigcTaskDO succeededTaskWithVersion() {
AigcTaskDO task = queuedTask();
task.setStatus(AigcTaskStatusEnum.SUCCEEDED.getStatus());
task.setVersionId(2048L);
task.setCreatorUserId(1001L); // 非空:notify(creatorUserId, gameId, versionId) 三参均须 anyLong() 可匹配(Mockito anyLong 不匹配 null)
return task;
}
/** 构造源落库落源结果(CommonResult.data)。 */
private static SourceProjectLandRespDTO landResp(Long id, String hash, boolean reused) {
SourceProjectLandRespDTO resp = new SourceProjectLandRespDTO();
resp.setId(id);
resp.setSourceHash(hash);
resp.setReused(reused);
return resp;
}
/** 构造带 sourceProject 的合法 succeeded 回调(U2 源落库用例)。 */
private static DifyCallbackReqVO succeededReqWithSource() {
DifyCallbackReqVO reqVO = succeededReq();
reqVO.setSourceProject(SOURCE_JSON);
return reqVO;
}
/** 构造排队中的生成任务(id=77/gameId=5/clicker;createTime 固定——组包 generatedAt 字节可复现的输入面) */
private static AigcTaskDO queuedTask() {
AigcTaskDO task = new AigcTaskDO();

View File

@ -0,0 +1,94 @@
package com.wanxiang.huijing.game.module.studio.api;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
import com.wanxiang.huijing.game.module.studio.enums.ApiConstants;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
/**
* RPC 服务 - 源项目工件 API(U2/B8 §5.2 源落库事务的跨模块 seam)
*
* <p><b>为什么是 seam</b>:源项目工件表 {@code game_source_project} 归 studio 模块(编排层产物,§12-①),
* 但「源 JSON 在图终态可得」「version_id 在建包后才生」两个时机都落在 <b>aigc 回调链</b>({@code DifyCallbackTxService}/
* {@code DifyCallbackServiceImpl})。aigc 跨模块只依赖对方 -api(守门④),故由本 seam 桥接:aigc 回调链调本 API
* 完成「落源 / 回填 version_id / 标孤儿」三态。MVP 单体内由 studio 模块 {@code SourceProjectApiImpl}(@RestController
* @Primary) 就地解析(同进程方法调用,非真实 Feign),与 ProjectVersionApi/RuntimePackageApi 同款模式。
*
* <p><b>事务边界(§5.2 关键,化解孤儿)</b>:源落库与建包<b>非同一大事务</b>(构建数十秒级,长事务=连接池杀手,
* 对齐 {@code SaaGraphDispatcher.java:55-57})。本 API 三方法各自独立短事务、用 status 机器态串联:
* <pre>
* ① {@link #land} → 源落库 status=0(草稿),source_hash 幂等去重(同 hash 命中复用不重复 insert)
* ② 触发确定性构建(建包走 handleCallback 唯一写入路径)
* ③ 构建成功 → {@link #markBuilt} 回填 version_id + status=1(已构建)
* ④ 构建失败 → {@link #markOrphan} status=2(孤儿),不建 package、不动 currentVersion
* </pre>
*
* <p><b>非阻断纪律</b>:源落库是回调链旁路的 additive 存储面,失败<b>不得阻断</b>现有发布链(建版本→组包→落包→回填三表)。
* 调用方(aigc 回调链)以 best-effort 方式调本 API:吞异常 + warn、源态缺失不影响已落库的 GamePackage 产物
* (与 {@code persistTraceQuietly} 同款非阻断范式)。
*
* <p>设计纪律:跨模块只依赖 studio 的 -api,禁止上游依赖 studio 的 -server(守门④:-api 不反依赖 -server)。
* 本 seam 复用 studio 既有 {@link ApiConstants}(NAME=studio-server,PREFIX=/rpc-api/studio)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 源项目工件")
public interface SourceProjectApi {
String PREFIX = ApiConstants.PREFIX;
/**
* 落源(§5.2 步骤①):把图终态产的源项目工件落 {@code game_source_project}(status=0 草稿)。
*
* <p>幂等:source_hash = 对 {@code req.sourceJson} 规范化 sha256(落库服务内计算);同 hash 命中既有行
* → 复用返回既有行 ID({@code reused=true}),不重复 insert(§5.2 去重)。
*
* @param req 落源入参(gameId/sourceJson/schemaVersion/buildProfile/baseVersionId)
* @return 落源结果(源行 ID + source_hash + 是否幂等复用),CommonResult 包裹
*/
@PostMapping(PREFIX + "/source-project/land")
@Operation(summary = "落源(建 game_source_project status=0 草稿,source_hash 幂等去重)")
CommonResult<SourceProjectLandRespDTO> land(@RequestBody SourceProjectLandReqDTO req);
/**
* 标已构建(§5.2 步骤③):构建成功建包后,回填源行的 version_id + 置 status=1(已构建)。
*
* <p>按落源返回的源行 ID 精确定位(与建包同一回调方法作用域内可得,避免 traceId/gameId 多行歧义)。
* 幂等:源行已 status=1 且 version_id 一致 → 无副作用返回(重复回调安全)。
*
* <p><b>P0-2 跨游戏回填防护</b>:传任务 {@code gameId} 供 Service 校验源行归属——源行 game_id 与任务 gameId
* 不一致则拒回填(返 false),绝不把 A 游戏 version_id 回填到 B 游戏源行。
*
* @param id 源行 ID({@link #land} 返回)
* @param versionId 构建成功建的产物版本 ID(project.game_version.id)
* @param gameId 任务所属游戏 ID(源行归属一致性校验,防跨游戏回填)
* @return 是否成功流转(CommonResult 包裹;源行不存在/gameId 不一致/非法流转返 false)
*/
@PostMapping(PREFIX + "/source-project/mark-built")
@Operation(summary = "标已构建(回填 version_id + status=1,校 gameId 归属)")
CommonResult<Boolean> markBuilt(@RequestParam("id") Long id,
@RequestParam("versionId") Long versionId,
@RequestParam("gameId") Long gameId);
/**
* 标孤儿(§5.2 步骤④):构建失败/补偿时,按<b>源行 ID 精确定位</b>把草稿源行置 status=2(孤儿)。
*
* <p><b>P1-2 标错行防护</b>:外层落源已持 {@code land} 返回的源行 ID,故按 id 精确定位本次落的源行,
* 不靠 game+hash 取「最新草稿」(并发同 hash 多草稿行取最新会标错行)。不建 package、不动 currentVersion。
* 幂等:源行不存在/非草稿态 → 无副作用返回(不报错,§5.2)。
*
* @param id 源行 ID({@link #land} 返回,外层 landing.sourceId)
* @return 是否标记了孤儿行(CommonResult 包裹;源行不存在/非草稿返 false)
*/
@PostMapping(PREFIX + "/source-project/mark-orphan-by-id")
@Operation(summary = "标孤儿(按源行 ID 精确定位置 status=2,不建包不动 currentVersion)")
CommonResult<Boolean> markOrphanById(@RequestParam("id") Long id);
}

View File

@ -0,0 +1,51 @@
package com.wanxiang.huijing.game.module.studio.dto;
import lombok.Data;
/**
* 源项目落库跨模块入参(U2/B8 §5.2 事务边界步骤①「源落库 status=0」)
*
* <p>用途:aigc 回调链在图终态拿到源项目工件 JSON(SAA state {@code sourceProject} 键)后,
* 经 {@code SourceProjectApi.land} 把源工件落 {@code game_source_project}(status=0 草稿)。
* source_hash 由落库服务对 sourceJson 规范化 sha256 计算(幂等/可寻址键)——同 hash 命中复用、不重复 insert
* (§5.2「同 source_hash 重复落库 → 幂等去重」)。
*
* <p><b>归属/边界</b>:源项目工件归 studio 模块(编排层产物,§12-①);本表只持 version_id 引用(构建成功建包后由
* {@code markBuilt} 回填),版本产物权威仍归 project(game_version)。跨模块只依赖 studio 的 -api(守门④)。
*
* <p><b>诚实边界(核实结论)</b>:当前 SAA happy create 路图终态的 {@code sourceProject} 仅为 asset 节点产的
* <b>最小骨架</b>(schemaVersion + assets[6] +(空/缺)gameDefinition),非「实体/场景/规则/行为齐全」的完整源项目
* (真完整源项目依赖生成 agent 真填 gameDefinition,属跨单元 U1 已接线 state 键 + 生成 agent 真产)。U2 只负责
* 「图终态有什么就落什么」,不伪造、不补全。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@Data
public class SourceProjectLandReqDTO {
/**
* 所属游戏 ID(game_source_project.game_id;必填,= project.game_project.id)
*/
private Long gameId;
/**
* 源项目工件 JSON 全文(SourceProject schema,contracts/agent-loop/source-project.schema.json;
* M0 走 DB LONGTEXT;落库服务对其规范化 sha256 得 source_hash 幂等键)
*/
private String sourceJson;
/**
* 源项目契约版本(独立于 GamePackage 版本;缺省 "1.0",落库服务空值兜底)
*/
private String schemaVersion;
/**
* 构建画像(目标引擎/优化档;= buildProfile,影响确定性构建缓存命中;缺省 "default")
*/
private String buildProfile;
/**
* modify 血缘:本源派生自的 base 版本 ID(create 时为空/NULL;modify/extend 路携带,§5.6)
*/
private Long baseVersionId;
}

View File

@ -0,0 +1,30 @@
package com.wanxiang.huijing.game.module.studio.dto;
import lombok.Data;
/**
* 源项目落库结果(U2/B8 §5.2 步骤①返回)
*
* <p>回传落库后的源行 ID 与 source_hash,供调用方(aigc 回调链)在后续「构建成功回填 version_id+status=1」
* 或「构建失败标 status=2 孤儿」时按 id 精确定位本次落的源行(避免 traceId/gameId 多行歧义)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@Data
public class SourceProjectLandRespDTO {
/**
* 源项目工件行 ID(game_source_project.id;幂等命中复用既有行时为既有行 ID)
*/
private Long id;
/**
* 源规范化 sha256(= SourceProject.sourceHash;可寻址/幂等键)
*/
private String sourceHash;
/**
* 是否幂等命中既有源行(true=同 source_hash 命中复用、未新增 insert;false=本次新落行,§5.2 去重)
*/
private Boolean reused;
}

View File

@ -41,4 +41,9 @@ public interface ErrorCodeConstants {
/** modify/extend 的 baseVersionId 不存在(按版本反查不到所属游戏/创作者) */
ErrorCode STUDIO_BASE_VERSION_NOT_EXISTS = new ErrorCode(1_112_003_003, "base 版本不存在");
// ========== 源项目工件 1-112-004-***(U2/B8 §5.2 源落库事务)==========
/** 源落库入参非法(gameId 缺 / sourceJson 空——无源工件可落,落源前置拒)。
* 注:status 流转方法(markBuilt/markOrphan)对「源行不存在/非法流转」返 false 不抛(best-effort 由调用方决断),故不另设错误码。 */
ErrorCode STUDIO_SOURCE_PROJECT_INVALID = new ErrorCode(1_112_004_000, "源项目落库入参非法(gameId 缺或 sourceJson 空)");
}

View File

@ -0,0 +1,50 @@
package com.wanxiang.huijing.game.module.studio.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Objects;
/**
* 源项目工件状态机枚举(对齐 db V18.0.0 game_source_project.status;U2/B8 §5.2 事务边界)
*
* <p>源态串联生成主线「改源不改产物」基座的源侧生命周期(非长事务,用 status 机器态串联,§5.2):
* DRAFT(0) 源落库成功 → BUILT(1) 构建成功建包回填 version_id / ORPHAN(2) 构建失败不建包 / PUBLISHED(3) 已发布。
*
* <p>合法流转(Service 校验,DO 层不承载):
* <ul>
* <li>0 草稿 →(构建成功)→ 1 已构建(回填 version_id);</li>
* <li>0 草稿 →(构建失败/补偿)→ 2 孤儿(不建包、不动 currentVersion);</li>
* <li>1 已构建 →(发布审核通过)→ 3 已发布(发布链 B1,本单元不闭合)。</li>
* </ul>
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@Getter
@AllArgsConstructor
public enum SourceProjectStatusEnum {
DRAFT(0, "草稿"),
BUILT(1, "已构建"),
ORPHAN(2, "孤儿"),
PUBLISHED(3, "已发布");
/** 状态值(落库 tinyint) */
private final Integer status;
/** 状态名(展示用) */
private final String name;
/**
* 根据状态值查枚举
*
* @param status 状态值
* @return 枚举;非法值返回 null
*/
public static SourceProjectStatusEnum of(Integer status) {
return Arrays.stream(values())
.filter(e -> Objects.equals(e.status, status))
.findFirst().orElse(null);
}
}

View File

@ -0,0 +1,49 @@
package com.wanxiang.huijing.game.module.studio.api;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
import com.wanxiang.huijing.game.module.studio.service.studio.SourceProjectService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 源项目工件 API 实现(U2/B8 §5.2,提供 RESTful 接口给跨模块 Feign 调用:aigc 回调链落源/回填/标孤儿)
*
* <p>{@code @RestController + @Primary}:与既有 {@code ProjectVersionApiImpl}/{@code RuntimePackageApiImpl} 同构——
* MVP 单体内同进程调用走本实现(aigc 回调链注入 {@link SourceProjectApi} 经 @Primary 就地解析,非真实 Feign),
* 拆微服务后走 Feign(守门④/§3.2)。仅委托 {@link SourceProjectService}(事务/幂等/流转守卫在 Service 承载)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@RestController // 提供 RESTful API 接口,给 Feign 调用
@Validated
@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析)
public class SourceProjectApiImpl implements SourceProjectApi {
@Resource
private SourceProjectService sourceProjectService;
@Override
public CommonResult<SourceProjectLandRespDTO> land(SourceProjectLandReqDTO req) {
// 落源:委托 Service 落 game_source_project(status=0),source_hash 幂等去重
return success(sourceProjectService.land(req));
}
@Override
public CommonResult<Boolean> markBuilt(Long id, Long versionId, Long gameId) {
// 标已构建:按源行 ID 回填 version_id + status=1(流转守卫 + gameId 归属校验在 Service,P0-2)
return success(sourceProjectService.markBuilt(id, versionId, gameId));
}
@Override
public CommonResult<Boolean> markOrphanById(Long id) {
// 标孤儿:按源行 ID 精确定位置 status=2(P1-2;非草稿/不存在幂等无副作用)
return success(sourceProjectService.markOrphanById(id));
}
}

View File

@ -0,0 +1,86 @@
package com.wanxiang.huijing.game.module.studio.dal.dataobject.studio;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 游戏源项目工件 DO(对应表 game_source_project,V18.0.0)——「改源不改打包产物」的源侧存储面
*
* <p>继承 {@link TenantBaseDO}:自动携带审计列(creator/create_time/updater/update_time/deleted)+ tenant_id。
*
* <p><b>范式(生命周期项目模型)</b>:游戏 = 长生命周期源项目(LLM as 工作室);本表存「可维护的结构化源项目工件」
* (SourceProject schema = contracts/agent-loop/source-project.schema.json);构建产物(bundle/manifest)仍存
* game_version + game_runtime_package(GamePackage 产物 schema 不变)。
*
* <p><b>引用边界</b>:本类只持引用 + 自身源态,不重建版本状态机——
* {@code gameId} 引用 project.game_project.id;{@code versionId} 引用 project.game_version.id(构建成功建包后回填,
* 版本权威归 project);{@code baseVersionId} 记 modify 血缘(create 时 NULL)。
*
* <p><b>源态状态机</b>:0 草稿 → 1 已构建(建包回填 version_id)/ 2 孤儿(构建失败,不建包)/ 3 已发布;
* 合法性流转由 {@code SourceProjectService} 校验,DO 层不承载(对齐 V18 头注「状态机用 tinyint,非法流转由 Service 校验」)。
*
* <p><b>可寻址/幂等</b>:{@code sourceHash} = 源规范化 sha256(= SourceProject.sourceHash);
* 按 game_id 查历史(idx_game)、按 version_id 反查源(idx_version)、按 source_hash 去重(idx_source_hash)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@TableName("game_source_project")
@KeySequence("game_source_project_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
@Data
@EqualsAndHashCode(callSuper = true)
public class GameSourceProjectDO extends TenantBaseDO {
/**
* 源项目工件 ID
*/
private Long id;
/**
* 所属游戏 ID(= project.game_project.id;必填)
*/
private Long gameId;
/**
* 关联产物版本 ID(= project.game_version.id);构建成功建包后回填,失败留 NULL=孤儿
*/
private Long versionId;
/**
* 源项目工件 JSON 全文(SourceProject schema;M0 走 DB LONGTEXT,大资产经 ref 外置)
*/
private String sourceJson;
/**
* 源项目对象存储 URL(切对象存储后用,与 sourceJson 二选一;M0 落空串)
*/
private String sourceUrl;
/**
* 源规范化 sha256(可寻址/幂等键;= SourceProject.sourceHash;64 位小写 hex)
*/
private String sourceHash;
/**
* 源项目契约版本(独立于 GamePackage 版本;缺省 "1.0")
*/
private String schemaVersion;
/**
* 构建画像(目标引擎/优化档;= buildProfile,影响 buildInputHash;缺省 "default")
*/
private String buildProfile;
/**
* 源态:0草稿 1已构建 2孤儿(构建失败) 3已发布(Service 校验流转)
*/
private Integer status;
/**
* modify 血缘:本源派生自的 base 版本 ID(create 时 NULL)
*/
private Long baseVersionId;
}

View File

@ -0,0 +1,71 @@
package com.wanxiang.huijing.game.module.studio.dal.mysql.studio;
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.GameSourceProjectDO;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 游戏源项目工件 Mapper(U2/B8 §5.2)
*
* <p>显式列查询(不裸 select*);三类查询各命中 V18 索引:
* <ul>
* <li>按 game_id 查源历史 → idx_game(game_id, id);</li>
* <li>按 version_id 反查源 → idx_version(version_id);</li>
* <li>按 (game_id, source_hash) 去重 → idx_source_hash(source_hash) 命中再过滤 game_id(孤儿标记改按 id 精确定位,见 selectById)。</li>
* </ul>
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@Mapper
public interface GameSourceProjectMapper extends BaseMapperX<GameSourceProjectDO> {
/**
* 按游戏查源项目工件历史(按 id 倒序);命中 idx_game(game_id, id)
*
* @param gameId 游戏 ID
* @return 源工件列表(按 id 倒序,含 draft/built/orphan/published 各态)
*/
default List<GameSourceProjectDO> selectListByGameId(Long gameId) {
return selectList(new LambdaQueryWrapperX<GameSourceProjectDO>()
.eq(GameSourceProjectDO::getGameId, gameId)
.orderByDesc(GameSourceProjectDO::getId));
}
/**
* 按产物版本 ID 反查源工件(idx_version);构建成功回填 version_id 后据此可由版本反查源
*
* @param versionId 产物版本 ID
* @return 源工件 DO;不存在返回 null(多行兜底取最早一条由调用方处理,此处取唯一)
*/
default GameSourceProjectDO selectByVersionId(Long versionId) {
return selectOne(GameSourceProjectDO::getVersionId, versionId);
}
/**
* 按 game_id + source_hash 去重查(idx_source_hash 命中再过滤 game_id):取同游戏同 hash 最新一行(按 id 倒序首条)。
*
* <p>幂等去重用:落源前先查,命中则复用既有行(不重复 insert,§5.2「同 source_hash 重复落库 → 幂等去重」)。
*
* <p><b>U2/B8 P0-2(跨游戏源行复用防护,命门)</b>:去重<b>必须按 game_id 隔离</b>——source_hash 仅是源 JSON 规范化摘要,
* 不含游戏归属;不同游戏可能产逻辑等价的最小骨架源(同 hash)。若只按 hash 去重,游戏 B 会复用游戏 A 的源行 →
* version_id 回填错乱、血缘污染。故按 (game_id, source_hash) 联合去重(与 V20 唯一键 uk_game_source_hash 同口径)。
*
* @param gameId 游戏 ID
* @param sourceHash 源规范化 sha256
* @return 同游戏同 hash 最新源工件 DO;无则返回 null
*/
default GameSourceProjectDO selectLatestByGameAndSourceHash(Long gameId, String sourceHash) {
return selectOne(new LambdaQueryWrapperX<GameSourceProjectDO>()
.eq(GameSourceProjectDO::getGameId, gameId)
.eq(GameSourceProjectDO::getSourceHash, sourceHash)
.orderByDesc(GameSourceProjectDO::getId)
.last("LIMIT 1"));
}
// 注:标孤儿改为按源行 ID 精确定位(外层持 land 返回的 sourceId,见 SourceProjectService.markOrphanById /
// BaseMapperX.selectById),不再用 game+hash 取「最新草稿」(P1-2:并发同 hash 多草稿会标错行),故移除原 selectLatestDraftByGameAndHash。
}

View File

@ -0,0 +1,55 @@
package com.wanxiang.huijing.game.module.studio.service.studio;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
/**
* 源项目工件 Service(U2/B8 §5.2 源落库事务;游戏 = 长生命周期源项目的源侧存储)
*
* <p>对外语义(被 {@code SourceProjectApi} seam 与 studio 编排复用):落源(status=0)+ status 流转
* (1 已构建 / 2 孤儿)+ source_hash 规范化 sha256 幂等去重。各方法独立短事务,由 status 机器态串联
* (非长事务,§5.2:构建数十秒级,长事务=连接池杀手)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
public interface SourceProjectService {
/**
* 落源(§5.2 步骤①):把图终态产的源项目工件落 game_source_project(status=0 草稿)。
*
* <p>幂等:对 {@code req.sourceJson} 规范化(键排序)后算 sha256 得 source_hash;同 hash 命中既有行 →
* 复用返回既有行({@code reused=true}),不重复 insert(§5.2 去重)。
*
* @param req 落源入参(gameId/sourceJson/schemaVersion/buildProfile/baseVersionId)
* @return 落源结果(源行 ID + source_hash + 是否幂等复用)
*/
SourceProjectLandRespDTO land(SourceProjectLandReqDTO req);
/**
* 标已构建(§5.2 步骤③):构建成功建包后,按源行 ID 回填 version_id + 置 status=1(已构建)。
*
* <p>流转守卫:仅从 0 草稿 / 1 已构建(同 versionId 幂等)流转;其它态不覆写(防误覆孤儿/已发布)。
*
* <p><b>U2/B8 P0-2(跨游戏回填防护)</b>:必须校验源行 {@code game_id} 与任务 {@code gameId} 一致——
* 不一致拒(返 false + warn),绝不把 A 游戏的 version_id 回填到 B 游戏的源行。
*
* @param id 源行 ID(land 返回)
* @param versionId 构建成功建的产物版本 ID
* @param gameId 任务所属游戏 ID(与源行 game_id 一致性校验,防跨游戏回填)
* @return true=成功流转或幂等无副作用;false=源行不存在/gameId 不一致/非法流转(不抛,best-effort 由调用方决断)
*/
boolean markBuilt(Long id, Long versionId, Long gameId);
/**
* 标孤儿(§5.2 步骤④):构建失败/补偿时,按<b>源行 ID 精确定位</b>把草稿源行置 status=2(孤儿)。
*
* <p><b>U2/B8 P1-2(标错行防护,命门)</b>:外层落源已持 {@code landing.sourceId},故按 id 精确定位本次落的源行,
* 不靠 game+hash 取「最新草稿」(并发下多次落源会产同 hash 多草稿行,取最新会标错行)。
* 仅标 status=0 草稿行;非草稿/不存在 → 幂等无副作用返回 false(不报错,§5.2)。不建 package、不动 currentVersion。
*
* @param id 源行 ID(land 返回,外层 landing.sourceId)
* @return true=标记了孤儿行;false=源行不存在/非草稿态(幂等无副作用)
*/
boolean markOrphanById(Long id);
}

View File

@ -0,0 +1,264 @@
package com.wanxiang.huijing.game.module.studio.service.studio;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.GameSourceProjectDO;
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.GameSourceProjectMapper;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
import com.wanxiang.huijing.game.module.studio.enums.SourceProjectStatusEnum;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
import static com.wanxiang.huijing.game.module.studio.enums.ErrorCodeConstants.STUDIO_SOURCE_PROJECT_INVALID;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 源项目工件 Service 实现(U2/B8 §5.2 源落库事务;源侧存储面,与 GamePackage 产物面解耦)
*
* <p><b>事务边界纪律(§5.2 关键,化解孤儿)</b>:本类三方法各开<b>独立短事务</b>({@code @Transactional}),
* 由 status 机器态串联(0 草稿 → 1 已构建 / 2 孤儿),<b>不与建包并入同一大事务</b>(构建数十秒级,
* 长事务=连接池杀手,对齐 {@code SaaGraphDispatcher.java:55-57} 既有纪律)。落源独立 commit 后,即便后续建包
* 失败回滚,源行仍在库(可标孤儿),实现「源已落、包未建」中间态的显式可辨建模(V18 status=2 孤儿)。
*
* <p><b>幂等去重(source_hash)</b>:落源前对 sourceJson <b>规范化(键字母序排序)</b>后算 sha256 得 source_hash;
* 同 hash 命中既有行 → 复用返回(不重复 insert)。规范化保证「逻辑等价的源 JSON(仅键序差异)算同一 hash」,
* 契约 source-project.schema.json 的 sourceHash 即此口径(源 JSON 规范化后 sha256)。malformed JSON(便宜模型脏输出)
* → 退化为原文 trim 后 sha256(仍确定性,不抛)。
*
* <p><b>非阻断边界(命门)</b>:源落库是回调链旁路的 additive 存储面——由调用方(aigc 回调链 best-effort)保证
* 「源落库失败不阻断现有发布链」。本 Service 自身只对「入参非法」(gameId 缺 / sourceJson 空)前置拒(落源无意义),
* status 流转方法对「源行不存在/非法流转」<b>返 false 不抛</b>(由 best-effort 调用方决断,不连坐主链)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
@Service
public class SourceProjectServiceImpl implements SourceProjectService {
private static final Logger log = LoggerFactory.getLogger(SourceProjectServiceImpl.class);
/** 源契约版本缺省(独立于 GamePackage 版本,对齐 schema schemaVersion const "1.0")。 */
private static final String DEFAULT_SCHEMA_VERSION = "1.0";
/** 构建画像缺省(目标引擎/优化档;= buildProfile)。 */
private static final String DEFAULT_BUILD_PROFILE = "default";
/**
* 规范化序列化器(source_hash 幂等根基):开 {@code ORDER_MAP_ENTRIES_BY_KEYS}——把 JSON 反序列化为
* 通用 {@code Object}(对象→LinkedHashMap)后<b>按键字母序递归</b>重序列化 → 逻辑等价输入(仅键序差异)得同一规范文本、
* 同一 sha256。<b>命门</b>:必须反序列化为 {@code Map}(非 {@code JsonNode}/{@code ObjectNode})才生效——
* {@code ORDER_MAP_ENTRIES_BY_KEYS} 只对 {@code java.util.Map} 序列化排序,对 {@code ObjectNode}(非 Map)不排序。
* 静态只读实例,无共享可变状态,线程安全。
*/
private static final ObjectMapper CANONICAL_MAPPER = new ObjectMapper()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.configure(SerializationFeature.INDENT_OUTPUT, false);
@Resource
private GameSourceProjectMapper gameSourceProjectMapper;
/**
* 落源(§5.2 步骤①):源工件落 game_source_project(status=0),source_hash 幂等去重。
*
* <p>独立短事务:本方法 commit 后源行即在库(与建包解耦)。幂等命中既有行则复用、不重复 insert。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public SourceProjectLandRespDTO land(SourceProjectLandReqDTO req) {
// 入参前置校验:gameId 缺 / sourceJson 空 → 落源无意义,前置拒(落源是源工件存储,无源即无落)
if (req == null || req.getGameId() == null || !StringUtils.hasText(req.getSourceJson())) {
throw exception(STUDIO_SOURCE_PROJECT_INVALID);
}
// 规范化 sha256:逻辑等价源(仅键序差异)得同一 hash(可寻址/幂等键,= SourceProject.sourceHash)。
// 命门(P1-2 唯一键前提):sourceHash 恒非空——canonicalSha256 必产 64 位 hex(脏 JSON 也退化为原文 sha256,
// 绝不返空/null),与 V18「source_hash CHAR(64) NOT NULL DEFAULT ''」+ V20 唯一键 uk_game_source_hash 自洽。
String sourceHash = canonicalSha256(req.getSourceJson());
// 幂等去重(§5.2「同 source_hash 重复落库 → 幂等去重」):同 (game_id, source_hash) 命中既有行 → 复用、不重复 insert。
// P0-2:去重必须按 game_id 隔离(source_hash 不含游戏归属,跨游戏同骨架会同 hash),否则游戏 B 复用游戏 A 的源行。
GameSourceProjectDO existing = gameSourceProjectMapper.selectLatestByGameAndSourceHash(req.getGameId(), sourceHash);
if (existing != null) {
log.info("[source-project] 落源命中既有 (game_id, source_hash) 复用(幂等去重,不重复 insert)gameId={}, hash={}, existingId={}, status={}",
req.getGameId(), sourceHash, existing.getId(), existing.getStatus());
return buildReusedResp(existing.getId(), sourceHash);
}
// 新落行:status=0 草稿;schemaVersion/buildProfile 空值兜底缺省;version_id 留 NULL(构建成功才回填)
GameSourceProjectDO source = new GameSourceProjectDO();
source.setGameId(req.getGameId());
source.setSourceJson(req.getSourceJson());
source.setSourceUrl(""); // M0 走 DB(source_json),对象存储 URL 留空串(与 V18 列默认一致)
source.setSourceHash(sourceHash);
source.setSchemaVersion(StringUtils.hasText(req.getSchemaVersion()) ? req.getSchemaVersion() : DEFAULT_SCHEMA_VERSION);
source.setBuildProfile(StringUtils.hasText(req.getBuildProfile()) ? req.getBuildProfile() : DEFAULT_BUILD_PROFILE);
source.setStatus(SourceProjectStatusEnum.DRAFT.getStatus()); // status=0 草稿
source.setBaseVersionId(req.getBaseVersionId()); // modify 血缘(create 时 NULL)
try {
gameSourceProjectMapper.insert(source);
} catch (DuplicateKeyException dup) {
// P1-2 并发兜底:先查未命中 + 后 insert 之间有并发同 (game_id, source_hash) 抢先插入 → 撞 V20 唯一键
// uk_game_source_hash。捕获后重查复用既有行(幂等:并发两路最终复用同一行,不重复落库、不外抛事务回滚)。
GameSourceProjectDO raced = gameSourceProjectMapper.selectLatestByGameAndSourceHash(req.getGameId(), sourceHash);
if (raced != null) {
log.info("[source-project] 落源撞唯一键(并发同 game+hash),重查复用既有行(幂等去重)gameId={}, hash={}, existingId={}",
req.getGameId(), sourceHash, raced.getId());
return buildReusedResp(raced.getId(), sourceHash);
}
// 重查仍空(极罕见:deleted/tenant 维度差异等)→ 无法兜底,原样上抛(best-effort 调用方吞 + warn,不阻断主链)
log.warn("[source-project] 落源撞唯一键但重查无既有行(异常并发态),上抛交调用方 best-effort 处理 gameId={}, hash={}",
req.getGameId(), sourceHash, dup);
throw dup;
}
log.info("[source-project] 落源完成(status=0 草稿)gameId={}, sourceId={}, hash={}, baseVersionId={}",
req.getGameId(), source.getId(), sourceHash, req.getBaseVersionId());
SourceProjectLandRespDTO resp = new SourceProjectLandRespDTO();
resp.setId(source.getId());
resp.setSourceHash(sourceHash);
resp.setReused(Boolean.FALSE);
return resp;
}
/** 构造「幂等复用既有行」落源结果(id=既有行 ID,reused=true)。 */
private static SourceProjectLandRespDTO buildReusedResp(Long existingId, String sourceHash) {
SourceProjectLandRespDTO resp = new SourceProjectLandRespDTO();
resp.setId(existingId);
resp.setSourceHash(sourceHash);
resp.setReused(Boolean.TRUE);
return resp;
}
/**
* 标已构建(§5.2 步骤③):按源行 ID 回填 version_id + status=1。
*
* <p>流转守卫:仅从 0 草稿 / 1 已构建(同 versionId 幂等)流转;其它态(孤儿/已发布)不覆写、返 false。
* 源行不存在 → 返 false(不抛,best-effort 调用方决断)。
*
* <p><b>P0-2 跨游戏回填防护</b>:先校源行 game_id 与任务 gameId 一致——不一致拒(返 false + warn),
* 绝不把 A 游戏 version_id 回填到 B 游戏源行(重复回调/源行复用错乱的最后一道防线)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean markBuilt(Long id, Long versionId, Long gameId) {
if (id == null || versionId == null) {
log.warn("[source-project] 标已构建入参缺失,跳过 id={}, versionId={}", id, versionId);
return false;
}
GameSourceProjectDO source = gameSourceProjectMapper.selectById(id);
if (source == null) {
// 理论不可达(land 刚落、同回调方法作用域内回填);防御返 false 不抛
log.warn("[source-project] 标已构建目标源行不存在,跳过 id={}, versionId={}", id, versionId);
return false;
}
// P0-2:源行归属校验——源行 game_id 必须与任务 gameId 一致,不一致绝不回填(防跨游戏版本血缘污染)
if (gameId != null && !Objects.equals(source.getGameId(), gameId)) {
log.warn("[source-project] 标已构建源行归属不一致(拒回填,防跨游戏污染)id={}, 源行 gameId={}, 任务 gameId={}, versionId={}",
id, source.getGameId(), gameId, versionId);
return false;
}
// 幂等:已 status=1 且 version_id 一致 → 无副作用返 true(重复回调安全)
if (Objects.equals(source.getStatus(), SourceProjectStatusEnum.BUILT.getStatus())
&& Objects.equals(source.getVersionId(), versionId)) {
log.info("[source-project] 标已构建幂等短路(已 status=1 且 versionId 一致)id={}, versionId={}", id, versionId);
return true;
}
// 流转守卫:仅从 0 草稿 流转到 1 已构建(防误覆孤儿/已发布等终态)
if (!Objects.equals(source.getStatus(), SourceProjectStatusEnum.DRAFT.getStatus())) {
log.warn("[source-project] 标已构建非法流转(仅 0 草稿 → 1 已构建),跳过 id={}, 当前 status={}, versionId={}",
id, source.getStatus(), versionId);
return false;
}
GameSourceProjectDO update = new GameSourceProjectDO();
update.setId(id);
update.setVersionId(versionId);
update.setStatus(SourceProjectStatusEnum.BUILT.getStatus()); // status=1 已构建
gameSourceProjectMapper.updateById(update);
log.info("[source-project] 标已构建完成(回填 version_id + status=1)id={}, versionId={}", id, versionId);
return true;
}
/**
* 标孤儿(§5.2 步骤④):按<b>源行 ID 精确定位</b>把草稿源行置 status=2。
*
* <p><b>P1-2 标错行防护</b>:外层已持 land 返回的 sourceId,故按 id 精确定位本次落的源行,不靠 game+hash 取「最新草稿」
* (并发多次落源会产同 hash 多草稿行,取最新会标错行)。仅标 status=0 草稿行;非草稿/不存在 → 幂等无副作用返 false。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean markOrphanById(Long id) {
if (id == null) {
log.warn("[source-project] 标孤儿入参缺失,跳过 id=null");
return false;
}
GameSourceProjectDO source = gameSourceProjectMapper.selectById(id);
if (source == null) {
// 源行不存在(未落源/落源失败):构建失败但源未落 = 无孤儿可标,幂等无副作用(§5.2)
log.info("[source-project] 标孤儿目标源行不存在(未落源/已删),幂等无副作用 id={}", id);
return false;
}
// 仅标 status=0 草稿行(避免误覆既有 built/published/已孤儿终态;非草稿幂等无副作用,§5.2 步骤④)
if (!Objects.equals(source.getStatus(), SourceProjectStatusEnum.DRAFT.getStatus())) {
log.info("[source-project] 标孤儿非草稿态(仅 0 草稿 → 2 孤儿),幂等无副作用 id={}, 当前 status={}", id, source.getStatus());
return false;
}
GameSourceProjectDO update = new GameSourceProjectDO();
update.setId(id);
update.setStatus(SourceProjectStatusEnum.ORPHAN.getStatus()); // status=2 孤儿
gameSourceProjectMapper.updateById(update);
log.info("[source-project] 标孤儿完成(status=2,不建包不动 currentVersion)sourceId={}, gameId={}", id, source.getGameId());
return true;
}
// ============================== 私有:规范化 sha256 ==============================
/**
* 规范化 sha256:把源 JSON 反序列化后按键字母序重序列化 → 算 sha256(可寻址/幂等键,= SourceProject.sourceHash)。
*
* <p>规范化保证「逻辑等价源(仅键序/空白差异)算同一 hash」;malformed JSON(便宜模型脏输出)解析失败 →
* 退化为原文 trim 后 sha256(仍确定性,best-effort 不抛——落源不因脏 JSON 二次崩,§5.2 非阻断)。
*
* @param sourceJson 源工件 JSON 全文
* @return 64 位小写 hex sha256
*/
private static String canonicalSha256(String sourceJson) {
String canonical;
try {
// 反序列化为通用 Object(对象→LinkedHashMap)→ 按键字母序递归重序列化(命门:Map 才被 ORDER_MAP_ENTRIES_BY_KEYS 排序)
Object tree = CANONICAL_MAPPER.readValue(sourceJson, Object.class);
canonical = CANONICAL_MAPPER.writeValueAsString(tree);
} catch (Exception e) {
// 脏 JSON 退化:原文 trim 后算 hash(仍确定性,不抛;落源不因脏输入崩)
log.warn("[source-project] 源 JSON 规范化失败(非法 JSON),退化为原文 sha256(仍确定性幂等)", e);
canonical = sourceJson.trim();
}
return sha256Hex(canonical);
}
/**
* 计算文本 UTF-8 字节的 sha256(hex 小写 64 位;与 schema sourceHash pattern ^[a-f0-9]{64}$ 对齐)。
*/
private static String sha256Hex(String text) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
// JVM 必带 SHA-256,理论不可达;防御上抛(落源事务回滚,best-effort 调用方吞)
throw new IllegalStateException("SHA-256 算法不可用", e);
}
}
}

View File

@ -0,0 +1,331 @@
package com.wanxiang.huijing.game.module.studio.service.studio;
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.GameSourceProjectDO;
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.GameSourceProjectMapper;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
import com.wanxiang.huijing.game.module.studio.enums.SourceProjectStatusEnum;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import static com.wanxiang.huijing.game.module.studio.enums.ErrorCodeConstants.STUDIO_SOURCE_PROJECT_INVALID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link SourceProjectServiceImpl} 单元测试(纯 Mockito,不依赖 DB;U2/B8 §5.2 源落库事务)
*
* <p>覆盖 U2 Test scenarios 中「落库服务」侧(含 codex 评审 P0/P1/P2 修复用例):
* <ul>
* <li>happy:落源 status=0 草稿(reused=false,insert 被调);</li>
* <li>edge:同 (game_id, source_hash) 重复落库 → 幂等去重(命中既有行复用、不重复 insert,reused=true);</li>
* <li><b>P0-2</b>:跨游戏同 hash 不复用源行(去重按 game_id 隔离——游戏 B 同 hash 但既有行属游戏 A → 不命中 → 新落行);</li>
* <li><b>P1-2</b>:落源撞唯一键(并发)→ 捕获 DuplicateKeyException 重查复用既有行(幂等兜底);</li>
* <li>happy:markBuilt 0→1 回填 version_id(仅从草稿流转);</li>
* <li>edge:markBuilt 对孤儿/已发布行非法流转 → 不覆写返 false;markBuilt 幂等(已 1 且 versionId 一致);</li>
* <li><b>P0-2</b>:markBuilt 源行 gameId 与任务 gameId 不一致 → 拒回填返 false(防跨游戏污染);</li>
* <li><b>P1-2</b>:markOrphanById 按源行 ID 精确标 0→2;edge:非草稿态/源行不存在 → 幂等无副作用返 false;</li>
* <li>edge:source_hash 规范化稳定(仅键序差异的两份源 JSON 算同一 hash → 幂等命中);</li>
* <li>error:入参非法(gameId 缺 / sourceJson 空)→ 1-112-004-000 前置拒。</li>
* </ul>
*
* <p>注意:mock {@code BaseMapper.insert} 用 {@code doAnswer} 回填生成 ID(Mockito 下 insert 不真写库不回填 ID);
* {@code updateById} 用 {@code any(GameSourceProjectDO.class)} 消歧(BaseMapper 重载,裸 any() 编译失败)。
*
* @author 造梦AI(U2/B8 源项目落库事务)
*/
class SourceProjectServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private SourceProjectServiceImpl sourceProjectService;
@Mock
private GameSourceProjectMapper gameSourceProjectMapper;
/** 最小合法源工件 JSON(asset 节点骨架口径:schemaVersion + assets[] + 空 gameDefinition)。 */
private static final String SOURCE_JSON = "{\"schemaVersion\":\"1.0\",\"gameDefinition\":{},\"assets\":[]}";
// ============================== 用例1:happy 落源 status=0 ==============================
@Test
void testLand_happyDraft() {
// 无既有 (game_id, source_hash) 命中 → 走 insert(doAnswer 回填生成 ID)
when(gameSourceProjectMapper.selectLatestByGameAndSourceHash(anyLong(), anyString())).thenReturn(null);
doAnswer(inv -> {
((GameSourceProjectDO) inv.getArgument(0)).setId(2001L);
return 1;
}).when(gameSourceProjectMapper).insert(any(GameSourceProjectDO.class));
SourceProjectLandReqDTO req = landReq();
SourceProjectLandRespDTO resp = sourceProjectService.land(req);
// 落源结果:新行 ID + 非复用 + 64 位 hex hash
assertEquals(2001L, resp.getId());
assertFalse(resp.getReused());
assertEquals(64, resp.getSourceHash().length());
assertTrue(resp.getSourceHash().matches("[a-f0-9]{64}"));
// insert 入参断言:status=0 草稿 + version_id 留 NULL + schemaVersion/buildProfile 兜底
ArgumentCaptor<GameSourceProjectDO> captor = ArgumentCaptor.forClass(GameSourceProjectDO.class);
verify(gameSourceProjectMapper).insert(captor.capture());
GameSourceProjectDO inserted = captor.getValue();
assertEquals(5L, inserted.getGameId());
assertEquals(SourceProjectStatusEnum.DRAFT.getStatus(), inserted.getStatus()); // status=0
assertNull(inserted.getVersionId()); // 构建成功才回填
assertEquals("1.0", inserted.getSchemaVersion());
assertEquals("default", inserted.getBuildProfile());
assertEquals(SOURCE_JSON, inserted.getSourceJson());
assertEquals(resp.getSourceHash(), inserted.getSourceHash());
}
// ============================== 用例2:edge 同 hash 幂等去重 ==============================
@Test
void testLand_idempotentDedupByHash() {
// 既有 (game_id, source_hash) 命中既有行 → 复用、不重复 insert(§5.2 去重)
GameSourceProjectDO existing = new GameSourceProjectDO();
existing.setId(1500L);
existing.setStatus(SourceProjectStatusEnum.DRAFT.getStatus());
when(gameSourceProjectMapper.selectLatestByGameAndSourceHash(anyLong(), anyString())).thenReturn(existing);
SourceProjectLandRespDTO resp = sourceProjectService.land(landReq());
assertEquals(1500L, resp.getId()); // 复用既有行 ID
assertTrue(resp.getReused()); // 幂等命中
verify(gameSourceProjectMapper, never()).insert(any(GameSourceProjectDO.class)); // 不重复 insert
}
// ============================== 用例2b(P0-2):跨游戏同 hash 不复用源行 ==============================
@Test
void testLand_crossGameSameHashNotReused() {
// P0-2:去重按 (game_id, source_hash) 联合键——游戏 7 落同骨架源(同 hash),但既有行属游戏 5,
// selectLatestByGameAndSourceHash(7, hash) 不命中(被 game_id 过滤)→ 走 insert 新落游戏 7 自己的源行(不复用游戏 5)。
when(gameSourceProjectMapper.selectLatestByGameAndSourceHash(eq(7L), anyString())).thenReturn(null);
doAnswer(inv -> { ((GameSourceProjectDO) inv.getArgument(0)).setId(7001L); return 1; })
.when(gameSourceProjectMapper).insert(any(GameSourceProjectDO.class));
SourceProjectLandReqDTO reqGame7 = landReq();
reqGame7.setGameId(7L); // 与既有行(游戏 5)不同游戏、同源 JSON(同 hash)
SourceProjectLandRespDTO resp = sourceProjectService.land(reqGame7);
// 不复用游戏 5 的行:新落游戏 7 自己的草稿行(reused=false)
assertFalse(resp.getReused());
assertEquals(7001L, resp.getId());
// 去重查必按 game_id=7 隔离(坐实不会查到/复用游戏 5 的行)
verify(gameSourceProjectMapper).selectLatestByGameAndSourceHash(eq(7L), anyString());
ArgumentCaptor<GameSourceProjectDO> captor = ArgumentCaptor.forClass(GameSourceProjectDO.class);
verify(gameSourceProjectMapper).insert(captor.capture());
assertEquals(7L, captor.getValue().getGameId()); // 新行归属游戏 7
}
// ============================== 用例2c(P1-2):落源撞唯一键并发兜底重查复用 ==============================
@Test
void testLand_duplicateKeyConcurrentFallbackReuse() {
// P1-2 并发兜底:先查未命中 → insert 撞 V20 唯一键(DuplicateKeyException,模拟并发抢插)→ 重查命中复用既有行。
GameSourceProjectDO raced = new GameSourceProjectDO();
raced.setId(8001L);
raced.setStatus(SourceProjectStatusEnum.DRAFT.getStatus());
// 第一次查(insert 前)返 null;第二次查(catch 内重查)返并发已插入的行
when(gameSourceProjectMapper.selectLatestByGameAndSourceHash(anyLong(), anyString()))
.thenReturn(null, raced);
doThrow(new DuplicateKeyException("uk_game_source_hash 唯一键冲突(模拟并发)"))
.when(gameSourceProjectMapper).insert(any(GameSourceProjectDO.class));
SourceProjectLandRespDTO resp = sourceProjectService.land(landReq());
// 兜底复用既有行(不外抛、不重复落库)
assertEquals(8001L, resp.getId());
assertTrue(resp.getReused());
verify(gameSourceProjectMapper, times(2)).selectLatestByGameAndSourceHash(anyLong(), anyString()); // 先查 + catch 重查
}
// ============================== 用例3:edge source_hash 规范化稳定(键序无关)==============================
@Test
void testLand_canonicalHashKeyOrderInvariant() {
when(gameSourceProjectMapper.selectLatestByGameAndSourceHash(anyLong(), anyString())).thenReturn(null);
doAnswer(inv -> { ((GameSourceProjectDO) inv.getArgument(0)).setId(1L); return 1; })
.when(gameSourceProjectMapper).insert(any(GameSourceProjectDO.class));
// 两份逻辑等价、仅键序不同的源 JSON
SourceProjectLandReqDTO a = landReq();
a.setSourceJson("{\"schemaVersion\":\"1.0\",\"assets\":[],\"gameDefinition\":{}}");
SourceProjectLandReqDTO b = landReq();
b.setSourceJson("{\"gameDefinition\":{},\"assets\":[],\"schemaVersion\":\"1.0\"}");
String hashA = sourceProjectService.land(a).getSourceHash();
String hashB = sourceProjectService.land(b).getSourceHash();
// 规范化(键字母序)后两者算同一 hash(幂等可寻址键不受键序/空白影响)
assertEquals(hashA, hashB);
}
// ============================== 用例4:happy markBuilt 0→1 回填 version_id ==============================
@Test
void testMarkBuilt_draftToBuilt() {
GameSourceProjectDO draft = new GameSourceProjectDO();
draft.setId(2001L);
draft.setGameId(5L); // 源行归属游戏 5(与任务 gameId 一致)
draft.setStatus(SourceProjectStatusEnum.DRAFT.getStatus()); // status=0
when(gameSourceProjectMapper.selectById(2001L)).thenReturn(draft);
boolean ok = sourceProjectService.markBuilt(2001L, 8888L, 5L); // 任务 gameId=5 与源行一致
assertTrue(ok);
// updateById 入参:回填 version_id + status=1
ArgumentCaptor<GameSourceProjectDO> captor = ArgumentCaptor.forClass(GameSourceProjectDO.class);
verify(gameSourceProjectMapper).updateById(captor.capture());
GameSourceProjectDO update = captor.getValue();
assertEquals(2001L, update.getId());
assertEquals(8888L, update.getVersionId()); // version_id 回填
assertEquals(SourceProjectStatusEnum.BUILT.getStatus(), update.getStatus()); // status=1
}
// ============================== 用例4b(P0-2):markBuilt 源行 gameId 与任务不一致 → 拒回填 ==============================
@Test
void testMarkBuilt_rejectGameIdMismatch() {
// P0-2:源行属游戏 5,但任务 gameId=9(重复回调/源行复用错乱)→ 归属校验失败,拒回填(防跨游戏版本血缘污染)
GameSourceProjectDO draft = new GameSourceProjectDO();
draft.setId(2010L);
draft.setGameId(5L); // 源行属游戏 5
draft.setStatus(SourceProjectStatusEnum.DRAFT.getStatus());
when(gameSourceProjectMapper.selectById(2010L)).thenReturn(draft);
boolean ok = sourceProjectService.markBuilt(2010L, 8888L, 9L); // 任务 gameId=9 ≠ 源行 gameId=5
assertFalse(ok); // 拒回填
verify(gameSourceProjectMapper, never()).updateById(any(GameSourceProjectDO.class)); // 绝不回填
}
// ============================== 用例5:edge markBuilt 对孤儿行非法流转不覆写 ==============================
@Test
void testMarkBuilt_rejectNonDraftTransition() {
GameSourceProjectDO orphan = new GameSourceProjectDO();
orphan.setId(2002L);
orphan.setGameId(5L); // gameId 一致(隔离归属校验,单测非法流转本身)
orphan.setStatus(SourceProjectStatusEnum.ORPHAN.getStatus()); // status=2 孤儿
when(gameSourceProjectMapper.selectById(2002L)).thenReturn(orphan);
boolean ok = sourceProjectService.markBuilt(2002L, 8888L, 5L);
assertFalse(ok); // 非草稿态不流转
verify(gameSourceProjectMapper, never()).updateById(any(GameSourceProjectDO.class)); // 不覆写
}
// ============================== 用例6:edge markBuilt 幂等(已 1 且 versionId 一致)==============================
@Test
void testMarkBuilt_idempotentWhenAlreadyBuilt() {
GameSourceProjectDO built = new GameSourceProjectDO();
built.setId(2003L);
built.setGameId(5L); // gameId 一致(归属校验先过,再判幂等)
built.setStatus(SourceProjectStatusEnum.BUILT.getStatus()); // 已 status=1
built.setVersionId(8888L); // 同 versionId
when(gameSourceProjectMapper.selectById(2003L)).thenReturn(built);
boolean ok = sourceProjectService.markBuilt(2003L, 8888L, 5L);
assertTrue(ok); // 幂等短路
verify(gameSourceProjectMapper, never()).updateById(any(GameSourceProjectDO.class)); // 零写入
}
// ============================== 用例7:edge markBuilt 源行不存在 ==============================
@Test
void testMarkBuilt_sourceNotExists() {
when(gameSourceProjectMapper.selectById(9999L)).thenReturn(null);
boolean ok = sourceProjectService.markBuilt(9999L, 8888L, 5L);
assertFalse(ok); // 不存在返 false 不抛
verify(gameSourceProjectMapper, never()).updateById(any(GameSourceProjectDO.class));
}
// ============================== 用例8(P1-2):happy markOrphanById 草稿→2(按源行 ID 精确标)==============================
@Test
void testMarkOrphanById_draftToOrphan() {
GameSourceProjectDO draft = new GameSourceProjectDO();
draft.setId(2004L);
draft.setGameId(5L);
draft.setStatus(SourceProjectStatusEnum.DRAFT.getStatus());
when(gameSourceProjectMapper.selectById(2004L)).thenReturn(draft); // 按 id 精确定位
boolean ok = sourceProjectService.markOrphanById(2004L);
assertTrue(ok);
ArgumentCaptor<GameSourceProjectDO> captor = ArgumentCaptor.forClass(GameSourceProjectDO.class);
verify(gameSourceProjectMapper).updateById(captor.capture());
GameSourceProjectDO update = captor.getValue();
assertEquals(2004L, update.getId());
assertEquals(SourceProjectStatusEnum.ORPHAN.getStatus(), update.getStatus()); // status=2
assertNull(update.getVersionId()); // 孤儿不回填 version_id
}
// ============================== 用例9(P1-2):edge markOrphanById 源行不存在 ==============================
@Test
void testMarkOrphanById_sourceNotExists() {
// 构建失败但源未落(或已删)→ 按 id 查不到,幂等无副作用
when(gameSourceProjectMapper.selectById(9990L)).thenReturn(null);
boolean ok = sourceProjectService.markOrphanById(9990L);
assertFalse(ok);
verify(gameSourceProjectMapper, never()).updateById(any(GameSourceProjectDO.class));
}
// ============================== 用例9b(P1-2):edge markOrphanById 非草稿态不覆写 ==============================
@Test
void testMarkOrphanById_rejectNonDraft() {
// 源行已 status=1 已构建(如重复回调先 markBuilt 再误触标孤儿)→ 仅标 0 草稿,非草稿幂等无副作用,绝不覆写已构建
GameSourceProjectDO built = new GameSourceProjectDO();
built.setId(2005L);
built.setGameId(5L);
built.setStatus(SourceProjectStatusEnum.BUILT.getStatus()); // status=1
built.setVersionId(8888L);
when(gameSourceProjectMapper.selectById(2005L)).thenReturn(built);
boolean ok = sourceProjectService.markOrphanById(2005L);
assertFalse(ok); // 非草稿不流转
verify(gameSourceProjectMapper, never()).updateById(any(GameSourceProjectDO.class)); // 绝不覆写已构建行
}
// ============================== 用例10:error 入参非法前置拒 ==============================
@Test
void testLand_invalidArgs() {
// gameId 缺
SourceProjectLandReqDTO noGame = landReq();
noGame.setGameId(null);
ServiceException ex1 = assertThrows(ServiceException.class, () -> sourceProjectService.land(noGame));
assertEquals(STUDIO_SOURCE_PROJECT_INVALID.getCode(), ex1.getCode());
// sourceJson 空
SourceProjectLandReqDTO noJson = landReq();
noJson.setSourceJson(" ");
ServiceException ex2 = assertThrows(ServiceException.class, () -> sourceProjectService.land(noJson));
assertEquals(STUDIO_SOURCE_PROJECT_INVALID.getCode(), ex2.getCode());
// 前置拒:无任何 insert
verify(gameSourceProjectMapper, never()).insert(any(GameSourceProjectDO.class));
}
// ============================== 私有构造 ==============================
private static SourceProjectLandReqDTO landReq() {
SourceProjectLandReqDTO req = new SourceProjectLandReqDTO();
req.setGameId(5L);
req.setSourceJson(SOURCE_JSON);
return req;
}
}

View File

@ -0,0 +1,22 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块:studio(game_source_project)| owner:后端线(6c6g 编辑 / mini-desktop 验迁移)
-- 文件:V20.0.0__game_source_project_add_uk.sql(Flyway,只新增;接 V19.0.0 之后;
-- 已合入禁止修改,回滚写新补偿迁移 V20.0.1 DROP INDEX,遵 V2.0.0/V17/V18 头注铁律)
-- 背书:U2/B8 codex 评审 P1-2(并发无唯一键)——源落库「先查未命中 + 后 insert」之间存在并发窗口,
-- 两路并发同 (game_id, source_hash) 会双插入两行草稿,破坏「同源幂等去重」语义。补唯一键由 DB 兜底并发幂等。
-- 内容:给 game_source_project 加唯一键 uk_game_source_hash(game_id, source_hash, deleted, tenant_id)。
-- 为何这四列(关键):
-- - game_id + source_hash:去重业务键(P0-2 同步——去重按 game_id 隔离,跨游戏同 hash 不复用;source_hash 仅源 JSON 规范化摘要不含归属);
-- - deleted:Yudao 逻辑删除维度——逻辑删行(deleted=1)不应与新活行(deleted=0)相撞,故纳入唯一键(既有 Yudao 唯一键惯例);
-- - tenant_id:多租户隔离维度——不同租户同 (game_id, source_hash) 互不冲突(MVP 单租户=0,纳入以备多租户演进)。
-- source_hash 恒非空(唯一键前提,已核实):落库服务 canonicalSha256 必产 64 位 hex(脏 JSON 也退化为原文 sha256,绝不返空/null);
-- V18 列定义 source_hash CHAR(64) NOT NULL DEFAULT '' → 即便兜底也是定长非空串,唯一键各行键值齐全、无 NULL 不计入唯一性的歧义。
-- 并发兜底闭环:落库 insert 捕获 DuplicateKeyException 后重查 selectLatestByGameAndSourceHash 复用既有行(见 SourceProjectServiceImpl.land)。
-- additive 非阻断:纯加索引、不改列、不改既有行;本表自 V18 新增、为旁路 additive 存储面,加唯一键不影响既有发布链(三表写链)。
-- ⚠️ 上线前置:若库内已有重复 (game_id, source_hash, deleted, tenant_id) 历史草稿行(旧版无唯一键并发产),须先去重再建键,否则 ADD UNIQUE 失败。
-- 本表为 2026-06-17 新增、MVP 各 lane 数据极少,正常为空表直接建键即可(mini-desktop 迁移验证为准)。
-- 回滚:additive → 真要 drop 写 V20.0.1 补偿迁移 DROP INDEX uk_game_source_hash(不改本文件,遵 V2.0.0 头注铁律)。
-- 错误码段:studio = 1-112-***-***(本迁移不引入新错误码)。
-- =============================================================================
ALTER TABLE `game_source_project`
ADD UNIQUE KEY `uk_game_source_hash` (`game_id`, `source_hash`, `deleted`, `tenant_id`) COMMENT '同游戏同源 hash 幂等唯一键(并发兜底;含 deleted/tenant_id 维度避让逻辑删/多租户冲突)';