diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcTaskStatusEnum.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcTaskStatusEnum.java index 724a0eda..913e352a 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcTaskStatusEnum.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcTaskStatusEnum.java @@ -42,6 +42,23 @@ public enum AigcTaskStatusEnum { return Objects.equals(QUEUED.status, status) || Objects.equals(RUNNING.status, status); } + /** + * 是否为终态(succeeded/failed/timed_out/canceled)——终态任务的回调应视为重复/迟到,不应再触发副作用。 + * + *

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)——仅终态失败可重试 * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml index 2c116955..331724c5 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml +++ b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml @@ -74,6 +74,15 @@ ${revision} + + + com.wanxiang + game-module-studio-api + ${revision} + + com.wanxiang diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/task/vo/DifyCallbackReqVO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/task/vo/DifyCallbackReqVO.java index a4a00c27..13658d8e 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/task/vo/DifyCallbackReqVO.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/task/vo/DifyCallbackReqVO.java @@ -83,4 +83,24 @@ public class DifyCallbackReqVO { + "固定架构扩展键(§5.8,additive 开放 Map):modelTier/escalationEvents/cacheHit/giveupDumpPath/cost") private Map trace; + /** + * 源项目工件 JSON 全文(U2/B8 §5.2 additive 字段;「改源不改产物」基座的源侧载体)。 + * + *

形态: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 孤儿。 + * + *

诚实边界(核实结论):当前 SAA happy create 路图终态的 sourceProject 仅为 asset 节点产的最小骨架 + * (schemaVersion + assets[6] +(空/缺)gameDefinition),非实体/场景/规则齐全的完整源项目(真完整源依赖生成 agent + * 真填 gameDefinition,属跨单元 U1 已接线 state 键 + 生成 agent 真产)。本字段「图终态有什么就带什么」,不伪造。 + * + *

非阻断/兼容:additive 可选——存量回调/桩不带本字段时为 null,源落库整段旁路(不落 game_source_project), + * 既有发布链(建版本→组包→落包→回填三表)字节零变化;源落库失败不得阻断现有发布链(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; + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java index 3446a276..ff07a0ec 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java @@ -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; } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java index ad033053..e2843800 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java @@ -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)。 + * + *

为何挂在外层非事务编排(命门,事务边界):本类 {@link #handleCallback} 不开事务,{@code SourceProjectApi} + * 各方法在 studio 侧自开独立短事务(REQUIRED 传播;本类无事务可加入 → 必开新事务、独立 commit)。这正是 + * §5.2「源落库与建包非同一大事务」要求:若挂在内层 {@code handleCallbackTx}(@Transactional),land 会被 + * REQUIRED 并入建包大事务、随建包失败一起回滚 → 孤儿源行无法留存、status=2 无从标。故落源/标孤儿必须在本外层 + * (建包提交前落源、建包失败后标孤儿、建包成功后回填),与既有「提交后 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 挂点(命门):内层事务已提交后执行「回查任务 → 生成完成通知 → 源态回填」。 + * + *

整段独立兜底(与写链补偿路彻底隔离):本方法内任何异常一律吞 + error log,绝不外抛—— + * 内层事务已成功提交,本段失败不应反向触发写链补偿(compensateQuietly/markSourceOrphanQuietly 会把已成功任务误置 failed), + * 亦不应让 handleCallback 改返已提交的 result。这与既有「提交后 notify」best-effort 挂点同性(§6.5 挂点1)。 + * + *

真实成功判据:仅 SUCCEEDED 终态且 versionId 已回填 = 真实成功(天然排除 config_invalid/failed/幂等短路无新版本—— + * 它们也 return TRUE,故 result==TRUE 不能作判据)。version_id 取自此回查任务,notify 与 markBuilt 同源。 + * + *

P1-1(通知/回填解耦):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 幂等去重)。 + * + *

非阻断硬约束(命门):全程 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 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 步骤③)。 + * + *

非阻断: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 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 步骤④)。 + * + *

非阻断:landing 为 null / sourceId 空(未落源)→ 旁路(构建失败但源未落 = 无孤儿可标);调用异常吞 + error log + * (绝不掩盖原写链异常——原异常必须继续上抛给编排器,本方法只做 best-effort 源态标记)。 + * + *

P1-2 标错行防护:按 {@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 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); + } + } + /** * 静默补偿(补偿自身失败只留痕、不吞原异常——原写链异常必须继续向上抛给编排器) * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java index 809a6867..6b8057b4 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java @@ -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 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 步骤④,集成:源落库与建包非同一事务):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 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 抛异常 → 源落库失败被吞,主回调链照常完成(建包三表链不受影响、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(); diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/api/SourceProjectApi.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/api/SourceProjectApi.java new file mode 100644 index 00000000..cf5f4e6e --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/api/SourceProjectApi.java @@ -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) + * + *

为什么是 seam:源项目工件表 {@code game_source_project} 归 studio 模块(编排层产物,§12-①), + * 但「源 JSON 在图终态可得」「version_id 在建包后才生」两个时机都落在 aigc 回调链({@code DifyCallbackTxService}/ + * {@code DifyCallbackServiceImpl})。aigc 跨模块只依赖对方 -api(守门④),故由本 seam 桥接:aigc 回调链调本 API + * 完成「落源 / 回填 version_id / 标孤儿」三态。MVP 单体内由 studio 模块 {@code SourceProjectApiImpl}(@RestController + * @Primary) 就地解析(同进程方法调用,非真实 Feign),与 ProjectVersionApi/RuntimePackageApi 同款模式。 + * + *

事务边界(§5.2 关键,化解孤儿):源落库与建包非同一大事务(构建数十秒级,长事务=连接池杀手, + * 对齐 {@code SaaGraphDispatcher.java:55-57})。本 API 三方法各自独立短事务、用 status 机器态串联: + *

+ *   ① {@link #land}      → 源落库 status=0(草稿),source_hash 幂等去重(同 hash 命中复用不重复 insert)
+ *   ② 触发确定性构建(建包走 handleCallback 唯一写入路径)
+ *   ③ 构建成功 → {@link #markBuilt} 回填 version_id + status=1(已构建)
+ *   ④ 构建失败 → {@link #markOrphan} status=2(孤儿),不建 package、不动 currentVersion
+ *   
+ * + *

非阻断纪律:源落库是回调链旁路的 additive 存储面,失败不得阻断现有发布链(建版本→组包→落包→回填三表)。 + * 调用方(aigc 回调链)以 best-effort 方式调本 API:吞异常 + warn、源态缺失不影响已落库的 GamePackage 产物 + * (与 {@code persistTraceQuietly} 同款非阻断范式)。 + * + *

设计纪律:跨模块只依赖 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 草稿)。 + * + *

幂等: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 land(@RequestBody SourceProjectLandReqDTO req); + + /** + * 标已构建(§5.2 步骤③):构建成功建包后,回填源行的 version_id + 置 status=1(已构建)。 + * + *

按落源返回的源行 ID 精确定位(与建包同一回调方法作用域内可得,避免 traceId/gameId 多行歧义)。 + * 幂等:源行已 status=1 且 version_id 一致 → 无副作用返回(重复回调安全)。 + * + *

P0-2 跨游戏回填防护:传任务 {@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 markBuilt(@RequestParam("id") Long id, + @RequestParam("versionId") Long versionId, + @RequestParam("gameId") Long gameId); + + /** + * 标孤儿(§5.2 步骤④):构建失败/补偿时,按源行 ID 精确定位把草稿源行置 status=2(孤儿)。 + * + *

P1-2 标错行防护:外层落源已持 {@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 markOrphanById(@RequestParam("id") Long id); + +} diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectLandReqDTO.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectLandReqDTO.java new file mode 100644 index 00000000..3a8ad5aa --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectLandReqDTO.java @@ -0,0 +1,51 @@ +package com.wanxiang.huijing.game.module.studio.dto; + +import lombok.Data; + +/** + * 源项目落库跨模块入参(U2/B8 §5.2 事务边界步骤①「源落库 status=0」) + * + *

用途: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 重复落库 → 幂等去重」)。 + * + *

归属/边界:源项目工件归 studio 模块(编排层产物,§12-①);本表只持 version_id 引用(构建成功建包后由 + * {@code markBuilt} 回填),版本产物权威仍归 project(game_version)。跨模块只依赖 studio 的 -api(守门④)。 + * + *

诚实边界(核实结论):当前 SAA happy create 路图终态的 {@code sourceProject} 仅为 asset 节点产的 + * 最小骨架(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; +} diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectLandRespDTO.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectLandRespDTO.java new file mode 100644 index 00000000..14c36452 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectLandRespDTO.java @@ -0,0 +1,30 @@ +package com.wanxiang.huijing.game.module.studio.dto; + +import lombok.Data; + +/** + * 源项目落库结果(U2/B8 §5.2 步骤①返回) + * + *

回传落库后的源行 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; +} diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java index 9f4d901a..80785f2a 100644 --- a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java @@ -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 空)"); + } diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/SourceProjectStatusEnum.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/SourceProjectStatusEnum.java new file mode 100644 index 00000000..21daed90 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/SourceProjectStatusEnum.java @@ -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 事务边界) + * + *

源态串联生成主线「改源不改产物」基座的源侧生命周期(非长事务,用 status 机器态串联,§5.2): + * DRAFT(0) 源落库成功 → BUILT(1) 构建成功建包回填 version_id / ORPHAN(2) 构建失败不建包 / PUBLISHED(3) 已发布。 + * + *

合法流转(Service 校验,DO 层不承载): + *

+ * + * @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); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/api/SourceProjectApiImpl.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/api/SourceProjectApiImpl.java new file mode 100644 index 00000000..cc644e4f --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/api/SourceProjectApiImpl.java @@ -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 回调链落源/回填/标孤儿) + * + *

{@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 land(SourceProjectLandReqDTO req) { + // 落源:委托 Service 落 game_source_project(status=0),source_hash 幂等去重 + return success(sourceProjectService.land(req)); + } + + @Override + public CommonResult 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 markOrphanById(Long id) { + // 标孤儿:按源行 ID 精确定位置 status=2(P1-2;非草稿/不存在幂等无副作用) + return success(sourceProjectService.markOrphanById(id)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/GameSourceProjectDO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/GameSourceProjectDO.java new file mode 100644 index 00000000..27614c8e --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/GameSourceProjectDO.java @@ -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)——「改源不改打包产物」的源侧存储面 + * + *

继承 {@link TenantBaseDO}:自动携带审计列(creator/create_time/updater/update_time/deleted)+ tenant_id。 + * + *

范式(生命周期项目模型):游戏 = 长生命周期源项目(LLM as 工作室);本表存「可维护的结构化源项目工件」 + * (SourceProject schema = contracts/agent-loop/source-project.schema.json);构建产物(bundle/manifest)仍存 + * game_version + game_runtime_package(GamePackage 产物 schema 不变)。 + * + *

引用边界:本类只持引用 + 自身源态,不重建版本状态机—— + * {@code gameId} 引用 project.game_project.id;{@code versionId} 引用 project.game_version.id(构建成功建包后回填, + * 版本权威归 project);{@code baseVersionId} 记 modify 血缘(create 时 NULL)。 + * + *

源态状态机:0 草稿 → 1 已构建(建包回填 version_id)/ 2 孤儿(构建失败,不建包)/ 3 已发布; + * 合法性流转由 {@code SourceProjectService} 校验,DO 层不承载(对齐 V18 头注「状态机用 tinyint,非法流转由 Service 校验」)。 + * + *

可寻址/幂等:{@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; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/GameSourceProjectMapper.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/GameSourceProjectMapper.java new file mode 100644 index 00000000..47dc70ee --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/GameSourceProjectMapper.java @@ -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) + * + *

显式列查询(不裸 select*);三类查询各命中 V18 索引: + *

+ * + * @author 造梦AI(U2/B8 源项目落库事务) + */ +@Mapper +public interface GameSourceProjectMapper extends BaseMapperX { + + /** + * 按游戏查源项目工件历史(按 id 倒序);命中 idx_game(game_id, id) + * + * @param gameId 游戏 ID + * @return 源工件列表(按 id 倒序,含 draft/built/orphan/published 各态) + */ + default List selectListByGameId(Long gameId) { + return selectList(new LambdaQueryWrapperX() + .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 倒序首条)。 + * + *

幂等去重用:落源前先查,命中则复用既有行(不重复 insert,§5.2「同 source_hash 重复落库 → 幂等去重」)。 + * + *

U2/B8 P0-2(跨游戏源行复用防护,命门):去重必须按 game_id 隔离——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() + .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。 + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectService.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectService.java new file mode 100644 index 00000000..92751918 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectService.java @@ -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 源落库事务;游戏 = 长生命周期源项目的源侧存储) + * + *

对外语义(被 {@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 草稿)。 + * + *

幂等:对 {@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(已构建)。 + * + *

流转守卫:仅从 0 草稿 / 1 已构建(同 versionId 幂等)流转;其它态不覆写(防误覆孤儿/已发布)。 + * + *

U2/B8 P0-2(跨游戏回填防护):必须校验源行 {@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 步骤④):构建失败/补偿时,按源行 ID 精确定位把草稿源行置 status=2(孤儿)。 + * + *

U2/B8 P1-2(标错行防护,命门):外层落源已持 {@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); + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImpl.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImpl.java new file mode 100644 index 00000000..a2f26f4d --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImpl.java @@ -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 产物面解耦) + * + *

事务边界纪律(§5.2 关键,化解孤儿):本类三方法各开独立短事务({@code @Transactional}), + * 由 status 机器态串联(0 草稿 → 1 已构建 / 2 孤儿),不与建包并入同一大事务(构建数十秒级, + * 长事务=连接池杀手,对齐 {@code SaaGraphDispatcher.java:55-57} 既有纪律)。落源独立 commit 后,即便后续建包 + * 失败回滚,源行仍在库(可标孤儿),实现「源已落、包未建」中间态的显式可辨建模(V18 status=2 孤儿)。 + * + *

幂等去重(source_hash):落源前对 sourceJson 规范化(键字母序排序)后算 sha256 得 source_hash; + * 同 hash 命中既有行 → 复用返回(不重复 insert)。规范化保证「逻辑等价的源 JSON(仅键序差异)算同一 hash」, + * 契约 source-project.schema.json 的 sourceHash 即此口径(源 JSON 规范化后 sha256)。malformed JSON(便宜模型脏输出) + * → 退化为原文 trim 后 sha256(仍确定性,不抛)。 + * + *

非阻断边界(命门):源落库是回调链旁路的 additive 存储面——由调用方(aigc 回调链 best-effort)保证 + * 「源落库失败不阻断现有发布链」。本 Service 自身只对「入参非法」(gameId 缺 / sourceJson 空)前置拒(落源无意义), + * status 流转方法对「源行不存在/非法流转」返 false 不抛(由 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)后按键字母序递归重序列化 → 逻辑等价输入(仅键序差异)得同一规范文本、 + * 同一 sha256。命门:必须反序列化为 {@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 幂等去重。 + * + *

独立短事务:本方法 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。 + * + *

流转守卫:仅从 0 草稿 / 1 已构建(同 versionId 幂等)流转;其它态(孤儿/已发布)不覆写、返 false。 + * 源行不存在 → 返 false(不抛,best-effort 调用方决断)。 + * + *

P0-2 跨游戏回填防护:先校源行 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 步骤④):按源行 ID 精确定位把草稿源行置 status=2。 + * + *

P1-2 标错行防护:外层已持 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)。 + * + *

规范化保证「逻辑等价源(仅键序/空白差异)算同一 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); + } + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java new file mode 100644 index 00000000..c8dfefe5 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java @@ -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 源落库事务) + * + *

