diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java
new file mode 100644
index 00000000..8d28e5cd
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java
@@ -0,0 +1,322 @@
+package com.wanxiang.huijing.game.module.aigc.saa;
+
+import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallbackReqVO;
+import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService;
+import com.wanxiang.huijing.game.module.aigc.service.executor.AigcExecutorProperties;
+import io.micrometer.observation.ObservationRegistry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * SaaFullGraphE2eTest —— U7 集成顶石:首次证明 SAA 真·全图生成 e2e 跑通并测成功率。
+ *
+ *
验什么(与既有 SAA 测试的区别):
+ *
+ * - {@link SaaNewApiGenerateSmokeTest} 只验「SAA→new-api→出工厂源码」一条窄链(无 build/play harness);
+ * - {@link SaaHarnessDriveTest} 只验「金样 factory 驱动 build+九门 harness」(不接 LLM、不接全图、不接 job 契约);
+ * - 本测=经 {@link SaaGraphDispatcher#dispatch} 真入口跑 {@link SaaStudioGraph} 16 节点全图
+ * (render→classify→design→generate→validate→scaffold→asset→build→play→player→nreview→…→emit/giveup)
+ * + 真 LLM(五角色经 new-api 网关)+ 真九门 harness(build/play 子进程,串行真玩门 4320/9222)
+ * + 真出口({@link DifyCallbackService#handleCallback} 据图终态组 reqVO)。这一整条 e2e 从未跑过,本测首证。
+ *
+ *
+ * 关键:dispatch 是「异步 + 单线程执行器 + Semaphore(1) 真玩门」=多 job 严格串行后台执行。
+ * 故本测先一次性投递全部 N 条(每次 dispatch 提交后台即返回 true),再用一把共享 {@link CountDownLatch}(N)
+ * 等全部回调;stub 回调按 {@code reqVO.getTraceId()} 把每条结果精确归位到并发 map(不会跨条错配,
+ * 这是修掉「每条 arm 新 latch + 240s 超时」旧设计致命缺陷的关键——旧设计下后台串行跑、回调命中的是当时
+ * 恰好 arm 的那条 latch,归因全错)。落库由 stub 整体旁路(不触三表写链)。
+ *
+ *
纪律(纯测试·零 prod 改动):
+ *
+ * - 门控 {@code -Dsaa.e2e=1}(无机/无 key 时不跑,避免误判 fail);
+ * - {@code saaCheckpointEnabled=false} + DataSource 传 null → 零 DB 依赖;
+ * - N 默认 5,可 {@code -Dsaa.e2e.n=K} 覆盖(串行单条含修复轮约 5~15 分钟,N=5 需约 30~75 分钟,按 mvn timeout 量裁);
+ * - 等待总超时 = {@code perBriefBudget(默认 900s) × N},可 {@code -Dsaa.e2e.perBriefSec} 调;到点仍未集齐则
+ * best-effort 收尾(已回调的照常计,未回调的计「(未回调)」),不一条卡死全挂;
+ * - 成功率作证据打印,不硬断 80%——存在性断言放宽到「至少 1 条真全图 e2e 跑通」(首证存在性,成功率留人判)。
+ *
+ *
+ * 运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空):
+ *
+ * NEWAPI_KEY=sk-... timeout 6000 mvn -pl ...aigc-server test \
+ * -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 \
+ * -DGAME_RUNTIME_DIR=/root/game-staging/repo/game-runtime \
+ * -Dsurefire.failIfNoSpecifiedTests=false
+ *
+ *
+ * @author 造梦AI(U7 集成顶石)
+ */
+class SaaFullGraphE2eTest {
+
+ /**
+ * game-runtime 根(harness 运行时所在:node_modules/esbuild + games/_wg1-gen/_shared/serve-and-play.sh + play.cdp.cjs)。
+ * 优先取系统属性 GAME_RUNTIME_DIR(与 {@link SaaHarnessDriveTest} 同口径),缺则取 staging 默认。
+ */
+ private static final String GAME_RUNTIME_DIR = System.getProperty("GAME_RUNTIME_DIR",
+ System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/game-staging/repo/game-runtime"));
+
+ /** new-api 网关 host 根(SAA 内部 stripV1Suffix 自剥 /v1;内网地址按项目规则可入码)。 */
+ private static final String LLM_BASE = "http://100.64.0.8:3000";
+
+ /** 单条 brief 的墙钟预算秒数(用于推总等待超时;含 LLM 多步 + 修复轮 + 真玩九门,实测单条约 5~15 分钟)。 */
+ private static final long PER_BRIEF_BUDGET_SECONDS =
+ Long.getLong("saa.e2e.perBriefSec", 900L);
+
+ /**
+ * N=5 条多样一句话 brief(覆盖不同玩法品类,避免单品类偶然过/不过)。可 {@code -Dsaa.e2e.n=K} 取前 K 条。
+ * 全部一次性投递,后台真玩门 Semaphore(1) 串行逐条跑。
+ */
+ private static final List ALL_BRIEFS = List.of(
+ "打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。",
+ "贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。",
+ "接金币:左右移动篮子接住从天上掉落的金币,漏接太多就失败。",
+ "躲障碍:角色不断前进,点击跳跃躲开迎面而来的障碍物,撞到就结束。",
+ "点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。"
+ );
+
+ /** 单条 brief 的 e2e 结果证据(回调按 traceId 精确归位)。 */
+ private static final class BriefResult {
+ final int idx;
+ final String brief;
+ final String traceId;
+ volatile String status = "(未回调)"; // succeeded / failed / (未回调=总超时内未集齐)
+ volatile int engineBundleLen = 0; // 成功路 bundle.iife.js 长度(含 __GameBundle)
+ volatile String failureReason = ""; // 失败路归桶(llm_error 等)
+ volatile boolean dispatched = false; // dispatch 投递握手是否 true
+ volatile long callbackAtMs = 0; // 回调命中时刻(相对 run 起点;推单条墙钟用)
+ volatile String failDetail = ""; // 失败时从 verdict.json/giveup-dump.json 摘的真因
+
+ BriefResult(int idx, String brief, String traceId) {
+ this.idx = idx;
+ this.brief = brief;
+ this.traceId = traceId;
+ }
+
+ boolean succeeded() {
+ return "succeeded".equals(status);
+ }
+ }
+
+ /**
+ * stub DifyCallbackService:按 {@code reqVO.getTraceId()} 把结果精确归位到 byTrace map + 倒数共享 latch,
+ * 不写 DB(替掉真回调的三表写链)。后台串行回调,无并发竞争;用 ConcurrentHashMap 保险。
+ */
+ private static final class CapturingStubCallback implements DifyCallbackService {
+ final CountDownLatch latch;
+ final Map byTrace;
+ final long runStartMs;
+
+ CapturingStubCallback(int n, Map byTrace, long runStartMs) {
+ this.latch = new CountDownLatch(n);
+ this.byTrace = byTrace;
+ this.runStartMs = runStartMs;
+ }
+
+ @Override
+ public Boolean handleCallback(DifyCallbackReqVO reqVO) {
+ // 只捕获 + 归位 + 倒数:不触任何 DB/事务(与真 DifyCallbackServiceImpl 的三表写链解耦)。
+ String traceId = reqVO.getTraceId();
+ BriefResult r = (traceId == null) ? null : byTrace.get(traceId);
+ if (r != null) {
+ r.status = reqVO.getStatus();
+ r.engineBundleLen = reqVO.getEngineBundle() == null ? 0 : reqVO.getEngineBundle().length();
+ r.failureReason = reqVO.getFailureReason() == null ? "" : reqVO.getFailureReason();
+ r.callbackAtMs = System.currentTimeMillis() - runStartMs;
+ System.out.println("[callback] #" + r.idx + " traceId=" + traceId
+ + " status=" + r.status + " engineBundleLen=" + r.engineBundleLen
+ + " reason=" + r.failureReason + " @+" + (r.callbackAtMs / 1000) + "s");
+ } else {
+ // 不应发生(traceId 必在 map 内):留痕,仍倒数避免 latch 永挂。
+ System.out.println("[callback] 未知 traceId=" + traceId + "(不在 map 内,忽略归位仍倒数)");
+ }
+ latch.countDown();
+ return Boolean.TRUE;
+ }
+ }
+
+ @Test
+ @EnabledIfSystemProperty(named = "saa.e2e", matches = "1",
+ disabledReason = "需 mini-desktop(node+esbuild+Chrome+端口 4320/9222 空)+ NEWAPI_KEY;无机/无 key 时跳过避免误判 fail;开关 -Dsaa.e2e=1")
+ void saa_full_graph_e2e_success_rate() throws Exception {
+ // ── 0) 前置就位:NEWAPI_KEY(密钥经环境变量,绝不入库)+ game-runtime harness 依赖 ──
+ String apiKey = System.getenv("NEWAPI_KEY");
+ assertNotNull(apiKey, "NEWAPI_KEY 未设(密钥须经环境变量注入,绝不入库)");
+ assertTrue(!apiKey.isBlank(), "NEWAPI_KEY 为空");
+ Path runtimeRoot = Paths.get(GAME_RUNTIME_DIR);
+ assertTrue(Files.isDirectory(runtimeRoot), "GAME_RUNTIME_DIR 不存在:" + runtimeRoot);
+ assertTrue(Files.exists(runtimeRoot.resolve("games/_wg1-gen/_shared/serve-and-play.sh")),
+ "缺 serve-and-play.sh(九门 harness 子进程地板):" + runtimeRoot);
+ assertTrue(Files.exists(runtimeRoot.resolve("node_modules/esbuild")),
+ "缺 node_modules/esbuild(build 节点依赖),请在 game-runtime 跑 npm i -D esbuild");
+
+ // N:默认 5,可 -Dsaa.e2e.n=K 取前 K 条(串行耗时大,调小便于在短 mvn timeout 内跑通若干条)。
+ int n = Integer.getInteger("saa.e2e.n", ALL_BRIEFS.size());
+ n = Math.max(1, Math.min(n, ALL_BRIEFS.size()));
+ List briefs = ALL_BRIEFS.subList(0, n);
+
+ // ── 1) 构造真 AigcExecutorProperties(@Data 即 setter 全有)──
+ // checkpoint 关 + DataSource 传 null → 零 DB 依赖;dispatcher=saa 走进程内全图派发。
+ AigcExecutorProperties props = new AigcExecutorProperties();
+ props.setLlmBase(LLM_BASE); // SAA 内部 stripV1Suffix 自剥 /v1
+ props.setApiKey(apiKey); // 经环境变量注入
+ props.setDispatcher("saa"); // 走进程内 SAA 全图派发器(非 http worker)
+ props.setSaaGameRuntimeDir(GAME_RUNTIME_DIR);
+ props.setSaaMaxRepairs(5); // stage1 修复轮上限(对齐 Python studio.py 默认 5)
+ props.setSaaMaxPlayerRounds(1); // player 顾问轮上限
+ props.setSaaStage2ExtraRepairs(3); // 救场阶梯 stage2 额外修复轮(5+3 阶梯)
+ props.setSaaCheckpointEnabled(false); // 关 checkpoint → 不依赖 DataSource,零 DB
+
+ // ── 2) 结果容器 + stub 回调(共享 latch(N) + byTrace 精确归位)+ 派发器(5 参,sourceProjectApi=null=create 路)──
+ long runStart = System.currentTimeMillis();
+ long runStamp = runStart;
+ List results = new ArrayList<>();
+ Map byTrace = new ConcurrentHashMap<>();
+ for (int i = 0; i < briefs.size(); i++) {
+ // traceId 唯一串:图内据此派生落盘目录名 saa-,回调据此精确归位。
+ String traceId = "saa-e2e-" + runStamp + "-" + (i + 1);
+ BriefResult r = new BriefResult(i + 1, briefs.get(i), traceId);
+ results.add(r);
+ byTrace.put(traceId, r);
+ }
+ CapturingStubCallback stub = new CapturingStubCallback(briefs.size(), byTrace, runStart);
+ // 直接 new(非 Spring 装配),@PostConstruct warmUpGraphEagerly 不会被调用——dispatch 内部 ensureGraph
+ // 懒构造图,首条 dispatch 即触发构造,行为等价(仅少了启动期预热)。
+ SaaGraphDispatcher dispatcher = new SaaGraphDispatcher(
+ props, stub, /*dataSource*/ null, ObservationRegistry.NOOP, /*sourceProjectApi*/ null);
+
+ // ── 3) 一次性投递全部 N 条(每次 dispatch 提交后台单线程即返回;后台串行逐条跑全图)──
+ System.out.println();
+ System.out.println("======== SAA 真·全图 e2e 投递 " + briefs.size() + " 条(后台串行跑)========");
+ for (BriefResult r : results) {
+ Map job = new HashMap<>();
+ job.put("brief", r.brief);
+ job.put("traceId", r.traceId);
+ job.put("templateId", "generic"); // create job 契约:仅 brief/traceId/templateId(modify 字段全缺=create)
+ try {
+ r.dispatched = dispatcher.dispatch(job);
+ } catch (Exception e) {
+ r.dispatched = false;
+ r.status = "(dispatch异常:" + e.getClass().getSimpleName() + ")";
+ }
+ System.out.println("[dispatch] #" + r.idx + " traceId=" + r.traceId
+ + " -> " + (r.dispatched ? "投递成功" : "投递失败") + " brief=" + r.brief);
+ if (!r.dispatched && "(未回调)".equals(r.status)) {
+ // 投递前置失败(game-runtime 缺/图构造失败/入参非法):本条不会有回调,预先倒数补位避免 latch 永挂。
+ r.status = "(投递失败)";
+ stub.latch.countDown();
+ }
+ }
+
+ // ── 4) 等全部回调(共享 latch(N));总超时 = perBriefBudget × N(串行执行)。到点 best-effort 收尾 ──
+ long totalTimeoutSec = PER_BRIEF_BUDGET_SECONDS * briefs.size();
+ System.out.println("======== 等待全部回调(总超时 " + totalTimeoutSec + "s = "
+ + PER_BRIEF_BUDGET_SECONDS + "s/条 × " + briefs.size() + " 条,后台串行)========");
+ boolean allDone = stub.latch.await(totalTimeoutSec, TimeUnit.SECONDS);
+ long totalWallMs = System.currentTimeMillis() - runStart;
+ if (!allDone) {
+ System.out.println("[warn] 总超时 " + totalTimeoutSec + "s 到点仍有条目未回调(best-effort 收尾,未回调条计「(未回调)」)");
+ }
+
+ // ── 5) 失败/未回调路补读盘诊断(verdict.json/giveup-dump.json)──
+ for (BriefResult r : results) {
+ if (!r.succeeded() && r.dispatched) {
+ r.failDetail = readFailDetail(runtimeRoot, r.traceId);
+ }
+ }
+
+ // ── 6) 汇总:成功率 X/N + 逐条证据 ──
+ long succeeded = results.stream().filter(BriefResult::succeeded).count();
+ System.out.println();
+ System.out.println("################ SAA 真·全图 e2e 汇总 ################");
+ System.out.println("game-runtime = " + GAME_RUNTIME_DIR);
+ System.out.println("new-api base = " + LLM_BASE + "(SAA 内部剥 /v1)");
+ System.out.println("总墙钟 = " + (totalWallMs / 1000) + "s(后台串行跑 " + briefs.size() + " 条)");
+ System.out.println("成功率 = " + succeeded + "/" + briefs.size()
+ + " (" + String.format("%.1f", 100.0 * succeeded / briefs.size()) + "%)");
+ System.out.println("------------------------------------------------------");
+ for (BriefResult r : results) {
+ System.out.println(String.format(
+ "#%d status=%-26s bundleLen=%-7d callback@+%-5s reason=%s",
+ r.idx, r.status, r.engineBundleLen,
+ (r.callbackAtMs > 0 ? (r.callbackAtMs / 1000) + "s" : "-"),
+ r.succeeded() ? "-" : (r.failureReason.isEmpty() ? "(无)" : r.failureReason)));
+ System.out.println(" brief: " + r.brief);
+ if (!r.succeeded() && !r.failDetail.isBlank()) {
+ System.out.println(" fail : " + r.failDetail);
+ }
+ }
+ System.out.println("######################################################");
+
+ // ── 7) 存在性断言(首证):至少 1 条真全图 e2e 跑通;成功率作证据,不硬断 80%(留人判)──
+ assertTrue(succeeded >= 1,
+ "至少 1 条 SAA 真全图 e2e 应跑通(实得 " + succeeded + "/" + briefs.size()
+ + ");若全 0:检查 new-api 可达/九门 harness/端口 4320-9222 占用/图终态——见上方逐条 failDetail");
+ }
+
+ /**
+ * 失败诊断:从图落盘目录读 verdict.json(哪门未过)/ giveup-dump.json(救场耗尽真因)摘要。
+ * 落盘目录名 = {@code saa-}(与 {@link SaaGraphDispatcher} buildInputs 一致,sanitize 把非 [A-Za-z0-9_-] 转 -)。
+ * best-effort:读不到/解析失败返空串(不抛、不连坐主流程)。
+ *
+ * @param runtimeRoot game-runtime 根
+ * @param traceId 本条 brief 的 traceId
+ * @return 失败真因摘要(verdict 未过门概览 + giveup 末次失败),无则空串
+ */
+ private static String readFailDetail(Path runtimeRoot, String traceId) {
+ try {
+ // dispatcher 落盘目录名 = "saa-" + sanitize(traceId);sanitize 把非法字符转 -(traceId 已合法,恒等)。
+ String gameDir = "saa-" + traceId.replaceAll("[^A-Za-z0-9_-]", "-");
+ Path evidenceDir = runtimeRoot.resolve("games/_wg1-gen").resolve(gameDir).resolve("evidence");
+ StringBuilder sb = new StringBuilder();
+ var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+
+ Path verdict = evidenceDir.resolve("verdict.json");
+ if (Files.exists(verdict)) {
+ var v = mapper.readTree(verdict.toFile());
+ sb.append("verdict.pass=").append(v.path("pass").asBoolean(false));
+ var guards = v.path("guards");
+ if (guards.isObject()) {
+ sb.append(" 未过门:[");
+ var it = guards.fieldNames();
+ boolean first = true;
+ while (it.hasNext()) {
+ String g = it.next();
+ if (!guards.path(g).path("pass").asBoolean(false)) { // 只列未过的门(聚焦真因)
+ if (!first) sb.append(",");
+ sb.append(g);
+ first = false;
+ }
+ }
+ sb.append("]");
+ }
+ } else {
+ sb.append("(无 verdict.json,可能 build 前即失败或图未到 play)");
+ }
+
+ Path giveup = evidenceDir.resolve("giveup-dump.json");
+ if (Files.exists(giveup)) {
+ var g = mapper.readTree(giveup.toFile());
+ sb.append(" giveup{modelTier=").append(g.path("modelTier").asText("?"))
+ .append(", failCount=").append(g.path("failCount").asText("?"))
+ .append(", lastStageFail=").append(g.path("lastStageFail").asText("?")).append("}");
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return "(读 failDetail 异常:" + e.getMessage() + ")";
+ }
+ }
+}