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 @@
形态: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 非阻断: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 非阻断: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 为什么是 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 机器态串联:
+ * 非阻断纪律:源落库是回调链旁路的 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 按落源返回的源行 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 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 用途: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 层不承载):
+ * {@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 继承 {@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 索引:
+ * 幂等去重用:落源前先查,命中则复用既有行(不重复 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 对外语义(被 {@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 修复用例):
+ * 注意: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
+ * ① {@link #land} → 源落库 status=0(草稿),source_hash 幂等去重(同 hash 命中复用不重复 insert)
+ * ② 触发确定性构建(建包走 handleCallback 唯一写入路径)
+ * ③ 构建成功 → {@link #markBuilt} 回填 version_id + status=1(已构建)
+ * ④ 构建失败 → {@link #markOrphan} status=2(孤儿),不建 package、不动 currentVersion
+ *
+ *
+ *
+ *
+ *
+ * @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 回调链落源/回填/标孤儿)
+ *
+ *
+ *
+ *
+ * @author 造梦AI(U2/B8 源项目落库事务)
+ */
+@Mapper
+public interface GameSourceProjectMapper extends BaseMapperX
+ *
+ *
+ *