覆盖 U2 Test scenarios 中「落库服务」侧(含 codex 评审 P0/P1/P2 修复用例): + *

    + *
  • happy:落源 status=0 草稿(reused=false,insert 被调);
  • + *
  • edge:同 (game_id, source_hash) 重复落库 → 幂等去重(命中既有行复用、不重复 insert,reused=true);
  • + *
  • P0-2:跨游戏同 hash 不复用源行(去重按 game_id 隔离——游戏 B 同 hash 但既有行属游戏 A → 不命中 → 新落行);
  • + *
  • P1-2:落源撞唯一键(并发)→ 捕获 DuplicateKeyException 重查复用既有行(幂等兜底);
  • + *
  • happy:markBuilt 0→1 回填 version_id(仅从草稿流转);
  • + *
  • edge:markBuilt 对孤儿/已发布行非法流转 → 不覆写返 false;markBuilt 幂等(已 1 且 versionId 一致);
  • + *
  • P0-2:markBuilt 源行 gameId 与任务 gameId 不一致 → 拒回填返 false(防跨游戏污染);
  • + *
  • P1-2:markOrphanById 按源行 ID 精确标 0→2;edge:非草稿态/源行不存在 → 幂等无副作用返 false;
  • + *
  • edge:source_hash 规范化稳定(仅键序差异的两份源 JSON 算同一 hash → 幂等命中);
  • + *
  • error:入参非法(gameId 缺 / sourceJson 空)→ 1-112-004-000 前置拒。
  • + *
+ * + *

注意: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 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 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 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 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; + } + +} diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V20.0.0__game_source_project_add_uk.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V20.0.0__game_source_project_add_uk.sql new file mode 100644 index 00000000..27def5c2 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V20.0.0__game_source_project_add_uk.sql @@ -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 维度避让逻辑删/多租户冲突)';