diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java index 33a27b14..08e4729b 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java @@ -11,6 +11,7 @@ import com.wanxiang.huijing.game.module.aigc.service.executor.PromptResourceLoad import com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher; import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher; import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerDispatchClient; +import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi; import io.micrometer.observation.ObservationRegistry; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -105,13 +106,17 @@ public class AigcExecutorConfiguration { public SaaGraphDispatcher aigcSaaGraphDispatcher(AigcExecutorProperties properties, DifyCallbackService difyCallbackService, ObjectProvider dataSourceProvider, - ObjectProvider observationRegistryProvider) { + ObjectProvider observationRegistryProvider, + ObjectProvider sourceProjectApiProvider) { // checkpoint(step5):软取 yudao 共享 DataSource(dynamic-datasource 路由源,primary=master)——saver 懒建于 // 首个 dispatch(dispatcher=http 时本 Bean 在席但永不跑图/永不触 DB);DataSource 缺席则降级内存态。 // observation:软取 Micrometer ObservationRegistry(actuator/micrometer 装配时在席;缺则用 NOOP=不埋点)。 + // U6/B7:软取 SourceProjectApi(studio @Primary 就地解析;modify/extend 路据 baseVersionId 反查 base 源注入 K_SOURCE_PROJECT)。 + // 缺席(单模块装配/studio 未在 classpath)→ modify 取源旁路(K_SOURCE_PROJECT 空串,与 create 同),不阻断装配。 DataSource dataSource = dataSourceProvider.getIfAvailable(); ObservationRegistry observationRegistry = observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP); - return new SaaGraphDispatcher(properties, difyCallbackService, dataSource, observationRegistry); + SourceProjectApi sourceProjectApi = sourceProjectApiProvider.getIfAvailable(); + return new SaaGraphDispatcher(properties, difyCallbackService, dataSource, observationRegistry, sourceProjectApi); } /** 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 a0a315b4..aa32bf8f 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 @@ -12,6 +12,9 @@ import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallba import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService; import com.wanxiang.huijing.game.module.aigc.service.executor.AigcExecutorProperties; import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher; +import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; import io.micrometer.observation.ObservationRegistry; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; @@ -84,6 +87,12 @@ public class SaaGraphDispatcher implements GenerationDispatcher { * 可为 null/NOOP(无 actuator/micrometer 装配态)→ 不埋点(图照常跑)。 */ private final ObservationRegistry observationRegistry; + /** + * 源项目工件反查 seam(U6/B7 §5.6:modify/extend 取 base 源):aigc→studio-api,按 baseVersionId 反查 base 源 source_json + * 注入 SAA state K_SOURCE_PROJECT(与 {@code DifyCallbackServiceImpl} 用同一 {@link SourceProjectApi} seam)。 + *

可为 null(4 参旧构造/纯抽取单测态)→ modify 取 base 源整段旁路(K_SOURCE_PROJECT 缺省空串,与 U1 现行一致)。 + */ + private final SourceProjectApi sourceProjectApi; /** * 后台单线程池(跑图阻塞活,禁占 tick/Tomcat 线程)。daemon 线程,命名便于排障。 @@ -117,7 +126,8 @@ public class SaaGraphDispatcher implements GenerationDispatcher { private volatile CompiledGraph compiledGraph; /** - * 构造(经 AigcExecutorConfiguration @Bean 调用)。 + * 4 参构造(保留:纯 trace 抽取单测 / 不接 modify 取源的装配态用)——{@code sourceProjectApi=null}, + * modify 取 base 源整段旁路(K_SOURCE_PROJECT 缺省空串,现行 create 路字节零变)。 * * @param properties 执行器配置 * @param difyCallbackService 回调唯一写入路径 @@ -126,10 +136,26 @@ public class SaaGraphDispatcher implements GenerationDispatcher { */ public SaaGraphDispatcher(AigcExecutorProperties properties, DifyCallbackService difyCallbackService, DataSource dataSource, ObservationRegistry observationRegistry) { + this(properties, difyCallbackService, dataSource, observationRegistry, null); + } + + /** + * 5 参构造(U6/B7:接 {@link SourceProjectApi} 反查 base 源;经 AigcExecutorConfiguration @Bean 调用)。 + * + * @param properties 执行器配置 + * @param difyCallbackService 回调唯一写入路径 + * @param dataSource yudao 共享 DataSource(checkpoint 用;可 null=降级内存态) + * @param observationRegistry 观测注册表(可 null/NOOP=不埋点) + * @param sourceProjectApi 源项目反查 seam(U6 modify 取 base 源;可 null=取源旁路) + */ + public SaaGraphDispatcher(AigcExecutorProperties properties, DifyCallbackService difyCallbackService, + DataSource dataSource, ObservationRegistry observationRegistry, + SourceProjectApi sourceProjectApi) { this.properties = properties; this.difyCallbackService = difyCallbackService; this.dataSource = dataSource; this.observationRegistry = observationRegistry; + this.sourceProjectApi = sourceProjectApi; } /** @@ -380,14 +406,82 @@ public class SaaGraphDispatcher implements GenerationDispatcher { throw new IllegalArgumentException("不支持的 modifyMode=\"" + modifyMode + "\"(仅支持 " + SUPPORTED_MODIFY_MODES + ",§5.6);禁静默退回 create"); } - // 透传(含空串=create 路;renderRouter 据 modifyMode 是否空分流)。modifyPatch/baseVersionId/sourceProject 同透传。 + // 透传(含空串=create 路;renderRouter 据 modifyMode 是否空分流)。modifyPatch/baseVersionId 同透传。 inputs.put(SaaStudioNodes.K_MODIFY_MODE, modifyMode); inputs.put(SaaStudioNodes.K_MODIFY_PATCH, strOf(job.get("modifyPatch"), "")); - inputs.put(SaaStudioNodes.K_BASE_VERSION_ID, strOf(job.get("baseVersionId"), "")); - inputs.put(SaaStudioNodes.K_SOURCE_PROJECT, strOf(job.get("sourceProject"), "")); + String baseVersionId = strOf(job.get("baseVersionId"), ""); + inputs.put(SaaStudioNodes.K_BASE_VERSION_ID, baseVersionId); + // ── U6/B7(§5.6 modify 取 base 源):modify/extend 路据 baseVersionId 反查 base 源 source_json 注入 K_SOURCE_PROJECT, + // 使 modify 节点在 base 源上改/重生成。job 已带 sourceProject(罕见,executor 当前不带)则优先用之; + // 否则经 SourceProjectApi 反查(aigc→studio-api seam)。create 路(modifyMode 空)不取源,K_SOURCE_PROJECT 空串=现行字节零变。 + inputs.put(SaaStudioNodes.K_SOURCE_PROJECT, resolveBaseSourceProject(job, modifyMode, baseVersionId, traceId)); return inputs; } + /** + * U6/B7:解析注入 K_SOURCE_PROJECT 的 base 源(§5.6 modify 取 base 源)。 + *

    + *
  • job 已显式带 {@code sourceProject}(非空)→ 直接用(最高优先,便于测试/未来 job 自带源);
  • + *
  • create 路(modifyMode 空)→ 不取源,返回空串(现行 create 行为字节零变,不触 SourceProjectApi);
  • + *
  • modify/extend 路 ∧ baseVersionId 非空 ∧ {@link #sourceProjectApi} 在席 → 反查 base 源 source_json 注入;
  • + *
  • 反查未命中({@code found=false})/ seam 缺席 / 反查异常 → 返回空串(base 无源),由 modify 节点的 U6 命门守卫 + * (regenerate-module 路 base 缺则 fail-loud,见 {@code SaaStudioNodes.guardRegenerateTarget})决断,不在此处抛 + * (派发侧旁路读异常不裸退图;deterministic 路本就 guard-pending-E2 不消费 base 源)。
  • + *
+ * + * @param job §6.1 job(可能带 sourceProject) + * @param modifyMode 已校验的 modify 模式(空=create 路) + * @param baseVersionId base 版本 ID 串(create 路空) + * @param traceId 贯穿键(日志定位) + * @return 注入 K_SOURCE_PROJECT 的 source_json;无则空串 + */ + private String resolveBaseSourceProject(Map job, String modifyMode, String baseVersionId, String traceId) { + // ① job 自带 sourceProject 优先(executor 当前不带;保留口子便于测试/未来 job 直供源)。 + String fromJob = strOf(job.get("sourceProject"), ""); + if (!fromJob.isEmpty()) { + return fromJob; + } + // ② create 路(modifyMode 空)不取源——不触 SourceProjectApi,现行 create 字节零变。 + if (modifyMode.isEmpty()) { + return ""; + } + // ③ modify/extend 路:据 baseVersionId 反查 base 源(seam 缺席/baseVersionId 空/反查未命中 → 空串,由 modify 节点守卫决断)。 + if (sourceProjectApi == null) { + log.warn("[saa-dispatch] modify 取 base 源跳过:SourceProjectApi seam 缺席(4 参构造态)traceId={}, mode={}", traceId, modifyMode); + return ""; + } + if (baseVersionId.isEmpty()) { + log.warn("[saa-dispatch] modify 取 base 源跳过:baseVersionId 空 traceId={}, mode={}", traceId, modifyMode); + return ""; + } + Long versionId; + try { + versionId = Long.valueOf(baseVersionId.trim()); + } catch (NumberFormatException e) { + log.warn("[saa-dispatch] modify 取 base 源跳过:baseVersionId 非数字={} traceId={}", baseVersionId, traceId); + return ""; + } + try { + CommonResult resp = sourceProjectApi.fetchByVersionId(versionId); + SourceProjectFetchRespDTO data = (resp == null) ? null : resp.getData(); + if (data == null || !Boolean.TRUE.equals(data.getFound()) + || data.getSourceJson() == null || data.getSourceJson().isBlank()) { + // base 无源(未落源/早期态):返空串。modify 节点 U6 命门(regenerate base 缺 fail-loud)据此明确报错,非静默全量重生成。 + log.warn("[saa-dispatch] modify 取 base 源未命中(base 无源行/源空)→ 注入空源 traceId={}, baseVersionId={}, mode={}", + traceId, versionId, modifyMode); + return ""; + } + log.info("[saa-dispatch] modify 取 base 源命中并注入 K_SOURCE_PROJECT traceId={}, baseVersionId={}, sourceId={}, len={}, mode={}", + traceId, versionId, data.getId(), data.getSourceJson().length(), modifyMode); + return data.getSourceJson(); + } catch (Exception e) { + // 反查异常(seam/Feign/DB 抖动):best-effort 旁路返空串,不裸退图(modify 节点守卫据空源决断)。 + log.warn("[saa-dispatch] modify 取 base 源反查异常 → 注入空源(不裸退图,由 modify 节点守卫决断)traceId={}, baseVersionId={}", + traceId, versionId, e); + return ""; + } + } + /** * result state → {@link DifyCallbackReqVO}(形态严格对齐 Python worker build_callback_payload,service.py:91-123)。 *
    diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java index e73793dd..1a095d27 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java @@ -259,6 +259,57 @@ final class SaaPrompts { return new String[]{system, user}; } + /** base 源注入上限(防超长 prompt 撑爆上下文/计费;便宜模型上下文窗口有限)。超出则截断并标注。 */ + private static final int SOURCE_PROJECT_MAX_CHARS = 12000; + + /** + * P0-2(regenerate-module 路真消费 base 源·informed-by-base 重生成):在 {@link #buildMessages} 基础上, + * 把 base 源项目 JSON + 目标 behavior 标识 + 改玩法意图 注入 user prompt,让 LLM 在既有结构上 + * 重生成那一个 behavior(其余 behavior/config/assets 摘要保留),而非凭题面凭空全量重出。 + * + *

    为何只在 regenerate 路调本方法:dispatcher 据 baseVersionId 反查 base 源注入 {@code K_SOURCE_PROJECT}, + * 但旧 {@link #buildMessages} 只取 enriched/feedback、不消费 base 源 → LLM 看不到原 behaviors → 仍可能全量重出, + * 「按 target 收窄」沦为伪契约(评审 P0-2)。本方法是 regenerate 路的专用消费点,把 base 源真喂进去。 + * create/repair 普通生成路不调本方法(仍走 {@link #buildMessages}),故普通路 prompt 字节零变。 + * + *

    诚实边界(B7/引擎线 follow-up):本期做到「base 源真喂 LLM + 目标 behavior 聚焦提示」(informed-by-base 重生成); + * 严格 per-behavior「只替换目标 behavior 子树、其余字节不动的 splice-back」依赖生成 agent 真填结构化 + * {@code gameDefinition/behaviors}(现源多为最小骨架,跨单元),本期不做,留 TODO(B7/engine-E2 splice-back)。 + * 即本方法不伪造「已实现精准 per-behavior 重生成」,只确保「LLM 真看到 base 源、被引导聚焦目标 behavior」。 + * + * @param briefText 题面(含设计稿,对齐 studio.py:216 的 enriched)。 + * @param retryFeedback 收窄回喂(modify 节点已写「只重 target、其余不动」+ intent;可空)。 + * @param gameRuntimeRoot game-runtime 根(读 few-shot 探针 factory)。 + * @param baseSourceJson base 源项目 JSON 串(dispatcher 反查注入 K_SOURCE_PROJECT;本方法仅在其非空时由调用方调用)。 + * @param targetBehavior 目标 behavior 标识(id/path;modify 已保证非空——见 P0-1 缺 target fail-loud)。 + * @param intent 改玩法意图(可空)。 + * @return [0]=system(与普通路同一 SYSTEM+few-shot,公平性铁律不改 prompt 语义),[1]=user(额外附 base 源 + 目标 behavior 聚焦段)。 + */ + static String[] buildRegenerateMessages(String briefText, String retryFeedback, Path gameRuntimeRoot, + String baseSourceJson, String targetBehavior, String intent) { + // system 与普通路完全一致(注入同一 few-shot)——公平性铁律:只在 user 末尾附 base 源上下文,不动 SYSTEM 语义。 + String[] basemsgs = buildMessages(briefText, retryFeedback, gameRuntimeRoot); + StringBuilder user = new StringBuilder(basemsgs[1]); + // base 源真喂:在既有源项目结构上重生成目标 behavior(其余部件保留),把 base 源 JSON 原样附上(超长截断标注)。 + String src = baseSourceJson == null ? "" : baseSourceJson; + boolean truncated = src.length() > SOURCE_PROJECT_MAX_CHARS; + if (truncated) { + src = src.substring(0, SOURCE_PROJECT_MAX_CHARS); + } + user.append("\n\n———\n【这是一次「在既有源项目上重生成单个玩法模块」的请求,不是从零新建】\n"); + user.append("下面是当前游戏的【源项目工件 JSON(base 源)】,请在其既有结构与其它部件不变的前提下," + + "只重新生成目标玩法模块(behavior):target=").append(targetBehavior).append("。\n"); + if (intent != null && !intent.isEmpty()) { + user.append("本次改动意图:").append(intent).append("。\n"); + } + user.append("要求:① 读懂 base 源中已有的 behaviors/config/assets 等部件,保持目标 behavior 之外的部件语义不变(其余摘要保留);" + + "② 只对 target=").append(targetBehavior).append(" 这一个 behavior 做重生成/调整;" + + "③ 最终仍按上面的【输出格式】只产出一个完整、可独立装载的 ```js 工厂模块(含整局可玩闭环,不要只给片段/diff)。\n"); + user.append("【base 源 JSON").append(truncated ? "(过长已截断,仅供结构参考)" : "").append("】:\n") + .append(src); + return new String[]{basemsgs[0], user.toString()}; + } + /** 读 few-shot 探针 factory(读文件防漂移,对齐 prompt.py:_few_shot)。 */ static String fewShot(Path gameRuntimeRoot) { try { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java index 5168fff4..0d837e2f 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java @@ -250,7 +250,9 @@ public final class SaaStudioGraph { .addNode("escalate", node_async(SaaStudioNodes.escalateNode())) .addNode("repair", node_async(SaaStudioNodes.repairNode())) .addNode("emit", node_async(SaaStudioNodes.emitNode(gameRuntimeRoot))) - .addNode("giveup", node_async(SaaStudioNodes.giveupNode())) + // U5/B4:giveup 节点持 gameRuntimeRoot 句柄,放弃前把 attempts/verdict/escalationEvents 整包真落盘到 + // games/_wg1-gen//evidence/giveup-dump.json(best-effort,失败仅 warn 不二次崩,见 SaaStudioNodes.giveupNode)。 + .addNode("giveup", node_async(SaaStudioNodes.giveupNode(gameRuntimeRoot))) // -- 直边 -- .addEdge(START, "render") // 图入口 .addEdge("classify", "design") // classify→design(§6.2) diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java index 21f61eb6..1dc127f4 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java @@ -450,7 +450,24 @@ final class SaaStudioNodes { .orElse(s.value(K_BRIEF, String.class).orElse("")); String feedback = s.value(K_FEEDBACK, String.class).orElse(null); - String[] msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot); + // ── P0-2(regenerate-module 路真消费 base 源):仅当本轮是 regenerate-module 路(modifyMode=regenerate-module + // ∧ role=fix〔modify 节点置〕)∧ base 源非空时,把 base 源 + 目标 behavior + intent 注入 prompt + // (informed-by-base 重生成);否则(create / 普通 repair 失败回边)走原 buildMessages,prompt 字节零变。 + // target/intent 从 K_MODIFY_PATCH 复用 resolveBehaviorTarget 再取(单一事实源,与 modify 节点同口径; + // modify 节点已经 P0-1 守卫保证 regenerate 路 target 非空)。 + String modifyMode = s.value(K_MODIFY_MODE, String.class).orElse(""); + String sourceProject = s.value(K_SOURCE_PROJECT, String.class).orElse(""); + boolean regeneratePath = isFix && "regenerate-module".equals(modifyMode) && !sourceProject.isEmpty(); + String[] msgs; + if (regeneratePath) { + JsonNode patch = SaaPrompts.looseParse(s.value(K_MODIFY_PATCH, String.class).orElse("")); + String target = resolveBehaviorTarget(patch); + String intent = patch != null ? patch.path("payload").path("intent").asText("") : ""; + msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, gameRuntimeRoot, + sourceProject, target, intent); + } else { + msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot); + } Map out = new HashMap<>(); // A-1:generate(含 repair 轮,role=fix 时)LLM 调用套 180s 超时 + ≤3 重试; // 超限不裸退图——把错因写 feedback + factorySrc 留空(validate 判空过短 → 路由 repair/giveup,对齐 run.py:103-105)。 @@ -757,23 +774,87 @@ final class SaaStudioNodes { * giveup 节点:超救场阶梯仍未过门 → status=failed(失败原因取最后 feedback)。 * *

    固定架构扩展(execution §6.2/§6.3):额外落 {@link #K_GIVEUP_DUMP_PATH}(放弃前完整 dump 的落盘路径,Opus 离线读)。 - * 本期边界(B4):本节点产出确定性 dump 路径(按 gameId 派生)初始逻辑——保证 trace 链路有此键、可观测; - * 「把所有 attempts 源码/verdict 整包真写盘到该路径」标 {@code TODO(B4)}(giveup 节点当前无 gameRuntimeRoot 句柄, - * 真落盘待 B4 补节点入参 + 写盘逻辑)。节点非空壳:真置终态 + 真产 dump 路径键。 + * + *

    U5/B4 真落盘(本期收口):本节点持 {@code gameRuntimeRoot} 句柄(仿 {@link #scaffoldNode}/{@link #emitNode} + * 工厂签名,由 {@code SaaStudioGraph.assemble} 传入),放弃前把 state.attempts(全源码轨迹)+ verdict + escalationEvents + + * 上下文(gameId/repairCount/failCount/modelTier/feedback)序列化整包真写盘到 + * {@code gameRuntimeRoot/games/_wg1-gen//evidence/giveup-dump.json}(供 Opus 离线分析定位为何救场耗尽), + * 并把 {@link #K_GIVEUP_DUMP_PATH} 回填为真实落盘路径(不再只产确定性派生路径占位)。 + * + *

    best-effort 纪律(命门):落盘失败仅 warn、绝不二次崩——dump 是放弃后的离线诊断旁路,写盘失败 + * (磁盘满/目录不可写/序列化异常)不得让 giveup 偏离正确终态。失败时仍置 {@code status=failed} + 正确 failureReason + * 回调({@link #K_GIVEUP_DUMP_PATH} 回填确定性派生路径占位,标记「dump 未落盘」),与 {@link #emitNode} 读盘失败的 + * 防御式降级同款(生成成败由 status/failureReason 决定,dump 在其后、独立)。 + * + *

    多级升档 TODO(B4) follow-up:本期维持单级救场(stage1→stage2,见 {@link #escalateNode});多级升档 + * (stage3+ 不同模型梯队)暂不做,留 follow-up(execution §6.3 单级阶梯已满足 ≥80% 目标,多级是增厚不在本期)。 + * + * @param gameRuntimeRoot game-runtime 根(dump 落盘到其 games/_wg1-gen/<gameId>/evidence/ 下;与 emit/play 同根)。 */ - static NodeAction giveupNode() { + static NodeAction giveupNode(Path gameRuntimeRoot) { return (OverAllState s) -> { Map out = new HashMap<>(); out.put(K_STATUS, "failed"); String fb = s.value(K_FEEDBACK, String.class).orElse("超过最大修复轮数仍未过门"); out.put(K_FAILURE, "max_repairs_exceeded: " + trunc(fb, 400)); - // dump 落盘路径(确定性派生;TODO(B4):真把 attempts/verdict 整包写到此路径喂 Opus 离线分析)。 String gameId = s.value(K_GAME_ID, String.class).orElse("unknown"); - out.put(K_GIVEUP_DUMP_PATH, "games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json"); + // 确定性派生的相对路径占位(与 emit/play 落盘目录同构);落盘成功后回填为真实绝对路径,失败则用此占位(标 dump 未落盘)。 + String relPath = "games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json"; + String dumpPath = relPath; + // B4 真落盘:把放弃前的完整轨迹整包写盘(best-effort,失败仅 warn 不二次崩,仍保正确 failed 终态)。 + try { + Path evidenceDir = gameRuntimeRoot.resolve("games").resolve("_wg1-gen").resolve(gameId).resolve("evidence"); + Files.createDirectories(evidenceDir); // 目录不存在则建(与 scaffold 落盘同款) + Path dumpFile = evidenceDir.resolve("giveup-dump.json"); + // dump 内容:放弃终态全貌——attempts(全源码轨迹,已 JSON 数组串)+ verdict + escalationEvents + 上下文计数。 + // attempts/verdict/escalationEvents 在 state 内已是 JSON 串 → 解回树嵌入(避免双重转义产难读的转义串),解析失败则原样落串(best-effort 不丢)。 + com.fasterxml.jackson.databind.node.ObjectNode dump = MAPPER.createObjectNode(); + dump.put("gameId", gameId); + dump.put("reason", "max_repairs_exceeded"); // 放弃原因桶(与 failureReason 同源) + dump.put("failureReason", strVal(s, K_FAILURE, out)); // 完整失败原因(含最后 feedback 摘要) + dump.put("repairCount", ((Number) s.value(K_REPAIR_COUNT, 0)).intValue()); + dump.put("failCount", ((Number) s.value(K_FAIL_COUNT, 0)).intValue()); + dump.put("modelTier", s.value(K_MODEL_TIER, String.class).orElse("")); + dump.put("lastFeedback", s.value(K_FEEDBACK, String.class).orElse("")); + dump.set("attempts", embedJsonOrText(s.value(K_ATTEMPTS, String.class).orElse("[]"))); // 全轮源码/usage/stage_fail 轨迹 + dump.set("verdict", embedJsonOrText(s.value(K_VERDICT, String.class).orElse(""))); // 末次九门 verdict + dump.set("escalationEvents", embedJsonOrText(s.value(K_ESCALATION_EVENTS, String.class).orElse("[]"))); // 救场升档事件序列 + dump.set("playerPanel", embedJsonOrText(s.value(K_PLAYER_PANEL, String.class).orElse(""))); // player 软门评判(若有) + dump.put("ts", java.time.Instant.now().toString()); + // 写盘:UTF-8、pretty(离线人读友好)。写成功 → dumpPath 回填真实绝对路径。 + Files.write(dumpFile, MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(dump)); + dumpPath = dumpFile.toAbsolutePath().toString(); + } catch (Exception e) { + // best-effort:落盘失败仅 warn,不二次崩——dumpPath 保留确定性派生占位(供 trace 链路有键、标 dump 未落盘)。 + System.err.println("[saa-giveup] giveup dump 落盘失败(best-effort,不影响 failed 终态)gameId=" + + gameId + ",err=" + trunc(String.valueOf(e.getMessage()), 200)); + } + out.put(K_GIVEUP_DUMP_PATH, dumpPath); return out; }; } + /** + * 取「本节点 out 已写值,否则 state 旧值」的字符串(giveup dump 记 failureReason 用;out 优先因本轮已置 failed 终态)。 + */ + private static String strVal(OverAllState s, String key, Map out) { + if (out.containsKey(key) && out.get(key) != null) { + return String.valueOf(out.get(key)); + } + return s.value(key, String.class).orElse(""); + } + + /** + * 把 state 内的 JSON 串嵌入 dump:能解析成 JSON 则嵌为子树(避免双重转义、离线可读);解析失败/空串则以文本节点原样落(best-effort 不丢内容)。 + */ + private static JsonNode embedJsonOrText(String raw) { + if (raw == null || raw.isEmpty()) { + return MAPPER.nullNode(); + } + JsonNode parsed = SaaPrompts.looseParse(raw); // 复用便宜模型脏 JSON 宽松解析口径 + return parsed != null ? parsed : com.fasterxml.jackson.databind.node.TextNode.valueOf(raw); + } + // ====================== ⑧ classify 节点(固定架构新增:品类分类 + profile 三维,真 LLM) ====================== /** @@ -947,13 +1028,52 @@ final class SaaStudioNodes { JsonNode patch = SaaPrompts.looseParse(patchStr); if ("regenerate-module".equals(mode)) { - // 重生成模块路:置 fix 角色 + 把 intent 作为 feedback 喂 generate(只重那一个 behavior,其余不动)。 + // 重生成模块路(U6/B7 不被卡部分):置 fix 角色 + 按 target 收窄到那一个 behavior 模块喂 generate(其余源不动)。 String intent = patch != null ? patch.path("payload").path("intent").asText("") : ""; + // U6:按 modifyPatch.target(behavior id 优先,否则 path)定位被重生成的模块——回喂 feedback 明确「只重这一个 behavior」, + // 非全量重出(generate 在 sourceProject 基础上重出,prompt 收窄到目标模块)。 + String target = resolveBehaviorTarget(patch); // 取 target.id(优先)/target.path;缺则空 + // ── P0-1(缺 target 强校验·绝不静默全量重生成):regenerate-module 的语义是「只重指定的那一个 behavior 模块」, + // 故 target(id 或 path)必须显式非空——否则收窄无锚点、prompt 退化为「在源上重出整局」= 与全量重生成无异 + // (旧实现:target 空 → resolveBehaviorTarget 返空串 → guardRegenerateTarget 仅 !target.isEmpty() 才校验 → + // 空 target 通过守卫、role=fix+feedback 路由 generate → 静默全量重生成)。本处首行强校验:缺 target + // 立即 fail-loud(status=failed + 明确 failureReason),复用 modifyRouter 的 fail 分支路由 END,不进入收窄/generate。 + if (target.isEmpty()) { + out.put(K_MODIFY_MODE, "deterministic"); // 复用 modifyRouter 的 fail 分支路由到 END(regenerate 缺 target=不可达 generate) + out.put(K_STATUS, "failed"); + out.put(K_FAILURE, "modify regenerate-module 缺 target:modifyPatch.target 的 id 或 path 均为空," + + "regenerate-module 须指定被重生成的目标 behavior(拒静默退化为全量重生成;请提供 target.id 或 target.path)"); + // base 源若有则透传保留(不丢),便于离线诊断。 + if (!spStr.isEmpty()) { + out.put(K_SOURCE_PROJECT, spStr); + } + return out; + } + // U6 命门:base 源缺失 / target 在 sourceProject.behaviors[] 无对应 → 明确错(非静默全量重生成),与确定性路 fail-loud 同纪律。 + String guardErr = guardRegenerateTarget(spStr, target); + if (guardErr != null) { + out.put(K_MODIFY_MODE, "deterministic"); // 复用 modifyRouter 的 fail 分支路由到 END(regenerate 守卫失败=不可达 generate) + out.put(K_STATUS, "failed"); + out.put(K_FAILURE, guardErr); + // base 源若有则透传保留(不丢),便于离线诊断。 + if (!spStr.isEmpty()) { + out.put(K_SOURCE_PROJECT, spStr); + } + return out; + } out.put(K_ROLE, "fix"); - out.put(K_FEEDBACK, intent.isEmpty() - ? "请只重生成被指定的玩法模块(behavior),其余部件保持不变。" - : ("请只重生成被指定的玩法模块(behavior),意图:" + intent + ";其余部件保持不变。")); - // sourceProject 原样透传(generate 在其基础上重出那一模块;B7 完整应用时按 target 精确定位模块)。 + // 回喂收窄到目标 behavior:明确「只重生成被指定模块、其余 sourceProject 部件不动」(非全量重出)。 + StringBuilder fb = new StringBuilder("请在现有源项目基础上,只重新生成被指定的玩法模块(behavior)"); + if (!target.isEmpty()) { + fb.append(":target=").append(target); + } + fb.append(",其余部件(其它 behaviors / config / assets / 关卡)保持不变"); + if (!intent.isEmpty()) { + fb.append("。改玩法意图:").append(intent); + } + fb.append("。"); + out.put(K_FEEDBACK, fb.toString()); + // sourceProject 原样透传(generate 在其基础上重出那一模块;guard 已确保 base 源 + target 有效)。 if (!spStr.isEmpty()) { out.put(K_SOURCE_PROJECT, spStr); } @@ -985,6 +1105,70 @@ final class SaaStudioNodes { }; } + /** + * U6:从 modifyPatch 取 regenerate-module 的目标 behavior 标识——优先 {@code target.id},否则 {@code target.path}(缺则空串)。 + * 用于把回喂 feedback 收窄到那一个 behavior(非全量重出),并供 {@link #guardRegenerateTarget} 校验目标存在性。 + */ + private static String resolveBehaviorTarget(JsonNode patch) { + if (patch == null || !patch.isObject()) { + return ""; + } + JsonNode target = patch.path("target"); + String id = target.path("id").asText("").trim(); + if (!id.isEmpty()) { + return id; + } + return target.path("path").asText("").trim(); + } + + /** + * U6 命门(取 base 源失败 / target 无对应源 → 明确错,非静默全量重生成):校验 regenerate-module 的前置—— + *

      + *
    • base 源缺失({@code sourceProject} 空/非法)→ 报错(无可改的源,不能凭空全量重生成当 modify);
    • + *
    • target 非空但 {@code sourceProject.behaviors[]} 内查无该 id/path 对应模块 → 报错(指向不存在 behavior,§U6 edge 用例)。
    • + *
    + * 校验通过返回 null;失败返回明确 failureReason(调用方据此置 failed 终态 + 路由 END,不静默退化为全量重生成)。 + * + *

    诚实边界:behaviors 容器探测兼容两形态——{@code sourceProject.behaviors} 为数组(元素含 id/name/path) + * 或 {@code sourceProject.gameDefinition.behaviors} 同形(源 schema 演进期两处都可能落 behaviors);两处都无 behaviors + * 容器时不卡死(仅校验 base 源非空,target 存在性留 generate 自处置)——避免对「源骨架尚无 behaviors 段」的早期态误杀。 + */ + private static String guardRegenerateTarget(String spStr, String target) { + // ① base 源非空校验:regenerate 是「在现有源上重出一模块」,base 源缺失则无源可改(不静默当全量生成)。 + JsonNode sp = (spStr == null || spStr.isEmpty()) ? null : SaaPrompts.looseParse(spStr); + if (sp == null || !sp.isObject()) { + return "modify regenerate-module 取 base 源失败:sourceProject 为空或非法 JSON(无可重生成的 base 源," + + "拒静默退化为全量重生成;请确认 baseVersionId 对应源已落库 game_source_project)"; + } + // ② target 存在性校验:仅当源里确有 behaviors 容器时才校验(无容器=源骨架早期态,不误杀,留 generate 处置)。 + if (!target.isEmpty()) { + JsonNode behaviors = sp.path("behaviors"); + if (!behaviors.isArray()) { + behaviors = sp.path("gameDefinition").path("behaviors"); // 兼容 gameDefinition.behaviors 落点 + } + if (behaviors.isArray() && behaviors.size() > 0 && !behaviorExists(behaviors, target)) { + return "modify regenerate-module 目标 behavior 不存在:target=" + target + + " 在 sourceProject.behaviors[] 中查无对应模块(拒静默全量重生成;请确认 target.id/path 正确)"; + } + } + return null; // 校验通过 + } + + /** 在 behaviors 数组里按 id/name/path 任一匹配 target(regenerate 目标存在性校验用)。 */ + private static boolean behaviorExists(JsonNode behaviors, String target) { + for (JsonNode b : behaviors) { + if (!b.isObject()) { + continue; + } + if (target.equals(b.path("id").asText(null)) + || target.equals(b.path("name").asText(null)) + || target.equals(b.path("path").asText(null))) { + return true; + } + } + return false; + } + /** * P1-6(RFC6901 JSON Pointer 寻址):按 modifyPatch.target.path 把 payload.value 写进 sourceProject 一处(确定性编辑)。 * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcherBaseSourceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcherBaseSourceTest.java new file mode 100644 index 00000000..b5a54c0a --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcherBaseSourceTest.java @@ -0,0 +1,142 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * SaaGraphDispatcherBaseSourceTest —— U6/B7 §5.6「modify 取 base 源」的纯单元测试(反射调 {@code buildInputs})。 + * + *

    不起图、不起 DB、不调 LLM:mock {@link SourceProjectApi},反射调 + * {@code SaaGraphDispatcher.buildInputs(Map,String)} 私有方法,断 K_SOURCE_PROJECT 注入结果。 + * + *

    守门目标(U6 Test scenarios + 命门): + *

      + *
    • modify 路 ∧ baseVersionId → 经 SourceProjectApi 反查 base 源注入 K_SOURCE_PROJECT;
    • + *
    • create 路(modifyMode 空)→ 不取源、不触 SourceProjectApi,K_SOURCE_PROJECT 空串(现行字节零变);
    • + *
    • base 反查未命中(found=false)→ 注入空源(由 modify 节点守卫决断,非静默全量重生成);
    • + *
    • job 自带 sourceProject → 优先用之、不触反查;
    • + *
    • seam 缺席(sourceProjectApi=null)→ 取源旁路、空串、不崩。
    • + *
    + * + * @author 造梦AI(U6/B7 modify 取 base 源) + */ +class SaaGraphDispatcherBaseSourceTest { + + /** 反射调被测私有方法 buildInputs(Map, String)。 */ + @SuppressWarnings("unchecked") + private static Map invokeBuildInputs(SaaGraphDispatcher d, Map job, String traceId) + throws Exception { + Method m = SaaGraphDispatcher.class.getDeclaredMethod("buildInputs", Map.class, String.class); + m.setAccessible(true); + return (Map) m.invoke(d, job, traceId); + } + + /** 用 mock SourceProjectApi 构造 dispatcher(其余依赖 null;buildInputs 只触碰 sourceProjectApi)。 */ + private static SaaGraphDispatcher dispatcherWith(SourceProjectApi api) { + return new SaaGraphDispatcher(null, null, null, null, api); + } + + private static Map modifyJob(String mode, Object baseVersionId) { + Map job = new HashMap<>(); + job.put("brief", "把跳跃改高"); + job.put("modifyMode", mode); + job.put("modifyPatch", "{\"mode\":\"" + mode + "\"}"); + if (baseVersionId != null) { + job.put("baseVersionId", baseVersionId); + } + return job; + } + + // ============================== 用例1:modify 路反查命中 → 注入 base 源 ============================== + + @Test + void buildInputs_modify_fetchesBaseSource_injectsSourceProject() throws Exception { + SourceProjectApi api = mock(SourceProjectApi.class); + SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO(); + data.setFound(Boolean.TRUE); + data.setId(9001L); + data.setSourceJson("{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"}]}"); + when(api.fetchByVersionId(eq(700L))).thenReturn(CommonResult.success(data)); + + Map inputs = invokeBuildInputs(dispatcherWith(api), + modifyJob("regenerate-module", 700L), "trace-u6-1"); + + assertEquals("regenerate-module", inputs.get(SaaStudioNodes.K_MODIFY_MODE)); + assertEquals("700", inputs.get(SaaStudioNodes.K_BASE_VERSION_ID)); + assertTrue(((String) inputs.get(SaaStudioNodes.K_SOURCE_PROJECT)).contains("jump"), + "modify 路应反查 base 源注入 K_SOURCE_PROJECT"); + verify(api).fetchByVersionId(700L); + } + + // ============================== 用例2:create 路不取源 ============================== + + @Test + void buildInputs_create_doesNotFetch_sourceProjectEmpty() throws Exception { + SourceProjectApi api = mock(SourceProjectApi.class); + Map job = new HashMap<>(); + job.put("brief", "一个躲避陨石的游戏"); // create 路:无 modifyMode + + Map inputs = invokeBuildInputs(dispatcherWith(api), job, "trace-create-1"); + + assertEquals("", inputs.get(SaaStudioNodes.K_MODIFY_MODE), "create 路 modifyMode 空"); + assertEquals("", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT), "create 路不取源 → K_SOURCE_PROJECT 空串"); + verify(api, never()).fetchByVersionId(anyLong()); // 现行字节零变:create 路不触 SourceProjectApi + } + + // ============================== 用例3:base 反查未命中 → 注入空源(命门:非静默全量) ============================== + + @Test + void buildInputs_modify_baseNotFound_injectsEmpty() throws Exception { + SourceProjectApi api = mock(SourceProjectApi.class); + SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO(); + data.setFound(Boolean.FALSE); // base 无对应源行 + when(api.fetchByVersionId(eq(701L))).thenReturn(CommonResult.success(data)); + + Map inputs = invokeBuildInputs(dispatcherWith(api), + modifyJob("regenerate-module", 701L), "trace-u6-miss"); + + assertEquals("", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT), + "base 反查未命中 → 注入空源(由 modify 节点 U6 守卫 fail-loud,非静默全量重生成)"); + } + + // ============================== 用例4:job 自带 sourceProject 优先 ============================== + + @Test + void buildInputs_jobCarriesSourceProject_takesPrecedence() throws Exception { + SourceProjectApi api = mock(SourceProjectApi.class); + Map job = modifyJob("regenerate-module", 700L); + job.put("sourceProject", "{\"fromJob\":true}"); // job 已带源 + + Map inputs = invokeBuildInputs(dispatcherWith(api), job, "trace-u6-job"); + + assertEquals("{\"fromJob\":true}", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT), + "job 自带 sourceProject 应优先用之"); + verify(api, never()).fetchByVersionId(anyLong()); // 不触反查 + } + + // ============================== 用例5:seam 缺席(4 参构造态)→ 取源旁路、不崩 ============================== + + @Test + void buildInputs_seamAbsent_modifyDegradesEmpty() throws Exception { + // 5 参构造传 null seam(等价 4 参旧构造态)。 + SaaGraphDispatcher d = new SaaGraphDispatcher(null, null, null, null, null); + Map inputs = invokeBuildInputs(d, modifyJob("regenerate-module", 700L), "trace-u6-noseam"); + assertEquals("", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT), + "seam 缺席 → modify 取源旁路、K_SOURCE_PROJECT 空串(不崩)"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java index f184332d..1ab4c65a 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraphTopologyTest.java @@ -430,10 +430,15 @@ class SaaStudioGraphTopologyTest { // ③ 路由:modifyRouter → "fail"(图里 → END,不走 build)。 assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "deterministic 路由应为 fail(→END),不走 build"); - // 复核:regenerate-module 路不受影响,仍 → generate。 + // 复核:regenerate-module 路不受 P0-4 deterministic guard 影响,仍 → generate(本期可达)。 + // 注(U6/B7):regenerate-module 现要求 base 源非空(取 base 源失败 fail-loud,非静默全量重生成),故此处补 K_SOURCE_PROJECT + // 非空基线(无 behaviors 容器 = 早期源骨架态,不触发 target 存在性守卫),坐实 regenerate ≠ deterministic-fail。 + // 注(U6 P0-1):regenerate-module 现还要求 target 非空(缺 target fail-loud,见 modify_regenerate_module_missing_target_fails_loud), + // 故此处 patch 补 target(本测的意图是「regenerate ≠ deterministic-fail」,非测缺 target 路)。 OverAllState s2 = newFullState(Map.of( SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", - SaaStudioNodes.K_MODIFY_PATCH, "{\"mode\":\"regenerate-module\",\"payload\":{\"intent\":\"x\"}}")); + SaaStudioNodes.K_MODIFY_PATCH, "{\"mode\":\"regenerate-module\",\"target\":{\"id\":\"jump\"},\"payload\":{\"intent\":\"x\"}}", + SaaStudioNodes.K_SOURCE_PROJECT, "{\"schemaVersion\":\"1.0\"}")); applyNode(s2, SaaStudioNodes.modifyNode()); assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s2)); } @@ -474,6 +479,200 @@ class SaaStudioGraphTopologyTest { // ====================== ⑮ P1-5:escalate 后 modelTier=stage2 时 generate 选 stage2 强档模型 ====================== + // ====================== ⑯ U5/B4:giveup 真落盘(dump 整包 + 路径回填 + best-effort) ====================== + + /** + * U5/B4:救场耗尽 giveup 时把 attempts/verdict/escalationEvents 整包真写盘到 + * {@code /games/_wg1-gen//evidence/giveup-dump.json},且 K_GIVEUP_DUMP_PATH 回填真实绝对路径、 + * giveup 仍正确置 failed 终态。 + */ + @Test + void giveup_node_writes_full_dump_and_backfills_real_path() throws Exception { + java.nio.file.Path tmpRoot = java.nio.file.Files.createTempDirectory("saa-giveup-dump-test"); + try { + String gameId = "saa-giveup-1"; + // 构造放弃终态桩:attempts(全轮轨迹)+ verdict(末次九门)+ escalationEvents(升档序列)+ 上下文计数。 + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_GAME_ID, gameId, + SaaStudioNodes.K_FEEDBACK, "九门 C_frame 始终未过:帧率探针读不到 tick", + SaaStudioNodes.K_REPAIR_COUNT, 8, + SaaStudioNodes.K_FAIL_COUNT, 8, + SaaStudioNodes.K_MODEL_TIER, "stage2", + SaaStudioNodes.K_ATTEMPTS, + "[{\"attempt\":0,\"role\":\"code\",\"model\":\"m\",\"usage\":{\"in\":10,\"out\":20}}," + + "{\"attempt\":1,\"role\":\"fix\",\"model\":\"m\",\"stage_fail\":\"play\",\"usage\":{\"in\":5,\"out\":7}}]", + SaaStudioNodes.K_VERDICT, "{\"pass\":false,\"guards\":{\"C_frame\":{\"pass\":false}}}", + SaaStudioNodes.K_ESCALATION_EVENTS, + "[{\"tierBefore\":\"stage1\",\"tierAfter\":\"stage2\",\"atFailCount\":5,\"ts\":\"2026-06-17T00:00:00Z\"}]")); + applyNode(s, SaaStudioNodes.giveupNode(tmpRoot)); + + // ① 终态正确:status=failed + failureReason 含 max_repairs_exceeded。 + assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""), "giveup 仍置 failed 终态"); + assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("max_repairs_exceeded")); + + // ② dump 路径回填为真实绝对路径,且文件真存在。 + String dumpPath = s.value(SaaStudioNodes.K_GIVEUP_DUMP_PATH, String.class).orElse(""); + assertTrue(dumpPath.endsWith("giveup-dump.json"), "dump 路径应指向 giveup-dump.json,实得=" + dumpPath); + java.nio.file.Path expected = tmpRoot.resolve("games").resolve("_wg1-gen").resolve(gameId) + .resolve("evidence").resolve("giveup-dump.json"); + assertEquals(expected.toAbsolutePath().toString(), dumpPath, "K_GIVEUP_DUMP_PATH 应回填真实绝对路径"); + assertTrue(java.nio.file.Files.exists(expected), "dump 文件应真落盘"); + + // ③ dump 内容含全 attempts/verdict/escalationEvents + 上下文(整包,不截断关键信息)。 + JsonNode dump = MAPPER.readTree(java.nio.file.Files.readAllBytes(expected)); + assertEquals(gameId, dump.path("gameId").asText()); + assertEquals("max_repairs_exceeded", dump.path("reason").asText()); + assertEquals(8, dump.path("failCount").asInt()); + assertEquals("stage2", dump.path("modelTier").asText()); + assertTrue(dump.path("attempts").isArray() && dump.path("attempts").size() == 2, + "dump 应含全 attempts 轨迹(整包不截断)"); + assertEquals("play", dump.path("attempts").get(1).path("stage_fail").asText(), "attempts 嵌为子树(非转义串)"); + assertFalse(dump.path("verdict").path("pass").asBoolean(true), "dump 应含末次 verdict"); + assertTrue(dump.path("escalationEvents").isArray() && dump.path("escalationEvents").size() == 1, + "dump 应含升档事件序列"); + } finally { + // 清理临时目录(递归删)。 + deleteRecursively(tmpRoot); + } + } + + /** + * U5/B4 best-effort:落盘失败(gameRuntimeRoot 指向一个已存在的文件,createDirectories 在其下建目录必失败) + * → giveup 仍正确置 failed 终态、不二次崩;K_GIVEUP_DUMP_PATH 回填确定性派生占位路径(标 dump 未落盘)。 + */ + @Test + void giveup_node_dump_write_failure_is_best_effort_no_crash() throws Exception { + // 用一个普通文件当 "root":root.resolve("games/...") 在文件下建目录 → createDirectories 抛 → 落盘失败路径。 + java.nio.file.Path fileAsRoot = java.nio.file.Files.createTempFile("saa-giveup-notdir", ".tmp"); + try { + String gameId = "saa-giveup-fail"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_GAME_ID, gameId, + SaaStudioNodes.K_FEEDBACK, "x", + SaaStudioNodes.K_ATTEMPTS, "[]")); + // 不抛异常(best-effort)。 + applyNode(s, SaaStudioNodes.giveupNode(fileAsRoot)); + // 终态仍正确。 + assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""), + "落盘失败仍须保正确 failed 终态(best-effort 不二次崩)"); + // 路径回填确定性派生占位(相对路径,标 dump 未落盘)。 + assertEquals("games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json", + s.value(SaaStudioNodes.K_GIVEUP_DUMP_PATH, String.class).orElse(""), + "落盘失败 → dumpPath 回填确定性派生占位(标 dump 未落盘)"); + } finally { + java.nio.file.Files.deleteIfExists(fileAsRoot); + } + } + + /** 递归删临时目录(测试清理用)。 */ + private static void deleteRecursively(java.nio.file.Path p) throws Exception { + if (!java.nio.file.Files.exists(p)) { + return; + } + try (java.util.stream.Stream walk = java.nio.file.Files.walk(p)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { + try { + java.nio.file.Files.deleteIfExists(path); + } catch (Exception ignore) { + // 清理 best-effort + } + }); + } + } + + // ====================== ⑰ U6/B7:regenerate-module 按 target 收窄 + base 缺拒 ====================== + + /** + * U6/B7:regenerate-module 按 target(behavior id)收窄——回喂 feedback 明确「只重生成那一个 behavior、其余不动」, + * 含 target 标识与 intent;base 源(含该 behavior)透传保留;路由 → generate(本期可达)。 + */ + @Test + void modify_regenerate_module_narrows_by_target() throws Exception { + // base 源含 behaviors[jump];target=jump 命中 → 收窄回喂、路由 generate。 + String base = "{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"},{\"id\":\"shoot\"}]}"; + String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"}," + + "\"payload\":{\"intent\":\"让跳跃更高更软\"}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + + assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""), "regenerate 置 role=fix"); + String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse(""); + assertTrue(fb.contains("jump"), "feedback 应收窄到 target=jump,实得=" + fb); + assertTrue(fb.contains("让跳跃更高更软"), "feedback 应含 intent"); + assertTrue(fb.contains("其余部件") || fb.contains("保持不变"), "feedback 应明确其余部件不动(非全量重出)"); + // base 源透传保留。 + assertTrue(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("").contains("shoot"), + "base 源应透传保留(generate 在其基础上重出 jump)"); + // 路由 → generate(本期可达)。 + assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s)); + } + + /** + * U6/B7 命门:regenerate-module 取 base 源失败(sourceProject 空)→ 明确错(非静默全量重生成),路由 fail(→END)。 + */ + @Test + void modify_regenerate_module_missing_base_source_fails_loud() throws Exception { + // base 源空(K_SOURCE_PROJECT 缺)→ 守卫报错、置 failed、路由 fail。 + String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"}," + + "\"payload\":{\"intent\":\"x\"}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch)); // 不给 K_SOURCE_PROJECT = base 源缺失 + applyNode(s, SaaStudioNodes.modifyNode()); + + assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""), + "base 源缺失应 fail-loud(非静默全量重生成)"); + assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("base 源"), + "failureReason 应明确指向 base 源缺失"); + assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "base 缺 → 路由 fail(→END)"); + } + + /** + * U6/B7 命门:regenerate-module 的 target 在 sourceProject.behaviors[] 查无对应 behavior → 明确错(非静默全量重生成)。 + */ + @Test + void modify_regenerate_module_unknown_target_fails_loud() throws Exception { + // base 源含 behaviors[jump],但 target=nonexistent 查无 → 守卫报错。 + String base = "{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"}]}"; + String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"nonexistent\"}," + + "\"payload\":{\"intent\":\"x\"}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + + assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""), + "target 指向不存在 behavior 应 fail-loud"); + assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("不存在"), + "failureReason 应明确 target behavior 不存在"); + assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s)); + } + + /** + * U6/B7 边界:base 源无 behaviors 容器(早期源骨架态)→ 不误杀(仅校验 base 源非空),路由 generate。 + * 守卫只在「源里确有 behaviors 容器」时才校验 target 存在性,避免对早期态误杀。 + */ + @Test + void modify_regenerate_module_no_behaviors_container_not_killed() throws Exception { + // base 源非空但无 behaviors 段(asset 节点产的最小骨架:schemaVersion + assets[])。 + String base = "{\"schemaVersion\":\"1.0\",\"assets\":[{\"id\":\"x\"}]}"; + String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"}," + + "\"payload\":{\"intent\":\"x\"}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + // 不误杀:role=fix、路由 generate(target 存在性留 generate 处置)。 + assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse("")); + assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s), + "无 behaviors 容器(早期源骨架)不误杀,路由 generate"); + } + /** * P1-5:升档(escalate→stage2)后 generate 必须真切到更强模型,否则升档无实际效果。 * 用纯静态选择器 {@link SaaStudioNodes#selectGenerateModelName}(与 generateNode 内模型对象选择同口径)无网络断言: @@ -499,4 +698,106 @@ class SaaStudioGraphTopologyTest { assertFalse(m.codeStage2().equals(m.code()), "P1-5:stage2 code 模型须不同于(强于)stage1,否则升档无实际效果"); } + + // ====================== ⑱ U6 评审 P0-1/P0-2:缺 target fail-loud + base 源真喂 prompt ====================== + + /** + * P0-1(缺 target 静默全量重生成 → fail-loud):regenerate-module 路 modifyPatch.target 的 id/path 均空时, + * 旧实现(resolveBehaviorTarget 返空串、guardRegenerateTarget 仅 !target.isEmpty() 才校验)会通过守卫、role=fix + * 路由 generate = 静默全量重生成。修后须首行强校验:缺 target 立即 status=failed + failureReason 说明 + * 「regenerate 缺 target」+ 路由 fail(→END),绝不进入收窄/generate。 + */ + @Test + void modify_regenerate_module_missing_target_fails_loud() throws Exception { + // base 源含 behaviors(充分),但 patch 完全不带 target(仅 intent)→ 缺 target 必须 fail-loud(非静默全量重生成)。 + String base = "{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"},{\"id\":\"shoot\"}]}"; + String patch = "{\"mode\":\"regenerate-module\",\"payload\":{\"intent\":\"让跳跃更高\"}}"; // 无 target + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + + assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""), + "缺 target 必须 fail-loud(绝不静默全量重生成)"); + String reason = s.value(SaaStudioNodes.K_FAILURE, String.class).orElse(""); + assertTrue(reason.contains("缺 target") || reason.contains("target"), + "failureReason 应说明 regenerate 缺 target,实得=" + reason); + // 未进入收窄路:role 不应被置 fix(不进入 generate)。 + assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "缺 target → 路由 fail(→END),不可达 generate"); + } + + /** + * P0-1 边界:target.path 形态(无 id 但有 path)应视为有 target、不被缺-target 守卫误杀。 + * (resolveBehaviorTarget id 优先、否则 path;缺 target 守卫只在两者皆空时触发。) + */ + @Test + void modify_regenerate_module_target_by_path_not_killed_by_missing_target_guard() throws Exception { + // base 源无 behaviors 容器(早期源骨架)= 不触 target 存在性守卫;patch 用 target.path 形态 → 不应判缺 target。 + String base = "{\"schemaVersion\":\"1.0\"}"; + String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"path\":\"/behaviors/0\"}," + + "\"payload\":{\"intent\":\"x\"}}"; + OverAllState s = newFullState(Map.of( + SaaStudioNodes.K_MODIFY_MODE, "regenerate-module", + SaaStudioNodes.K_MODIFY_PATCH, patch, + SaaStudioNodes.K_SOURCE_PROJECT, base)); + applyNode(s, SaaStudioNodes.modifyNode()); + assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""), + "target.path 形态应视为有 target(不被缺-target 守卫误杀)"); + assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s)); + } + + /** + * P0-2(regenerate 路真消费 base 源·informed-by-base):buildRegenerateMessages(generateNode 在 regenerate 路所调) + * 产出的 user prompt 必须含 base 源 JSON 关键内容 + 目标 behavior 标识 + intent,让 LLM 在既有结构上重那一个 behavior, + * 而非凭题面凭空全量重出(修评审「按 target 收窄成伪契约」)。 + */ + @Test + void regenerate_prompt_carries_base_source_and_target() { + String enriched = "题面:一个跳跃小游戏\n\n## 设计稿\n核心机制..."; + String feedback = "请在现有源项目基础上,只重新生成被指定的玩法模块(behavior):target=jump"; + String baseSource = "{\"schemaVersion\":\"1.0\",\"behaviors\":[" + + "{\"id\":\"jump\",\"params\":{\"height\":120}},{\"id\":\"shoot\",\"params\":{\"rate\":3}}]}"; + String[] msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, DUMMY_ROOT, + baseSource, "jump", "让跳跃更高更软"); + String user = msgs[1]; + // ① base 源真喂:user prompt 含源 JSON 的关键标识(behaviors / 各部件 id),LLM 能看到原结构。 + assertTrue(user.contains("behaviors"), "regenerate prompt 应含 base 源结构(behaviors),实得片段=" + tail(user)); + assertTrue(user.contains("\"id\":\"jump\"") && user.contains("\"id\":\"shoot\""), + "regenerate prompt 应含 base 源各 behavior(含须保留的 shoot),坐实 LLM 看到既有结构"); + assertTrue(user.contains("height") || user.contains("120"), + "regenerate prompt 应含 base 源细节(目标 behavior 既有参数),非仅题面"); + // ② 目标 behavior 聚焦:明确 target + 意图 + 「只重目标、其余不变」。 + assertTrue(user.contains("target=jump"), "regenerate prompt 应聚焦 target=jump"); + assertTrue(user.contains("让跳跃更高更软"), "regenerate prompt 应含改玩法意图"); + assertTrue(user.contains("其它部件") || user.contains("其余") || user.contains("不变"), + "regenerate prompt 应提示其余部件保留(informed-by-base 而非全量重出)"); + // system 与普通路同一(公平性铁律:不改 SYSTEM 语义,只在 user 附 base 源)。 + assertEquals(SaaPrompts.buildMessages(enriched, feedback, DUMMY_ROOT)[0], msgs[0], + "regenerate 路 system 必须与普通路同一 SYSTEM(不改 prompt 语义)"); + } + + /** + * P0-2 零回归:create/repair 普通生成路(buildMessages)的 prompt 不含 base 源——只在 regenerate 路注入。 + * 严格 additive 的字节级证据:普通路 user prompt 不出现「源项目」/「base 源」注入段。 + */ + @Test + void normal_path_prompt_has_no_base_source() { + String enriched = "题面:一个跳跃小游戏\n\n## 设计稿\n核心机制..."; + // 普通 create 路(无 feedback)。 + String[] create = SaaPrompts.buildMessages(enriched, null, DUMMY_ROOT); + assertFalse(create[1].contains("源项目工件") || create[1].contains("base 源") || create[1].contains("base 源 JSON"), + "create 普通路 prompt 不应含 base 源注入(零回归)"); + // 普通 repair 失败回边(有 feedback,但非 regenerate 路)。 + String[] repair = SaaPrompts.buildMessages(enriched, "上一轮 validate 失败:源码过短", DUMMY_ROOT); + assertFalse(repair[1].contains("源项目工件") || repair[1].contains("base 源"), + "repair 普通路(失败回喂)prompt 不应含 base 源注入(零回归)"); + // 普通路 user prompt 仍是「实现下面这款游戏」开头(字节不变锚)。 + assertTrue(create[1].startsWith("请实现下面这款游戏"), "create 普通路 user prompt 开头不变"); + } + + /** 取字符串尾部片段(断言失败时定位,避免打印超长 prompt)。 */ + private static String tail(String s) { + return s == null ? "null" : (s.length() > 300 ? "..." + s.substring(s.length() - 300) : s); + } } 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 index cf5f4e6e..ecaa398b 100644 --- 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 @@ -1,5 +1,6 @@ package com.wanxiang.huijing.game.module.studio.api; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; 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; @@ -7,6 +8,7 @@ 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.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -91,4 +93,21 @@ public interface SourceProjectApi { @Operation(summary = "标孤儿(按源行 ID 精确定位置 status=2,不建包不动 currentVersion)") CommonResult markOrphanById(@RequestParam("id") Long id); + /** + * 按产物版本 ID 反查源工件(U6/B7 §5.6:modify/extend 取 base 源):构建成功回填 version_id 后据此由版本反查源 + * (idx_version 命中),回传 source_json 供注入 SAA state K_SOURCE_PROJECT。 + * + *

    用途:modify/extend 要在 base 版本的源项目上改/重生成。{@code SaaGraphDispatcher.buildInputs} 据已持久化抵达的 + * {@code baseVersionId} 调本 API 反查 base 源(aigc→studio-api seam,与 land/markBuilt 同一 seam),注入 K_SOURCE_PROJECT。 + * + *

    非阻断纪律:反查是派发侧旁路读——version_id 无对应源行(base 未落源/早期态)→ {@code found=false}、 + * sourceJson=null(调用方据此明确「base 无源」,决断是否拒;不抛)。 + * + * @param versionId base 产物版本 ID(project.game_version.id) + * @return 反查结果(found + source_json + status),CommonResult 包裹;无对应源行 found=false + */ + @GetMapping(PREFIX + "/source-project/fetch-by-version") + @Operation(summary = "按版本 ID 反查源工件 source_json(U6 modify 取 base 源;无则 found=false)") + CommonResult fetchByVersionId(@RequestParam("versionId") Long versionId); + } diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectFetchRespDTO.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectFetchRespDTO.java new file mode 100644 index 00000000..8258f23d --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/dto/SourceProjectFetchRespDTO.java @@ -0,0 +1,39 @@ +package com.wanxiang.huijing.game.module.studio.dto; + +import lombok.Data; + +/** + * 源项目工件反查结果(U6/B7 §5.6:modify/extend 按 baseVersionId 反查 base 源 source_json) + * + *

    为什么需要它:modify/extend 要在「被改 base 版本的源项目」之上改/重生成(execution §5.6),故须按 base + * 产物版本 ID 反查其源工件 JSON(idx_version)注入 SAA state K_SOURCE_PROJECT,使 modify 节点在 base 源上改。 + * 反查发生在 aigc 派发侧({@code SaaGraphDispatcher.buildInputs},baseVersionId 已随 V19 列持久化抵达),经本 seam 桥接。 + * + *

    边界:只回传源态 + source_json(不回 sourceUrl 等存储细节);version_id 无对应源行 → {@code found=false}、 + * sourceJson 留 null(调用方据此明确「base 无源」而非静默空源;U6 命门:base 缺拒,非静默全量重生成)。 + * + * @author 造梦AI(U6/B7 modify 取 base 源) + */ +@Data +public class SourceProjectFetchRespDTO { + + /** + * 是否查到 base 版本对应的源行(true=查到、sourceJson 有值;false=无对应源行、sourceJson 为 null) + */ + private Boolean found; + + /** + * 源项目工件行 ID(game_source_project.id;found=false 时为 null) + */ + private Long id; + + /** + * 源项目工件 JSON 全文(SourceProject schema;found=false 时为 null)——注入 SAA state K_SOURCE_PROJECT + */ + private String sourceJson; + + /** + * 源态:0草稿 1已构建 2孤儿 3已发布(found=false 时为 null;诊断用,反查不限态) + */ + private Integer status; +} 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 index cc644e4f..7835be16 100644 --- 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 @@ -1,5 +1,6 @@ package com.wanxiang.huijing.game.module.studio.api; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; 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; @@ -46,4 +47,10 @@ public class SourceProjectApiImpl implements SourceProjectApi { return success(sourceProjectService.markOrphanById(id)); } + @Override + public CommonResult fetchByVersionId(Long versionId) { + // 反查 base 源(U6/B7 §5.6 modify 取 base 源):按版本 ID 反查 source_json,无对应源行 found=false + return success(sourceProjectService.fetchByVersionId(versionId)); + } + } 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 index 92751918..591a9a92 100644 --- 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 @@ -1,5 +1,6 @@ package com.wanxiang.huijing.game.module.studio.service.studio; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO; import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO; @@ -52,4 +53,15 @@ public interface SourceProjectService { */ boolean markOrphanById(Long id); + /** + * 按产物版本 ID 反查源工件(U6/B7 §5.6:modify/extend 取 base 源):idx_version 命中回传 source_json。 + * + *

    只读、不限态(反查 base 源不要求 status=1,published/built 皆可读);version_id 无对应源行 → {@code found=false}、 + * sourceJson=null(调用方据此明确「base 无源」;不抛)。version_id 为 null → found=false。 + * + * @param versionId base 产物版本 ID(= project.game_version.id) + * @return 反查结果(found + source_json + status);无对应源行 found=false + */ + SourceProjectFetchRespDTO fetchByVersionId(Long versionId); + } 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 index a2f26f4d..7e00fe8f 100644 --- 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 @@ -4,6 +4,7 @@ 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.SourceProjectFetchRespDTO; 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; @@ -218,6 +219,38 @@ public class SourceProjectServiceImpl implements SourceProjectService { return true; } + /** + * 按产物版本 ID 反查源工件(U6/B7 §5.6:modify/extend 取 base 源):idx_version 命中回传 source_json。 + * + *

    只读、不限态(base 源 built/published 皆可读);version_id 为 null / 无对应源行 → found=false、sourceJson=null + * (调用方据此明确「base 无源」,决断是否拒,不抛——非阻断旁路读)。 + */ + @Override + @Transactional(readOnly = true, rollbackFor = Exception.class) + public SourceProjectFetchRespDTO fetchByVersionId(Long versionId) { + SourceProjectFetchRespDTO resp = new SourceProjectFetchRespDTO(); + if (versionId == null) { + log.warn("[source-project] 反查 base 源入参 versionId=null,found=false"); + resp.setFound(Boolean.FALSE); + return resp; + } + GameSourceProjectDO source = gameSourceProjectMapper.selectByVersionId(versionId); + if (source == null) { + // base 版本无对应源行(未落源/早期态):found=false,调用方据此明确「base 无源」(U6 命门:base 缺拒,非静默空源) + log.info("[source-project] 反查 base 源未命中(version_id 无对应源行)versionId={}, found=false", versionId); + resp.setFound(Boolean.FALSE); + return resp; + } + resp.setFound(Boolean.TRUE); + resp.setId(source.getId()); + resp.setSourceJson(source.getSourceJson()); + resp.setStatus(source.getStatus()); + log.info("[source-project] 反查 base 源命中 versionId={}, sourceId={}, status={}, sourceJsonLen={}", + versionId, source.getId(), source.getStatus(), + source.getSourceJson() == null ? 0 : source.getSourceJson().length()); + return resp; + } + // ============================== 私有:规范化 sha256 ============================== /** 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 index c8dfefe5..fd657c6a 100644 --- 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 @@ -319,6 +319,46 @@ class SourceProjectServiceImplTest extends BaseMockitoUnitTest { verify(gameSourceProjectMapper, never()).insert(any(GameSourceProjectDO.class)); } + // ============================== 用例:U6 fetchByVersionId 反查 base 源 ============================== + + @Test + void testFetchByVersionId_hit() { + // idx_version 命中 → found=true + source_json + status。 + GameSourceProjectDO src = new GameSourceProjectDO(); + src.setId(3001L); + src.setVersionId(700L); + src.setSourceJson(SOURCE_JSON); + src.setStatus(SourceProjectStatusEnum.BUILT.getStatus()); + when(gameSourceProjectMapper.selectByVersionId(700L)).thenReturn(src); + + com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO resp = + sourceProjectService.fetchByVersionId(700L); + assertTrue(resp.getFound()); + assertEquals(3001L, resp.getId()); + assertEquals(SOURCE_JSON, resp.getSourceJson()); + assertEquals(SourceProjectStatusEnum.BUILT.getStatus(), resp.getStatus()); + } + + @Test + void testFetchByVersionId_miss_returnsFoundFalse() { + // version_id 无对应源行 → found=false、sourceJson=null(U6 命门:base 无源,调用方据此决断)。 + when(gameSourceProjectMapper.selectByVersionId(701L)).thenReturn(null); + com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO resp = + sourceProjectService.fetchByVersionId(701L); + assertFalse(resp.getFound()); + assertNull(resp.getSourceJson()); + assertNull(resp.getId()); + } + + @Test + void testFetchByVersionId_nullVersionId_returnsFoundFalse() { + // versionId=null → found=false,不查 Mapper(前置短路)。 + com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO resp = + sourceProjectService.fetchByVersionId(null); + assertFalse(resp.getFound()); + verify(gameSourceProjectMapper, never()).selectByVersionId(any()); + } + // ============================== 私有构造 ============================== private static SourceProjectLandReqDTO landReq() {