feat(runner-v2): P3 3a+3b-A 派发面 plumbing——便宜模型生成游戏经后端 dispatch 自动入 feed 真玩(零回归验)

3a canary:pong 真 bundle→buildGamePackage→发布→feed 引擎真渲真玩(gameId 9301,brick-2 真值通路 mvn 13/13 验)。3b-A 派发面:执行器对 generic dispatch 外置 worker→worker 回调→buildGamePackage→feed,stub worker e2e 实证(任务129→版本93111→gameId 9302 引擎真渲 CDP 11/11)。

改动:① 点亮 AigcTemplateConstants(List.of(generic))+PromptResourceLoader ② 派发面 AigcGenerateExecutor(dispatchGeneric 分支:仅 dispatchTemplateId=generic 走、其余进程内 LLM 旧路逐行不变=零回归)+WorkerDispatchClient+AigcExecutorProperties/Configuration ③ 内网回调 AdminAigcTaskController @PermitAll /dify/callback-internal(注入系统身份解 updater-null=部署窗口#3 同根因复发,镜像 executeWithSystemIdentity)④ stub worker scripts/wg1_stub_worker.py + 灌库/发布桩 p3_*/p3b_* + config worker-url ⑤ 单测 +3 派发面用例(dispatch/失败/零回归 clicker 不 dispatch)⑥ 蒸馏 ai-generation-pipeline.md §⓪′ 派发面范式+2 红线。

零回归硬约束:AigcGenerateExecutorTest 20/0/0、AigcTaskServiceImplTest 9/0/0,clicker 仍进程内(dispatchSender 零交互)。主笔亲核 gating diff(:342-344 仅 generic)+肉眼复核 feed 真玩截图。🔴 安全红线(留 3b-B):/dify/callback-internal 跳 RBAC 仅恃内网兜底,真 worker/外部接入前必补 HMAC/mTLS 服务间签名。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-14 14:19:25 +00:00
parent d79f2c37d5
commit ce9085df6b
16 changed files with 1373 additions and 7 deletions

View File

@ -23,6 +23,16 @@
- **批跑前置铁律**:①浏览器侧 frontend 必须 `http://localhost:4173`(crypto.subtle 安全上下文,走 IP 真包也永 demo 兜底)②执行器与批跑互斥(批前关 `aigc.executor.enabled`)③首批前跑一次 `evalflow.py --seed-spike`(幂等)④抽检模型不可用按评审版 R8 降级(同模型异版本 prompt 重裁+告警),分歧>10% 冻结下一批发布开关。 - **批跑前置铁律**:①浏览器侧 frontend 必须 `http://localhost:4173`(crypto.subtle 安全上下文,走 IP 真包也永 demo 兜底)②执行器与批跑互斥(批前关 `aigc.executor.enabled`)③首批前跑一次 `evalflow.py --seed-spike`(幂等)④抽检模型不可用按评审版 R8 降级(同模型异版本 prompt 重裁+告警),分歧>10% 冻结下一批发布开关。
- **回调写链**(真实,9bf3d54):唯一写入路径=`DifyCallbackService.handleCallback`(三表同事务:task/version/runtime_package;putManifest 未命中显式失败;checksum 两段序列化消解自指)——任何新生成来源(M-b 执行器/未来真 Dify)一律复用此语义,禁止旁路。 - **回调写链**(真实,9bf3d54):唯一写入路径=`DifyCallbackService.handleCallback`(三表同事务:task/version/runtime_package;putManifest 未命中显式失败;checksum 两段序列化消解自指)——任何新生成来源(M-b 执行器/未来真 Dify)一律复用此语义,禁止旁路。
### ⓪′ P3 派发面(generic 外置 worker 生成 engineBundle 入 feed,3b-A 实证 2026-06-14)
**范式**:M-b「执行器进程内 LLM 出 GameConfig」之外,新增「**派发外置 worker 产 engineBundle**」路——仅 `templateId=generic` 走,旧四模板(若再开放)走旧进程内路逐行不变(**零回归硬约束**)。链路:执行器 `dispatchGeneric`(`AigcGenerateExecutor`)组 §6.1 job POST 给 `aigc.executor.worker-url` → worker 异步产 bundle → 把含 `engineBundle` 的 `DifyCallbackReqVO` POST 回**新开的内网回调子路由** `/admin-api/aigc/dify/callback-internal` → 复用 `handleCallback` 唯一写入路径建版本/组包/落包 → 发布翻转入 feed。执行器投递握手成功即留任务 RUNNING 等回调(超 deadline 由 watchdog 收尸兜底),**不在派发路写终态、不走进程内 `chatJson`**(日志 `executor-llm` 对该任务 0 命中即铁证)。
**两条红线(门里逮过的真坑·复用必看)**:
1. **🔴 非 RBAC 的 HTTP 回调路必须自注入系统身份(部署窗口#3 同根因,换路复发)**:worker 是内部服务无登录态,回调走 `@PermitAll` 内网子路由(仿 `SmsCallbackController`/pay-notify 范式解 §6.5 auth 孤儿);但 `@PermitAll` 路在 web 线程无 LoginUser → `DefaultDBFieldHandler` 取不到 userId → `handleCallback` 三表 UPDATE/INSERT 的 `updater/creator` 带 null 直出撞 NOT NULL(`DataIntegrityViolation`,任务卡 RUNNING)。**修法**:内网回调 controller 内自注入系统身份 `LoginUser().setId(0L).setUserType(ADMIN)` 入 `SecurityContextHolder`,`finally` 必 `clearContext()`(镜像 `AigcGenerateExecutor.executeWithSystemIdentity`)。RBAC 的 `/dify/callback` 由登录管理员提供身份故无此坑——**任何新免鉴权回调路(真 worker 3b-B/外部接入)都要补这条**。
2. **🔴 @PermitAll 内网回调路上线前必补服务间签名校验**:本路由仅恃「内网不可外达」兜底,上线前若可被外网路由到,任意请求可伪造回调驱动落包(安全红线);3b-B 真 worker / 外部接入前补 HMAC/共享密钥或 mTLS(与 `/dify/callback`「真 Dify 接入补签名」TODO 同治)。
**stub worker 配方(证管线用,3b-B 换真 worker)**:`game-cloud/scripts/wg1_stub_worker.py`(极简 HTTP,收 job→读固定 pong bundle `game-runtime/games/_wg1-gen/pong/bundle.iife.js` 作 engineBundle→回调;gameConfig 回 §5.2 最小占位 `{templateId,title,theme,engineDriven:true}`,不调 LLM 不打包)。e2e 脚手架:`p3b_scaffold_pong.sql`(建 gameId=9302 四态)→插 generic 任务→回调建新版本→`p3b_publish_flip.sql`(把 feed 翻到回调新版本)→ CDP 复用 `game-studio/evidence/p3-pong/p3-pong.driver.cjs <base> <gameId> <versionId>` 真玩。**配置**:`aigc.executor.worker-url`(空=派发面无端点→generic 判 failed(llm_error),不静默卡死);staging 经 `.env` 注 `AIGC_WORKER_URL`;stub 起 durable 单元 `systemd-run --unit=wg1-stub-worker`。
--- ---
## 目标 ## 目标

View File

@ -7,17 +7,25 @@ import cn.wanxiang.game.module.aigc.convert.task.AigcTaskConvert;
import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO; import cn.wanxiang.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import cn.wanxiang.game.module.aigc.service.callback.DifyCallbackService; import cn.wanxiang.game.module.aigc.service.callback.DifyCallbackService;
import cn.wanxiang.game.module.aigc.service.task.AigcTaskService; import cn.wanxiang.game.module.aigc.service.task.AigcTaskService;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/** /**
@ -61,4 +69,49 @@ public class AdminAigcTaskController {
return success(difyCallbackService.handleCallback(reqVO)); return success(difyCallbackService.handleCallback(reqVO));
} }
/**
* 内网生成 worker 回调(P3 W-G1 派发面·3b-A,2026-06-14)。
*
* 【为何另开本路由(§6.5 auth 孤儿解法)】P3 generic 走「派发外置 worker」范式:worker 是【内部服务】、
* 无 RBAC 登录态 token,无法调上方带 {@code @PreAuthorize('aigc:dify:callback')} 的 /dify/callback。
* 故按本仓既有「外部/内部服务回调」范式(SmsCallbackController 的 {@code @PermitAll}、pay-notify 回调同款)
* 另开本 {@code @PermitAll} 内网回调子路由,复用同一 {@link DifyCallbackService#handleCallback} 唯一写入路径
* (建版本/组包/落包/回填全不变),把鉴权问题收敛为「仅内网可达」。worker 把 §6.1 result-out 经
* {@link DifyCallbackReqVO}(含 engineBundle 字段)POST 到本端点驱动任务终态。
*
* 【安全边界(诚实分界)】本路由跳过 admin RBAC,仅恃【内网不可外达】兜底(MVP staging:worker 与后端
* 同 mini-desktop localhost)。⚠️ 3b-B 真 worker / 未来外部接入前,必须补【服务间签名校验】
* (HMAC/共享密钥或 mTLS),与上方 /dify/callback 的「真 Dify 接入补签名」TODO 同治;上线前若本路由
* 可被外网路由到,须在网关层显式拦截或加签名门,否则任意外部请求可伪造回调驱动落包(安全红线)。
* 【TenantIgnore】worker 无租户上下文;与 SmsCallbackController 同款 {@code @TenantIgnore},任务定位经
* traceId 全局唯一(handleCallback 内 selectByTraceId 不依赖租户上下文)。
*/
@PostMapping("/dify/callback-internal")
@PermitAll
@TenantIgnore
@Operation(summary = "内网生成 worker 完成回调(P3 派发面·@PermitAll 内网)",
description = "P3 generic 派发外置 worker 后,worker 把含 engineBundle 的 §6.1 result-out 经本内网免鉴权"
+ "子路由 POST 回来,复用 handleCallback 唯一写入路径建版本/组包/落包。仅内网可达;真 worker/外部接入前补签名校验。")
public CommonResult<Boolean> difyCallbackInternal(@Valid @RequestBody DifyCallbackReqVO reqVO) {
// 复用唯一写入路径(与 /dify/callback 完全同款 handleCallback,仅鉴权门不同):worker 回调携 engineBundle,
// 经 DifyCallbackTxService.resolveEngineBundleText 取值后 put 进 GamePackage.engineBundle 落包入 feed。
log.info("[aigc-callback-internal] 收到内网 worker 回调 traceId={}, status={}, engineBundleLen={}",
reqVO.getTraceId(), reqVO.getStatus(),
reqVO.getEngineBundle() == null ? 0 : reqVO.getEngineBundle().length());
// 【根因·与部署窗口#3 同源】本路由 @PermitAll、无 RBAC 登录态 → DefaultDBFieldHandler 取不到 userId →
// handleCallback 三表写链(建版本/组包/落包/回填,含受理置 RUNNING)UPDATE/INSERT 的 updater/creator
// 带 null 直出撞 NOT NULL(DataIntegrityViolation,任务卡 RUNNING)。RBAC 的 /dify/callback 由登录管理员
// 提供身份故无此问题;本内网路由须自注入【系统身份 id=0】(审计列落 "0",镜像
// AigcGenerateExecutor.executeWithSystemIdentity 同款,finally 必清理防 web 线程池复用污染)。
LoginUser systemUser = new LoginUser().setId(0L).setUserType(UserTypeEnum.ADMIN.getValue());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(systemUser, null, Collections.emptyList()));
try {
return success(difyCallbackService.handleCallback(reqVO));
} finally {
// web 线程池线程复用:处理完毕(含异常路径)立即清空安全上下文,防系统身份泄漏给同线程后续请求
SecurityContextHolder.clearContext();
}
}
} }

View File

@ -7,6 +7,7 @@ import cn.wanxiang.game.module.aigc.service.executor.AigcGenerateExecutor;
import cn.wanxiang.game.module.aigc.service.executor.ExecutorLlmClient; import cn.wanxiang.game.module.aigc.service.executor.ExecutorLlmClient;
import cn.wanxiang.game.module.aigc.service.executor.GameConfigSchemaValidator; import cn.wanxiang.game.module.aigc.service.executor.GameConfigSchemaValidator;
import cn.wanxiang.game.module.aigc.service.executor.PromptResourceLoader; import cn.wanxiang.game.module.aigc.service.executor.PromptResourceLoader;
import cn.wanxiang.game.module.aigc.service.executor.WorkerDispatchClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@ -73,6 +74,17 @@ public class AigcExecutorConfiguration {
return new ExecutorLlmClient(properties); return new ExecutorLlmClient(properties);
} }
/**
* 外置生成 worker 派发通道(P3 W-G1 派发面·3b-A:§6.5;JDK HttpClient 投递 §6.1 job,仅 generic 路用)
*
* @param properties 执行器配置(worker-url / 派发超时)
* @return 派发通道客户端
*/
@Bean
public WorkerDispatchClient aigcWorkerDispatchClient(AigcExecutorProperties properties) {
return new WorkerDispatchClient(properties);
}
/** /**
* 生成执行器(§5:tick 触发面 + 执行体;构造时执行 §5.2 阈值不等式校验,误配置 fail-fast) * 生成执行器(§5:tick 触发面 + 执行体;构造时执行 §5.2 阈值不等式校验,误配置 fail-fast)
* *
@ -82,6 +94,7 @@ public class AigcExecutorConfiguration {
* @param promptResourceLoader prompt 装载器 * @param promptResourceLoader prompt 装载器
* @param schemaValidator schema 校验器 * @param schemaValidator schema 校验器
* @param llmClient LLM 通道 * @param llmClient LLM 通道
* @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路)
* @return 执行器(@Scheduled 注解由全局调度后处理器消费,fixedDelay 单飞) * @return 执行器(@Scheduled 注解由全局调度后处理器消费,fixedDelay 单飞)
*/ */
@Bean @Bean
@ -90,9 +103,10 @@ public class AigcExecutorConfiguration {
DifyCallbackService difyCallbackService, DifyCallbackService difyCallbackService,
PromptResourceLoader promptResourceLoader, PromptResourceLoader promptResourceLoader,
GameConfigSchemaValidator schemaValidator, GameConfigSchemaValidator schemaValidator,
ExecutorLlmClient llmClient) { ExecutorLlmClient llmClient,
WorkerDispatchClient workerDispatchClient) {
return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService, return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService,
promptResourceLoader, schemaValidator, llmClient, Clock.systemDefaultZone()); promptResourceLoader, schemaValidator, llmClient, workerDispatchClient, Clock.systemDefaultZone());
} }
} }

View File

@ -95,10 +95,43 @@ public class AigcExecutorProperties {
* 支持模板集(默认值取 {@link AigcTemplateConstants#SUPPORTED_TEMPLATE_IDS} 唯一同源, * 支持模板集(默认值取 {@link AigcTemplateConstants#SUPPORTED_TEMPLATE_IDS} 唯一同源,
* 与 AigcTaskServiceImpl.validateTemplateExists 口径一致,避免「提交通过、执行 no_template_match」割裂)。 * 与 AigcTaskServiceImpl.validateTemplateExists 口径一致,避免「提交通过、执行 no_template_match」割裂)。
* M-c 批① 已扩 merge(clicker+merge);批② 扩 idle/tycoon;dodge/runner/match 已降 P1 不进集合 → no_template_match。 * M-c 批① 已扩 merge(clicker+merge);批② 扩 idle/tycoon;dodge/runner/match 已降 P1 不进集合 → no_template_match。
* 不在集合内 → failed + no_template_match(M1)。 * 不在集合内 → failed + no_template_match(M1)。P3 W-G1 点亮 generic(单一通用,默认即 ["generic"])。
*/ */
private List<String> supportedTemplates = new ArrayList<>(AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS); private List<String> supportedTemplates = new ArrayList<>(AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS);
// ===== P3 W-G1 派发面(3b-A,2026-06-14):generic 模板走外置 worker 生成 =====
/**
* 触发派发外置 worker 的模板 id(P3 范式切换边界):仅此 templateId 走「派发 job 给 worker → worker 回调
* 携 engineBundle 入 feed」新范式({@link AigcGenerateExecutor#dispatchGeneric});其余 templateId 一律走旧
* 进程内 LLM 出 GameConfig 路(M-c clicker/merge 零回归硬约束)。默认 {@code "generic"}(O-P3-1 单一通用口径)。
* 空串 = 关闭派发(任何模板都走旧进程内路,回滚用)。
*/
private String dispatchTemplateId = "generic";
/**
* 外置生成 worker 端点 URL(执行器 POST §6.1 job-in JSON 到此地址;worker 异步生成后把 §6.1 result-out
* 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal)。staging 经 application-staging.yaml 占位符注入
* ({@code ${AIGC_WORKER_URL:}});默认空串 = 派发面无端点 → generic 任务 dispatch 即判 failed(llm_error)
* (不烧 LLM、可观测,不静默卡死)。3b-A 指 stub worker(回已知 pong bundle),3b-B 换 L1 真 worker。
*/
private String workerUrl = "";
/**
* 派发 job 的 HTTP 单次超时秒数(仅「投递 job」这一步的连接/响应超时,非生成总时长——生成是 worker 侧
* 异步活、由 worker 回调驱动终态、超 deadline 由 watchdog 收尸兜底)。默认 30s(投递握手足够,避免投递
* 阶段长挂占 tick 线程)。
*/
private Integer workerDispatchTimeoutSeconds = 30;
/**
* worker 回调本服务的 callback URL(写进 §6.1 job.callback.target,worker 据此把 result POST 回来)。
* 默认指向内网免鉴权回调子路由 /admin-api/aigc/dify/callback-internal(@PermitAll,§6.5 auth 孤儿解法:
* worker 是内部服务无 RBAC token,复用 SmsCallbackController/pay-notify 同款 @PermitAll 回调范式,
* 仅内网可达;真 worker/外部接入时另补服务间签名校验)。staging 经占位符 ${AIGC_CALLBACK_URL:...} 可覆盖。
*/
private String callbackUrl = "http://localhost:48080/admin-api/aigc/dify/callback-internal";
/** /**
* 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast) * 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast)
* *

View File

@ -22,6 +22,7 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
@ -87,6 +88,8 @@ public class AigcGenerateExecutor {
private final GameConfigSchemaValidator schemaValidator; private final GameConfigSchemaValidator schemaValidator;
/** LLM 通道(§6.3) */ /** LLM 通道(§6.3) */
private final ExecutorLlmClient llmClient; private final ExecutorLlmClient llmClient;
/** 外置生成 worker 派发通道(P3 W-G1 派发面,仅 generic 模板走,§6.5) */
private final WorkerDispatchClient workerDispatchClient;
/** 墙钟(预算门用;单测注入假时钟验证 M5) */ /** 墙钟(预算门用;单测注入假时钟验证 M5) */
private final Clock clock; private final Clock clock;
@ -104,11 +107,13 @@ public class AigcGenerateExecutor {
* @param promptResourceLoader prompt 契约装载器 * @param promptResourceLoader prompt 契约装载器
* @param schemaValidator schema 校验器 * @param schemaValidator schema 校验器
* @param llmClient LLM 通道 * @param llmClient LLM 通道
* @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路)
* @param clock 墙钟 * @param clock 墙钟
*/ */
public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper, public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper,
DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader, DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader,
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient, Clock clock) { GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
WorkerDispatchClient workerDispatchClient, Clock clock) {
// §5.2 阈值关系铁律:stale×60 > budget + 273,装配期校验(误配置宁可启动失败也不带病收割在飞任务) // §5.2 阈值关系铁律:stale×60 > budget + 273,装配期校验(误配置宁可启动失败也不带病收割在飞任务)
properties.validateThresholds(); properties.validateThresholds();
this.properties = properties; this.properties = properties;
@ -117,6 +122,7 @@ public class AigcGenerateExecutor {
this.promptResourceLoader = promptResourceLoader; this.promptResourceLoader = promptResourceLoader;
this.schemaValidator = schemaValidator; this.schemaValidator = schemaValidator;
this.llmClient = llmClient; this.llmClient = llmClient;
this.workerDispatchClient = workerDispatchClient;
this.clock = clock; this.clock = clock;
// 启动三行自检日志(§12-① 冒烟观察面:执行器已启用 / prompt 资源版本 / key 配置态;严禁打印密钥本身) // 启动三行自检日志(§12-① 冒烟观察面:执行器已启用 / prompt 资源版本 / key 配置态;严禁打印密钥本身)
log.info("[executor-selfcheck] aigc 生成执行器已启用(poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={})", log.info("[executor-selfcheck] aigc 生成执行器已启用(poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={})",
@ -329,7 +335,16 @@ public class AigcGenerateExecutor {
return callbackFailed(task, FailureReasonEnum.CONFIG_INVALID); return callbackFailed(task, FailureReasonEnum.CONFIG_INVALID);
} }
// ----- §5.3-2~5 渲染 → LLM → 解析校验 →(失败重出 1 次)----- // ----- §6.5 派发面分支(P3 W-G1·3b-A):仅 dispatchTemplateId(默认 generic)走「派发外置 worker + 异步等回调」-----
// ⚠️ 范式切换边界:generic 不在进程内 chatJson 出 GameConfig,改组 §6.1 job 投递 worker,worker 异步产
// engineBundle 后经回调入参 POST 回 /dify/callback-internal 驱动终态。其余 templateId(旧四模板若再开放)
// 一律走下方进程内 LLM 出 GameConfig 旧路逐行不变(M-c clicker/merge 零回归硬约束)。
if (StringUtils.hasText(properties.getDispatchTemplateId())
&& properties.getDispatchTemplateId().equals(task.getTemplateId())) {
return dispatchGeneric(task);
}
// ----- §5.3-2~5 渲染 → LLM → 解析校验 →(失败重出 1 次)(进程内旧路:generic 以外模板)-----
String findings = ""; // 首轮空串;重出轮 = schema 校验错误文本灌 findings 槽(clicker-designer.md:23-24 明文) String findings = ""; // 首轮空串;重出轮 = schema 校验错误文本灌 findings 槽(clicker-designer.md:23-24 明文)
JsonNode validConfig = null; JsonNode validConfig = null;
List<String> lastErrors = Collections.emptyList(); List<String> lastErrors = Collections.emptyList();
@ -399,6 +414,85 @@ public class AigcGenerateExecutor {
return invokeCallback(task, reqVO, "succeeded"); return invokeCallback(task, reqVO, "succeeded");
} }
/**
* 派发面(P3 W-G1·3b-A,§6.5):组 §6.1 job-in 投递外置 worker,任务留 RUNNING 异步等回调。
*
* 【与进程内旧路的根本差异】不在进程内 chatJson 出 GameConfig;改为把 brief + 渲染上下文组进 §6.1 job
* POST 给 worker 端点({@link AigcExecutorProperties#getWorkerUrl()})。worker 异步写 GameHostFactory →
* esbuild 打 __GameBundle → 把 §6.1 result-out 经回调入参(含 engineBundle)POST 回
* /admin-api/aigc/dify/callback-internal(@PermitAll 内网回调子路由)→ 复用 handleCallback 唯一写入路径
* 建版本/组包/落包 → 任务终态。本方法只负责「投递」,不写终态(终态由 worker 回调驱动)。
*
* 【投递成功后任务状态】任务保持 RUNNING(processCandidate 的 CAS 已置 1)。worker 回调命中即终态化;
* worker 崩/回调丢/超 deadline → watchdog 收尸为 failed(timeout)(§6.5 deadline 处置,零僵尸)。
*
* 【投递失败处置】worker 端点未配置/HTTP 错/网络异常 → {@link WorkerDispatchClient#dispatch} 返回 false →
* 本方法按 M3 failed(llm_error) 落终态(不烧 LLM、可观测,不静默卡 RUNNING 让 watchdog 兜底超时)。
*
* @param task 已认领的 generic 任务(gameId 已由预检保证非空)
* @return true=投递成功(任务留 RUNNING 等回调,tick 汇总计入「派发」);false=投递失败已落 failed(llm_error)
*/
private boolean dispatchGeneric(AigcTaskDO task) {
// 组渲染上下文(§6.1 renderCtx):template_schema 与服务端校验同源(generic schema 已就位);
// banned_list 用户路径无批内禁重语境置空;findings 首发空(worker 内部重出自管,不经后端重灌)。
String templateSchema;
try {
templateSchema = promptResourceLoader.getTemplateSchemaText(task.getTemplateId());
} catch (Exception e) {
// 资源未就绪/未知模板(自检/预检应已挡,防御):投递前置失败按 config_invalid 处置
log.error("[executor-dispatch] 取 generic schema 失败(按 config_invalid 处置)taskId={}, traceId={}",
task.getId(), task.getTraceId(), e);
return callbackFailed(task, FailureReasonEnum.CONFIG_INVALID);
}
Map<String, Object> renderCtx = new LinkedHashMap<>();
renderCtx.put("template_schema", templateSchema);
renderCtx.put("banned_list", "");
renderCtx.put("findings", "");
// 预算与幂等键(§6.1 budget/idempotency_key/deadline_ms):maxLlmCalls 对齐 ledger BudgetGuard 8 次;
// deadline_ms 取任务级墙钟预算(毫秒),与 watchdog 收尸阈值协同(§6.5 deadline 处置)。
Map<String, Object> budget = new LinkedHashMap<>();
budget.put("maxYuan", 0.15);
budget.put("maxLlmCalls", 8);
String idempotencyKey = task.getTraceId(); // traceId 全链路唯一,作幂等键(同 job 重发不重复产)
// 回调描述(§6.1 callback):type=http,target=内网免鉴权回调子路由(worker 把 result POST 回此地址)。
Map<String, Object> callback = new LinkedHashMap<>();
callback.put("type", "http");
callback.put("target", properties.getCallbackUrl());
// 组 §6.1 job-in(字段/语义逐字对齐 spec §6.1,供 L1/stub worker 不用猜)
Map<String, Object> job = new LinkedHashMap<>();
job.put("job_id", task.getTraceId()); // 贯穿 job↔result 的幂等键(与 idempotency_key 配合)
job.put("tier", "L1"); // 裸路(VERDICT §6-2 framework-light)
job.put("kind", "generate"); // 单局生成
job.put("brief", task.getPrompt() == null ? "" : task.getPrompt()); // 用户一句话创意
job.put("model", properties.getLlmModel()); // bake-off 时参数化(M3/M2.7/DS-V4),worker 调 new-api 用此 model
job.put("budget", budget);
job.put("gameId", task.getGameId()); // 组包元数据(必填,否则后端无法组包)
job.put("templateId", task.getTemplateId()); // generic
job.put("renderCtx", renderCtx);
job.put("idempotency_key", idempotencyKey);
job.put("deadline_ms", (long) properties.getTaskBudgetSeconds() * 1000L); // 任务级墙钟(ms)
job.put("callback", callback);
// traceId 显式带上(worker 回调 reqVO.traceId 必须与本任务一致,handleCallback 据此定位任务);
// 与 job_id 同值(traceId 即贯穿键),冗余携带便于 worker 直接取用、不依赖从 job_id 推。
job.put("traceId", task.getTraceId());
boolean dispatched = workerDispatchClient.dispatch(job);
if (dispatched) {
// 投递成功:任务留 RUNNING 等 worker 回调(终态由回调驱动,不在此写)。tick 汇总以「派发」计数(非 succeeded/failed)。
log.info("[executor-dispatch] generic 任务已派发 worker(留 RUNNING 等回调,终态由回调链驱动)"
+ "taskId={}, traceId={}, gameId={}, model={}", task.getId(), task.getTraceId(),
task.getGameId(), properties.getLlmModel());
return true;
}
// 投递失败:按 M3 failed(llm_error) 落终态(worker 不可达/端点空,不静默卡 RUNNING)
log.error("[executor-dispatch] generic 任务投递失败(worker 不可达/端点未配置),按 llm_error 落终态 taskId={}, traceId={}",
task.getId(), task.getTraceId());
return callbackFailed(task, FailureReasonEnum.LLM_ERROR);
}
/** /**
* 解析 LLM content 并做 schema 校验(F2:非 JSON / 缺 config 视同 schema 失败,进重出轮) * 解析 LLM content 并做 schema 校验(F2:非 JSON / 缺 config 视同 schema 失败,进重出轮)
* *

View File

@ -42,8 +42,16 @@ public final class AigcTemplateConstants {
* 资源 Map 加行 + contracts/templates/&lt;id&gt;.schema.json 就位),白名单口径单点维护不变。 * 资源 Map 加行 + contracts/templates/&lt;id&gt;.schema.json 就位),白名单口径单点维护不变。
* 旧四模板的契约 schema(contracts/templates/{clicker,merge,idle,tycoon}.schema.json)与 prompt 注册表条目按 * 旧四模板的契约 schema(contracts/templates/{clicker,merge,idle,tycoon}.schema.json)与 prompt 注册表条目按
* W-CLEAN 范围(OUT)保留不动,仅本白名单清空使其「不可被生成」。 * W-CLEAN 范围(OUT)保留不动,仅本白名单清空使其「不可被生成」。
*
* 【P3 W-G1 点亮 generic(2026-06-14,3b-A 派发面 plumbing)】新模板体系落地:注册单一通用 templateId
* {@code "generic"}(O-P3-1 单一通用口径,品类区分留 prompt 层)。两处消费点(validateTemplateExists /
* 执行器预检)同步见 generic、不产生「提交通过、执行 no_template_match」割裂。配套:PromptResourceLoader
* .TEMPLATE_RESOURCES 加 generic 行(generic-coder.md + generic.schema.json,brick-2 已就位),
* AigcExecutorProperties.supportedTemplates 默认值随本常量同源。
* ⚠️ generic 走「派发外置 worker 产 engineBundle 经回调入 feed」新范式(执行器 generic 分支 dispatch,
* 见 AigcGenerateExecutor.dispatchGeneric);旧四模板若再开放仍走进程内 LLM 出 GameConfig 旧路,互不影响。
*/ */
public static final List<String> SUPPORTED_TEMPLATE_IDS = List.of(); public static final List<String> SUPPORTED_TEMPLATE_IDS = List.of("generic");
private AigcTemplateConstants() { private AigcTemplateConstants() {
// 纯常量持有类,禁止实例化 // 纯常量持有类,禁止实例化

View File

@ -76,6 +76,13 @@ public class PromptResourceLoader {
m.put("tycoon", new String[]{ m.put("tycoon", new String[]{
"wanxiang-contracts/prompts/04-config/tycoon-designer.md", "wanxiang-contracts/prompts/04-config/tycoon-designer.md",
"wanxiang-contracts/templates/tycoon.schema.json"}); "wanxiang-contracts/templates/tycoon.schema.json"});
// P3 W-G1(2026-06-14,3b-A):generic 通用模板资源——编码 prompt(出 GameHostFactory 源码,非策划出 GameConfig)+
// bundle 元数据 schema(校验 __GameBundle 全局名/体量,非 GameConfig 字段)。brick-2 已就位两文件。
// 注:generic 走派发外置 worker 路,后端不在进程内渲染/喂 LLM 该 prompt;本行的作用是
// ① 让 PromptResourceLoader.isReady() 通过(资源齐全)② schema 进 GameConfigSchemaValidator 缓存(同源就绪)。
m.put("generic", new String[]{
"wanxiang-contracts/prompts/04-config/generic-coder.md",
"wanxiang-contracts/templates/generic.schema.json"});
TEMPLATE_RESOURCES = Collections.unmodifiableMap(m); TEMPLATE_RESOURCES = Collections.unmodifiableMap(m);
} }

View File

@ -0,0 +1,147 @@
package cn.wanxiang.game.module.aigc.service.executor;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* 外置生成 worker 派发通道(HJ-GEN-001 P3 W-G1 派发面 · 3b-A,2026-06-14)
*
* 【职责】把执行器组好的 §6.1 job-in JSON POST 给外置 worker 端点({@code aigc.executor.worker-url})。
* 仅做「投递握手」——投递成功(HTTP 2xx)即返回 true,任务留 RUNNING 等 worker 异步回调;worker 异步
* 生成完把 §6.1 result-out 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal 驱动终态。
* 投递失败(端点空/HTTP 错/网络异常)即返回 false,由调用方按 M3 failed(llm_error) 处置(不烧 LLM、可观测)。
*
* 【为何只投递不等结果(派发面收敛理由,§6.5)】生成是 worker 侧多步 agentic 活(写源码→esbuild 打包→
* 自检→失败重出),不宜让 tick 线程同步长挂;故执行器只「投 job + 异步等回调」,回调命中由现存
* handleCallback 唯一写入路径驱动,超 deadline 由 watchdog 收尸兜底(与 §5.3「下发 worker + 收 result」一致)。
*
* 【设计同源】HTTP 发送抽象复用 {@link ExecutorLlmClient.HttpJsonSender}(单测注入桩驱动、零真实网络,
* 与 ExecutorLlmClient 同款可测性范式);生产用 JDK java.net.http.HttpClient(零新 HTTP 依赖)。
*
* 【Bean 注册铁律(§3)】本类不标 @Component/@Service,只经 AigcExecutorConfiguration @Bean 注册,
* 防组件扫描绕过 aigc.executor.enabled 总开关。
*
* @author 造梦AI(P3 派发面)
*/
@Slf4j
public class WorkerDispatchClient {
/** job JSON 编解码(实例私有,无共享可变状态) */
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 执行器配置(worker-url / 派发超时) */
private final AigcExecutorProperties properties;
/** HTTP 发送实现(生产=JDK HttpClient;单测注入桩,验证投递而不走网络)——复用 ExecutorLlmClient 抽象 */
private final ExecutorLlmClient.HttpJsonSender sender;
/** 生产构造:JDK HttpClient 真实发送(经 AigcExecutorConfiguration @Bean 调用) */
public WorkerDispatchClient(AigcExecutorProperties properties) {
this(properties, defaultSender());
}
/**
* 可注入构造(单测以桩 sender 驱动,零真实网络)
*
* @param properties 执行器配置
* @param sender HTTP 发送实现
*/
public WorkerDispatchClient(AigcExecutorProperties properties, ExecutorLlmClient.HttpJsonSender sender) {
this.properties = properties;
this.sender = sender;
}
/**
* 投递一个 §6.1 job-in 到 worker 端点(仅投递握手,成功即返回,不等生成结果)
*
* @param job §6.1 job-in 字段表(job_id/tier/kind/brief/model/budget/gameId/templateId/renderCtx/
* idempotency_key/deadline_ms/callback)
* @return true=投递成功(HTTP 2xx,任务留 RUNNING 等回调);false=投递失败(端点空/HTTP 错/网络异常,
* 调用方按 M3 failed(llm_error) 处置)
*/
public boolean dispatch(Map<String, Object> job) {
String workerUrl = properties.getWorkerUrl();
if (workerUrl == null || workerUrl.isBlank()) {
// 端点未配置:派发面无落点 → 投递失败(不静默卡死,调用方判 failed 可观测)
log.error("[executor-dispatch] worker 端点未配置(aigc.executor.worker-url 为空),无法派发 generic 任务 job_id={}",
job.get("job_id"));
return false;
}
String body;
try {
body = MAPPER.writeValueAsString(job);
} catch (Exception e) {
// job 序列化理论不可达失败:防御留痕,按投递失败处置
log.error("[executor-dispatch] job 序列化失败 job_id={}", job.get("job_id"), e);
return false;
}
long start = System.currentTimeMillis();
try {
ExecutorLlmClient.SenderResponse resp = sender.send(workerUrl, /*apiKey 派发不带鉴权头*/ "",
body, properties.getWorkerDispatchTimeoutSeconds());
boolean ok = resp.statusCode() >= 200 && resp.statusCode() < 300;
if (ok) {
// 投递成功留痕(外部交互可追溯):job_id/gameId/model + 耗时;任务留 RUNNING 等 worker 回调
log.info("[executor-dispatch] job 投递成功(任务留 RUNNING 等回调)job_id={}, gameId={}, model={}, http={}, 耗时={}ms",
job.get("job_id"), job.get("gameId"), job.get("model"), resp.statusCode(),
System.currentTimeMillis() - start);
} else {
// HTTP 层错误:截响应片段入日志便于排障
log.error("[executor-dispatch] job 投递 HTTP 错误 job_id={}, http={}, body={}, 耗时={}ms",
job.get("job_id"), resp.statusCode(), brief(resp.body()), System.currentTimeMillis() - start);
}
return ok;
} catch (Exception e) {
// 网络超时/连接拒绝等:投递失败(worker 不可达),调用方判 failed(llm_error)
log.error("[executor-dispatch] job 投递通道异常 job_id={}, 耗时={}ms", job.get("job_id"),
System.currentTimeMillis() - start, e);
return false;
}
}
// ============================== 私有工具 ==============================
/**
* 截响应体片段(前 200 字符)入日志,防大响应刷屏
*
* @param body 响应体
* @return 片段文本
*/
private static String brief(String body) {
if (body == null) {
return "";
}
return body.length() <= 200 ? body : body.substring(0, 200);
}
/**
* 生产默认 sender:JDK HttpClient(连接超时 10s;单次请求超时由调用传入派发超时秒数)。
* 与 ExecutorLlmClient.defaultSender 同款,但派发不带 Authorization 头(worker 端点内网、job 无鉴权)。
*
* @return HTTP 发送实现
*/
private static ExecutorLlmClient.HttpJsonSender defaultSender() {
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
return (url, apiKey, jsonBody, timeoutSeconds) -> {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8));
// 派发握手不带 Authorization(worker 端点内网,job 本身无鉴权语义);保留 apiKey 形参兼容抽象签名
if (apiKey != null && !apiKey.isBlank()) {
builder.header("Authorization", "Bearer " + apiKey);
}
HttpResponse<String> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return new ExecutorLlmClient.SenderResponse(response.statusCode(), response.body());
};
}
}

View File

@ -77,6 +77,10 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
@Mock @Mock
private ExecutorLlmClient.HttpJsonSender sender; private ExecutorLlmClient.HttpJsonSender sender;
/** worker 派发 HTTP 发送桩(真实 WorkerDispatchClient 持有它验证「投 job 不走 in-process LLM」,P3 派发面) */
@Mock
private ExecutorLlmClient.HttpJsonSender dispatchSender;
// ============================== 组装与夹具 ============================== // ============================== 组装与夹具 ==============================
/** /**
@ -90,7 +94,12 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
ExecutorLlmClient llmClient = new ExecutorLlmClient(props, sender, millis -> { ExecutorLlmClient llmClient = new ExecutorLlmClient(props, sender, millis -> {
// 单测空 sleeper:免真实等待(退避/节流逻辑仍真实走到) // 单测空 sleeper:免真实等待(退避/节流逻辑仍真实走到)
}); });
return new AigcGenerateExecutor(props, aigcTaskMapper, difyCallbackService, LOADER, VALIDATOR, llmClient, clock); // P3 派发面:本组用例 supportedTemplates=["clicker"](非 generic),均走进程内旧路、不触发派发;
// 故注入「桩 sender 永不被调」的 WorkerDispatchClient 仅为满足构造签名(dispatch 零交互)。
// generic 派发分支的专项覆盖见 testDispatchGeneric_* 用例(显式 setSupportedTemplates(["generic"]))。
WorkerDispatchClient workerDispatchClient = new WorkerDispatchClient(props, dispatchSender);
return new AigcGenerateExecutor(props, aigcTaskMapper, difyCallbackService, LOADER, VALIDATOR,
llmClient, workerDispatchClient, clock);
} }
/** /**
@ -542,4 +551,112 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
assertNull(SecurityFrameworkUtils.getLoginUserId(), "异常路径结束后安全上下文应已清理"); assertNull(SecurityFrameworkUtils.getLoginUserId(), "异常路径结束后安全上下文应已清理");
} }
// ============================== 用例17-19:P3 派发面(generic 走 dispatch + 零回归) ==============================
/** generic 就绪态配置:supportedTemplates=["generic"](点亮 generic)+ worker-url 已配 + dispatchTemplateId 默认 generic */
private static AigcExecutorProperties genericProps() {
AigcExecutorProperties props = new AigcExecutorProperties();
props.setApiKey("test-key");
props.setSupportedTemplates(java.util.List.of("generic")); // 点亮 generic(生产同源常量已 List.of("generic"))
props.setWorkerUrl("http://localhost:9999/wg1/generate"); // 桩 worker 端点(dispatchSender 拦截,零真实网络)
// dispatchTemplateId 默认即 "generic",callbackUrl 默认即内网回调子路由——本组用例用默认值
return props;
}
/** queued generic 任务夹具(generic 模板 + gameId 已挂 + 5 秒前入队) */
private static AigcTaskDO genericQueuedTask(Long id, String traceId) {
AigcTaskDO task = new AigcTaskDO();
task.setId(id);
task.setTraceId(traceId);
task.setTemplateId("generic");
task.setGameId(9302L);
task.setPrompt("做一个弹球小游戏");
task.setStatus(0);
task.setCreateTime(LocalDateTime.now().minusSeconds(5));
task.setTenantId(1L);
return task;
}
/** 把投递响应包成 SenderResponse(worker 投递握手成功=HTTP 202) */
private static ExecutorLlmClient.SenderResponse dispatchAccepted() {
return new ExecutorLlmClient.SenderResponse(202, "{\"accepted\":true}");
}
@Test
void testDispatchGeneric_postsJobNotInProcessLlm() throws Exception {
// 准备:一条 generic queued 任务;worker 投递桩返回 202(投递成功)
AigcTaskDO task = genericQueuedTask(701L, "aigc-gen-1");
when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task));
when(aigcTaskMapper.claimQueuedTask(701L)).thenReturn(true);
when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt())).thenReturn(dispatchAccepted());
newExecutor(genericProps(), Clock.systemDefaultZone()).runOneTick();
// 断言①:走派发——POST job 给 worker 端点恰 1 次,且 body 是 §6.1 job-in 形态(含 brief/gameId/templateId/callback)
ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(dispatchSender).send(urlCaptor.capture(), anyString(), bodyCaptor.capture(), anyInt());
assertEquals("http://localhost:9999/wg1/generate", urlCaptor.getValue());
com.fasterxml.jackson.databind.JsonNode job = JSON.readTree(bodyCaptor.getValue());
assertEquals("aigc-gen-1", job.path("job_id").asText());
assertEquals("generate", job.path("kind").asText());
assertEquals("做一个弹球小游戏", job.path("brief").asText());
assertEquals(9302, job.path("gameId").asInt());
assertEquals("generic", job.path("templateId").asText());
assertEquals("aigc-gen-1", job.path("traceId").asText());
assertEquals("http", job.path("callback").path("type").asText());
assertTrue(job.path("callback").path("target").asText().contains("/dify/callback-internal"),
"callback.target 应指向内网回调子路由");
assertTrue(job.path("renderCtx").path("template_schema").asText().contains("__GameBundle"),
"renderCtx.template_schema 应为 generic bundle 元数据 schema(含 __GameBundle 字面量)");
// 断言②:未走 in-process LLM(关键·范式切换)——sender(LLM 通道)零交互
verifyNoInteractions(sender);
// 断言③:任务留 RUNNING 等回调——执行器不在派发路写终态(零 handleCallback)
verifyNoInteractions(difyCallbackService);
}
@Test
void testDispatchGeneric_dispatchFailedMarksLlmError() throws Exception {
// 准备:worker 投递桩抛网络异常(worker 不可达)→ 投递失败
AigcTaskDO task = genericQueuedTask(702L, "aigc-gen-2");
when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task));
when(aigcTaskMapper.claimQueuedTask(702L)).thenReturn(true);
when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt()))
.thenThrow(new IOException("连接拒绝(worker 不可达·单测桩)"));
newExecutor(genericProps(), Clock.systemDefaultZone()).runOneTick();
// 断言:投递失败 → failed(llm_error) 落终态(不静默卡 RUNNING);仍零 in-process LLM
ArgumentCaptor<DifyCallbackReqVO> captor = ArgumentCaptor.forClass(DifyCallbackReqVO.class);
verify(difyCallbackService).handleCallback(captor.capture());
assertEquals("failed", captor.getValue().getStatus());
assertEquals("llm_error", captor.getValue().getFailureReason());
verifyNoInteractions(sender);
}
@Test
void testZeroRegression_clickerStillInProcessNotDispatched() throws Exception {
// 零回归硬约束:dispatchTemplateId=generic 时,clicker 任务仍走进程内 LLM 旧路、绝不派发。
// 配置同时点亮 clicker+generic,提交一条 clicker 任务 → 必走 in-process(sender 被调),dispatchSender 零交互。
AigcExecutorProperties props = genericProps();
props.setSupportedTemplates(java.util.List.of("clicker", "generic")); // 同时点亮两者
AigcTaskDO task = queuedTask(703L, "aigc-clk-1"); // clicker 任务(夹具 templateId=clicker)
when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task));
when(aigcTaskMapper.claimQueuedTask(703L)).thenReturn(true);
when(sender.send(anyString(), anyString(), anyString(), anyInt()))
.thenReturn(llmOk(designContent(10, "奶茶店结账高峰")));
when(difyCallbackService.handleCallback(any(DifyCallbackReqVO.class))).thenReturn(true);
newExecutor(props, Clock.systemDefaultZone()).runOneTick();
// 断言①:clicker 走进程内 LLM(sender 被调 1 次)+ 正常成功回调(handleCallback succeeded)
verify(sender).send(anyString(), anyString(), anyString(), anyInt());
ArgumentCaptor<DifyCallbackReqVO> captor = ArgumentCaptor.forClass(DifyCallbackReqVO.class);
verify(difyCallbackService).handleCallback(captor.capture());
assertEquals("succeeded", captor.getValue().getStatus());
assertEquals("clicker", captor.getValue().getTemplateId());
// 断言②:clicker 绝不派发——worker 派发通道零交互(零回归核心证据)
verifyNoInteractions(dispatchSender);
}
} }

View File

@ -0,0 +1,70 @@
-- ============================================================================
-- P3 canary 3a·四态脚手架(pong 引擎游戏入 feed 真玩)—— adapt 自 p2_scaffold_4state.sql
-- 缘由:P3 把 L1 便宜模型生成的 pong 游戏经后端入 feed、真人浏览器真玩(护城河可演示)。
-- 复用 P2 灌库路,新建一款已发布游戏 gameId=9301 供 p3_seed_pong.mjs 覆写其运行包 engineBundle=pong bundle。
-- (pong bundle 与 P2 ref bundle 同构:iife 全局 __GameBundle,含 bootGameHost/GameHostFactory,引擎真入包。)
-- 四态:game_project.status=4(PUBLISHED) ∧ game_version.status=3 ∧ game_runtime_package.status=1 ∧ game_feed_rank 行
-- ID:gameId=9301 / versionId=93010 / pkgId=93010 / feedRankId=93010(高位区,与 9201/9202 零碰撞)
-- engineBundle 由后续 p3_seed_pong.mjs 9301 覆写 package_json(本脚本先建空 package_json 占位行)
-- 幂等:先 DELETE 同 ID 行再 INSERT(纯重建,可重复执行)
-- tenant:staging application-staging.yaml tenant.enable=false → tenant_id=0 即可
-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
-- ============================================================================
USE `ruoyi-vue-pro`;
-- charset 防御(与 seed 脚本同口径):强制 utf8mb4,避免中文 title 被客户端默认 latin1 双编码
SET NAMES utf8mb4;
START TRANSACTION;
-- 清旧(幂等重建)
DELETE FROM game_feed_rank WHERE game_id = 9301;
DELETE FROM game_runtime_package WHERE game_id = 9301;
DELETE FROM game_version WHERE game_id = 9301;
DELETE FROM game_project WHERE id = 9301;
-- ① project:已发布(status=4),current_version_id 指向 93010,launch_zone_id 留 NULL(→feed zone 0)
INSERT INTO game_project
(id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
status, current_version_id, launch_zone_id, featured, play_count, like_count,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(9301, 1, '弹球对战(Pong·引擎真渲)', 'P3 canary:便宜模型生成的 pong 经 bootGameHost 引擎真渲入 feed 真玩',
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
'P3,engine,pong,wg1-gen', 'pong', 'all',
4, 93010, NULL, b'0', 0, 0,
'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0);
-- ② version:已发布(status=3),package_url 空(走 DB-manifest 端点),checksum 先占位(seed 脚本覆写运行包不动版本)
INSERT INTO game_version
(id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
status, creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(93010, 9301, 1, NULL, '', 0, '',
3, 'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0);
-- ③ runtime_package:先建 status=1 行 + 空 package_json 占位(engineBundle 由 seed 脚本 UPDATE 覆写本行)
-- sandbox_attr/allow_origins 取既有发布默认;package_url 空=DB-manifest 内嵌路
INSERT INTO game_runtime_package
(id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
bundle_size, checksum, sandbox_attr, allow_origins, status,
creator, create_time, updater, update_time, deleted, tenant_id, package_json)
VALUES
(93010, 9301, 93010, 'pong', '', 'index.html', '1.0.0', 'eager',
0, '', 'allow-scripts', '', 1,
'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0, NULL);
-- ④ feed_rank:zone_id=0(混合流) + status=1 入流 + sort_score 给正值(置流首便于 CDP 真玩定位)
INSERT INTO game_feed_rank
(id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(93010, 9301, 93010, 0, 90.0000, 10.0000, 100.0000, 1, 1,
'p3-scaffold-pong', NOW(), 'p3-scaffold-pong', NOW(), b'0', 0);
COMMIT;
-- 回读四态
SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=9301
UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=93010
UNION ALL SELECT 'runtime_pkg', id, status, version_id FROM game_runtime_package WHERE id=93010
UNION ALL SELECT 'feed_rank', id, status, zone_id FROM game_feed_rank WHERE id=93010;

View File

@ -0,0 +1,171 @@
#!/usr/bin/env node
/**
* P3 canary 3a 灌库桩 —— adapt 自 p2_seed_engine_bundle.mjs
* ============================================================================
* 【目标】把 L1 便宜模型生成的 pong 游戏 bundle 文本,灌进已发布 version(gameId=9301)的
* package_json.engineBundle,使 pong 经 feed 通用宿主 bootGameHost 引擎真渲、真人浏览器真玩。
* (护城河可演示:便宜模型生成的游戏在真 feed 被真人玩。)
*
* 【与 P2 ref 灌库桩的差异(adapt 点)】
* - gameId 缺省 9301(p3_scaffold_pong.sql 建的脚手架游戏;P2 是 9201/ref);
* - bundle 路径指向 pong(game-runtime/games/_wg1-gen/pong/bundle.iife.js;P2 是 wanglanmei-ref/dist/ref-bundle.iife.js);
* - templateId/title/gameConfig 改 pong(前端宿主分流只看 pkg.engineBundle 有无,不看 templateId,此处仅元数据标识)。
* 其余防坑逻辑(utf8mb4 charset / checksum 自洽 / versionId 占位回填 / __GameBundle 全局名硬校验)逐字保留。
*
* 【依赖的已发布游戏】本桩零新建、仅覆写某已发布 version 的 package_json,故须先跑同目录
* p3_scaffold_pong.sql 建四态脚手架(gameId=9301: project=4 ∧ version=3 ∧ pkg=1 ∧ feed_rank)。
*
* 【完整性校验链不变量(防 demo 门③依赖)】
* 宿主取包做 sha256(manifest 端点响应原文) == game_runtime_package.checksum 列。
* 故 UPDATE 用 checksum = LOWER(SHA2(package_json,256)) 对刚写入的同一文本求值 → 列值与所存文本字节恒一致,
* 宿主 crypto.subtle 校验必过、不触 demo 兜底。
*
* 【用法(mini-desktop staging)】
* 0) 建四态脚手架:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 \
* -uroot -p"$MYSQL_ROOT_PASSWORD" < game-cloud/scripts/p3_scaffold_pong.sql (建 gameId=9301)
* 1) 生成 SQL:node game-cloud/scripts/p3_seed_pong.mjs [gameId] > /tmp/p3_seed_pong.sql
* (gameId 缺省 9301;bundle 路径固定 games/_wg1-gen/pong/bundle.iife.js)
* 2) 应用:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < /tmp/p3_seed_pong.sql
* (⚠️ --default-character-set=utf8mb4 必带:客户端默认 latin1 会把 UTF-8 bundle/中文 title 双编码,
* 致列字节 ≠ checksum 计算字节 → manifest sha256 不一致 → 宿主校验失败 → demo 兜底;SQL 头亦自带 SET NAMES 双保险)
* 3) 重跑安全(幂等):再次执行步骤 1+2 即可(纯 UPDATE 覆写,无副作用)。
*
* @author 造梦AI(P3 canary)
*/
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ---------- 常量(与 P2 冻结契约逐字一致)----------
/** 冻结:iife 顶层全局名,宿主从 window.__GameBundle 取 GameHostFactory/bootGameHost(三轨此名必须逐字一致) */
const FROZEN_GLOBAL_NAME = '__GameBundle';
/** pong bundle 固定产物路径——esbuild --global-name=__GameBundle 出(W-G1 生成 worker 产出,九门已验)。 */
const PONG_BUNDLE_REL = '../../game-runtime/games/_wg1-gen/pong/bundle.iife.js';
/** checksum 自指消解占位:64 位 '0'(与真实 sha256 hex 等长,对齐 DifyCallbackTxService.CHECKSUM_PLACEHOLDER) */
const CHECKSUM_PLACEHOLDER = '0'.repeat(64);
/** meta.cover 占位(1px 透明 gif data URI,沿后端组包同款,满足契约 format=uri) */
const COVER_PLACEHOLDER =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
/** pong 游戏标识(元数据;前端宿主分流不依赖此值,仅展示/契约 required 用) */
const PONG_TITLE = '弹球对战(Pong·引擎真渲)';
const PONG_TEMPLATE_ID = 'pong';
// ---------- 入参 ----------
const __dirname = dirname(fileURLToPath(import.meta.url));
// gameId 缺省 9301(p3_scaffold_pong.sql 建的脚手架游戏);运行时可传第一个 CLI 参数覆盖
const gameId = String(process.argv[2] || '9301').trim();
// ---------- 步骤①:读 pong bundle 文本 + 校验全局名 ----------
const bundlePath = resolve(__dirname, PONG_BUNDLE_REL);
let engineBundle;
try {
engineBundle = readFileSync(bundlePath, 'utf8');
} catch (e) {
// 读不到产物即写链断裂:显式失败(不灌空桩)
console.error(`[p3-seed-pong][FATAL] 读不到 pong bundle:${bundlePath}\n` +
` 请先在 game-runtime 跑 W-G1 生成 worker 产出该文件。原始错误:${e.message}`);
process.exit(1);
}
// 全局名硬校验:bundle 必须以 FROZEN_GLOBAL_NAME(__GameBundle)导出 iife,否则宿主取不到 factory、真接线必 fail。
if (!engineBundle.includes(FROZEN_GLOBAL_NAME)) {
console.error(`[p3-seed-pong][FATAL] pong bundle 未含冻结全局名 ${FROZEN_GLOBAL_NAME}。\n` +
` 须由 W-G1 产线以 esbuild --global-name=${FROZEN_GLOBAL_NAME} 重建 ${bundlePath}。\n` +
` 当前不灌库(避免种入宿主取不到 factory 的死卡)。`);
process.exit(2);
}
// ---------- 步骤②:组 GamePackage JSON(含 engineBundle;键序对齐后端 buildGamePackage)----------
// 用「保插入序」的对象逐键放入,序列化采用紧凑无空格(与后端 Jackson 默认无缩进口径一致,便于字节对齐)。
function buildPackage(versionPlaceholder) {
const pkg = {};
pkg.schemaVersion = '1.0';
pkg.gameId = String(gameId);
pkg.versionId = versionPlaceholder; // SQL 端 REPLACE 回填真实 versionId(见下)
pkg.templateId = PONG_TEMPLATE_ID; // pong 标识(过渡口径,前端分流不依赖)
pkg.gameConfig = { templateId: PONG_TEMPLATE_ID, title: PONG_TITLE };
pkg.assets = [];
// manifest:bundleSize/checksum 先占位(内层自述,非宿主校验面);保留三键齐全满足契约 required
pkg.manifest = {
runtimeVersion: '1.0.0',
entry: 'index.html',
preloadPolicy: 'eager',
bundleSize: 0,
checksum: CHECKSUM_PLACEHOLDER,
};
pkg.meta = {
title: PONG_TITLE,
summary: 'P3 canary:便宜模型生成的 pong 经 bootGameHost 引擎真渲入 feed 真玩',
cover: COVER_PLACEHOLDER,
ageRating: 'all',
};
// engineBundle 末位放入(落 checksum 之内,由整包 sha256 覆盖;与后端 put 落点同义)
pkg.engineBundle = engineBundle;
return pkg;
}
// 内层 manifest.checksum 自指消解:先以占位序列化 T0 → C=sha256(T0) → 写回得 T1(包内自述用,宿主用外层列值)。
const VID_PLACEHOLDER = '__VERSION_ID__';
const t0 = JSON.stringify(buildPackage(VID_PLACEHOLDER));
const innerChecksum = createHash('sha256').update(t0, 'utf8').digest('hex');
const pkgT1 = buildPackage(VID_PLACEHOLDER);
pkgT1.manifest.checksum = innerChecksum;
const t1 = JSON.stringify(pkgT1);
// ---------- 步骤③:转义为 SQL 字符串字面量(MySQL:转义 \ 和 ') ----------
function sqlQuote(text) {
return "'" + text.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
}
const t1Sql = sqlQuote(t1);
// ---------- 步骤④:产出幂等 UPDATE SQL ----------
const sql = `-- ============================================================================
-- P3 canary 3a 灌库桩 SQL(pong 引擎游戏)—— 由 game-cloud/scripts/p3_seed_pong.mjs 生成
-- 目标 gameId=${gameId};灌入 pong 引擎 bundle 到其当前生效版本的 package_json.engineBundle
-- 应用:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
-- 幂等:纯 UPDATE,可重复执行
-- ============================================================================
USE \`ruoyi-vue-pro\`;
-- charset 防御:强制连接 charset=utf8mb4,否则客户端默认 latin1 把 UTF-8 bundle/中文 title 双编码落 utf8mb4 列
-- → 列字节 ≠ SET @pj 计算 checksum 的字节 → stored checksum 与 manifest 端点 sha256(原文) 不一致
-- → 宿主 crypto.subtle 校验必失败 → demo 兜底 → 引擎永不渲。自带 SET NAMES 不依赖调用方加参数。
SET NAMES utf8mb4;
-- ① 解析该 gameId 当前生效版本 ID(game_project.current_version_id → game_version.id)
SET @gid := ${Number(gameId)};
SET @vid := (SELECT current_version_id FROM game_project WHERE id = @gid AND deleted = b'0' LIMIT 1);
-- 防御:版本未解析(游戏不存在/未设当前版本)则报错中止,不盲写
SET @ok := IF(@vid IS NULL, (SELECT CONCAT('FATAL: gameId=', @gid, ' 无 current_version_id,先跑 p3_scaffold_pong.sql') ), 'ok');
SELECT @vid AS resolved_version_id, @ok AS precheck;
-- ② 覆写该版本运行包行:package_json(versionId 占位回填真实 @vid)+ 自洽 checksum/bundle_size + status=1
-- 把终文一次性装入会话变量 @pj,避免单 UPDATE 内联 3× bundle(~265KB) 撞 max_allowed_packet
SET @pj := REPLACE(${t1Sql}, '${VID_PLACEHOLDER}', CAST(@vid AS CHAR));
START TRANSACTION;
UPDATE game_runtime_package
SET
package_json = @pj,
checksum = LOWER(SHA2(@pj, 256)),
bundle_size = LENGTH(@pj),
status = 1,
updater = 'p3-seed-pong',
update_time = NOW()
WHERE version_id = @vid AND deleted = b'0';
COMMIT;
-- ③ 回读核对(应见 status=1、checksum 64 位、package_json 含 engineBundle/__GameBundle)
SELECT version_id, status, checksum, bundle_size,
(package_json LIKE '%"engineBundle"%') AS has_engine_bundle_key,
(package_json LIKE '%${FROZEN_GLOBAL_NAME}%') AS has_frozen_global_name
FROM game_runtime_package WHERE version_id = @vid AND deleted = b'0';
`;
// 产出到 stdout(调用方重定向到 .sql 文件)
process.stdout.write(sql);
// 诊断信息走 stderr(不污染 stdout 的纯 SQL)
console.error(`[p3-seed-pong] gameId=${gameId} bundle=${bundlePath} ` +
`bundleBytes=${Buffer.byteLength(engineBundle, 'utf8')} ` +
`pkgJsonBytes(占位态)=${Buffer.byteLength(t1, 'utf8')} innerChecksum=${innerChecksum}`);

View File

@ -0,0 +1,50 @@
-- ============================================================================
-- P3·3b-A 派发面 e2e·发布翻转(把 gameId=9302 的 feed 卡翻到【回调链新建的】version)
-- 缘由:回调链 handleCallback 建【新】version(N)+runtime_package(engineBundle=stub 回的 pong),但
-- 新 runtime_package 落库 status=0(未发布),且 project.current_version_id/feed_rank 仍指占位 93020。
-- 本脚本把三处翻到回调新版本 N,使 feed 服务【回调链产物】(=派发面真产物),证 plumbing 闭环。
-- 自动选 N:gameId=9302 下 gen_task_id 非空(=回调建的,占位 93020 的 gen_task_id 为 NULL)的最新 version。
-- 幂等:纯 UPDATE 翻指针,可重复执行(再跑选同一 N)。
-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
-- ============================================================================
USE `ruoyi-vue-pro`;
SET NAMES utf8mb4;
START TRANSACTION;
-- 选回调新建的最新 version N(gen_task_id 非空 = 回调链建的;占位 93020 gen_task_id 为 NULL 被排除)
SET @vid := (SELECT id FROM game_version
WHERE game_id = 9302 AND gen_task_id IS NOT NULL AND deleted = b'0'
ORDER BY id DESC LIMIT 1);
-- 防御:未找到回调新版本则不翻(保持占位态,避免把 feed 指向不存在版本)
-- (@vid 为 NULL 时下方 UPDATE 的 WHERE 不命中任何行,安全空操作)
-- ① 把回调新版本对应的 runtime_package 发布(status 0→1);占位 93020 包仍在但不再被 feed 指
UPDATE game_runtime_package SET status = 1, updater = 'p3b-flip', update_time = NOW()
WHERE game_id = 9302 AND version_id = @vid AND @vid IS NOT NULL;
-- ② project.current_version_id 指向回调新版本 N
UPDATE game_project SET current_version_id = @vid, updater = 'p3b-flip', update_time = NOW()
WHERE id = 9302 AND @vid IS NOT NULL;
-- ③ feed_rank.version_id 指向回调新版本 N(feed 卡 /play 取此 version 的包 = 回调产物 pong)
UPDATE game_feed_rank SET version_id = @vid, updater = 'p3b-flip', update_time = NOW()
WHERE game_id = 9302 AND @vid IS NOT NULL;
COMMIT;
-- 回读:翻转后的指针 + 新版本运行包态(@vid 即 feed 现服务的 versionId,供 CDP driver 用)
SELECT @vid AS flipped_version_id;
SELECT 'project.current_version' k, current_version_id v FROM game_project WHERE id=9302
UNION ALL SELECT 'feed_rank.version', version_id FROM game_feed_rank WHERE game_id=9302
UNION ALL SELECT 'new_pkg.status', status FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid
UNION ALL SELECT 'new_pkg.bundleSize', bundle_size FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid;
-- 校验 engineBundle 真入包(LENGTH>0 = 回调把 pong bundle 落进 package_json)
SELECT 'engineBundle_in_pkg_len' k,
CHAR_LENGTH(JSON_UNQUOTE(JSON_EXTRACT(package_json, '$.engineBundle'))) v
FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid;
-- 校验 checksum 自洽(列值 == 对所存 package_json 重算的 sha256)——宿主完整性校验链不变量
SELECT 'checksum_selfconsistent' k,
(checksum = LOWER(SHA2(package_json, 256))) v
FROM game_runtime_package WHERE game_id=9302 AND version_id=@vid;

View File

@ -0,0 +1,71 @@
-- ============================================================================
-- P3·3b-A 派发面 e2e·四态脚手架(gameId=9302)—— adapt 自 p3_scaffold_pong.sql / p2_scaffold_4state.sql
-- 缘由:3b-A 证「派发→stub worker→回调→buildGamePackage→新版本→入 feed」全 plumbing。
-- 与 3a 直灌库桩(9301)不同:3b-A 的 engineBundle 由【回调链 handleCallback】产出(非 SQL 直写)。
-- 本脚本只建一款【已发布 project(gameId=9302)+ 占位 version(93020)+ feed_rank】,供:
-- ① 提交 generic 任务携 gameId=9302(project 须先存在);
-- ② 回调链建【新】version(N)+runtime_package(engineBundle=stub 回的 pong);
-- ③ 跑 p3b_publish_flip.sql <N> 把 project.current_version_id/feed_rank 翻到新版本 N → feed 服务回调产物。
-- 四态:game_project.status=4(PUBLISHED) ∧ game_version.status=3 ∧ game_runtime_package.status=1 ∧ game_feed_rank 行
-- ID:gameId=9302 / 占位 versionId=93020 / pkgId=93020 / feedRankId=93020(高位区,与 9201/9301 零碰撞)
-- 占位 version 93020 的 package_json 为空(feed 卡先有占位包,翻到回调新版本 N 后服务真 pong)。
-- 幂等:先 DELETE 同 gameId/占位 ID 行再 INSERT(纯重建,可重复执行)。注意:不删回调新建的 version(N≠93020)。
-- tenant:staging tenant.enable=false → tenant_id=0
-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
-- ============================================================================
USE `ruoyi-vue-pro`;
SET NAMES utf8mb4;
START TRANSACTION;
-- 清旧(幂等重建;删该 gameId 全部 version/package/feed_rank,含上轮回调新建的——重跑前清场)
DELETE FROM game_feed_rank WHERE game_id = 9302;
DELETE FROM game_runtime_package WHERE game_id = 9302;
DELETE FROM game_version WHERE game_id = 9302;
DELETE FROM game_project WHERE id = 9302;
-- ① project:已发布(status=4),current_version_id 先指占位 93020(flip 后改指回调新版本 N)
INSERT INTO game_project
(id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
status, current_version_id, launch_zone_id, featured, play_count, like_count,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(9302, 1, '弹球(generic 派发·回调入feed)', 'P3 3b-A 派发面 e2e:generic 任务派发 stub worker 回 pong bundle 经回调链入 feed',
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
'P3,3b-A,dispatch,generic,pong', 'generic', 'all',
4, 93020, NULL, b'0', 0, 0,
'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0);
-- ② 占位 version:已发布(status=3),仅供脚手架四态成立;回调会另建新 version(N)
INSERT INTO game_version
(id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
status, creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(93020, 9302, 1, NULL, '', 0, '',
3, 'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0);
-- ③ 占位 runtime_package:status=1 + 空 package_json(占位包;flip 前 feed 卡指它,flip 后指回调新包)
INSERT INTO game_runtime_package
(id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
bundle_size, checksum, sandbox_attr, allow_origins, status,
creator, create_time, updater, update_time, deleted, tenant_id, package_json)
VALUES
(93020, 9302, 93020, 'generic', '', 'index.html', '1.0.0', 'eager',
0, '', 'allow-scripts', '', 1,
'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0, NULL);
-- ④ feed_rank:zone_id=0(混合流) + status=1 入流(flip 后把 version_id 改指回调新版本 N)
INSERT INTO game_feed_rank
(id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(93020, 9302, 93020, 0, 0.0000, 0.0000, 0.0000, 0, 1,
'p3b-scaffold', NOW(), 'p3b-scaffold', NOW(), b'0', 0);
COMMIT;
-- 回读四态
SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=9302
UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=93020
UNION ALL SELECT 'runtime_pkg', id, status, version_id FROM game_runtime_package WHERE id=93020
UNION ALL SELECT 'feed_rank', id, status, version_id FROM game_feed_rank WHERE id=93020;

View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WG1 stub worker(P3 W-G1 派发面·3b-A,2026-06-14)——证全 plumbing 用的极简桩 worker。
【用途】把 P3 派发面端到端打通:执行器 POST §6.1 job → 本 stub 收 job → 读已知 pong bundle 作 engineBundle →
把 §6.1 result-out 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal(@PermitAll 内网回调子路由)→
后端 handleCallback 建版本/组包/落包入 feed。**不调任何 LLM、不打包**——只回固定 pong bundle,专证管线。
真 worker(L1 run.py:裸 openai SDK 写 GameHostFactory → esbuild 打 __GameBundle → 自检)留 3b-B。
【与 §6.1 接缝的关系】本 stub 实现 §6.1 job-in(收)/ result-out(回)两个 JSON 契约:
- 收 job:解 job_id/brief/gameId/templateId/callback.target(其余字段透传/忽略)。
- 回 result:但回调入参形态对齐【后端 DifyCallbackReqVO】(traceId/status/templateId/gameConfig/engineBundle),
而非直接回 §6.1 result-out 全形态——因为 3b-A 复用 HTTP 回调路(worker→/dify/callback-internal),
后端回调入参 VO 即落点;§6.1 result-out 的 selfCheck/costYuan 等字段是 worker↔pipeline「队列路」语义,
HTTP 回调路下由后端从 DifyCallbackReqVO 取 engineBundle(resolveEngineBundleText)落包,二者等价。
【gameConfig 占位(§5.2 占位框)】engine 路下游戏逻辑全在 engineBundle 内,gameConfig 仍不可空(GamePackage
required + additionalProperties:false);故回最小合法占位 {templateId, title, theme, engineDriven:true}。
【鉴权(§6.5 auth 孤儿解法)】worker 是内部服务无 RBAC token,回调走后端新开的 @PermitAll 内网回调子路由
/admin-api/aigc/dify/callback-internal(复用 handleCallback 唯一写入路径)。本 stub 因此【不带 Authorization 头】。
【运行(mini-desktop)】
python3 game-cloud/scripts/wg1_stub_worker.py [--port 9301] \
[--bundle game-runtime/games/_wg1-gen/pong/bundle.iife.js] \
[--callback http://localhost:48080/admin-api/aigc/dify/callback-internal]
执行器 application-staging.yaml 配 aigc.executor.worker-url=http://localhost:9301/wg1/generate 指向本 stub。
@author 造梦AI(P3 派发面·stub)
"""
import argparse
import json
import os
import sys
import threading
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ---------- 默认常量(mini-desktop staging 口径)----------
# pong bundle 固定产物(brick-2 已放此路径,267KB iife 顶层全局名 __GameBundle)
DEFAULT_BUNDLE = "game-runtime/games/_wg1-gen/pong/bundle.iife.js"
# 内网免鉴权回调子路由(后端 AdminAigcTaskController.difyCallbackInternal,@PermitAll)
DEFAULT_CALLBACK = "http://localhost:48080/admin-api/aigc/dify/callback-internal"
DEFAULT_PORT = 9301
def log(msg):
"""简单带前缀日志(stdout,便于 nohup 落盘排障)。"""
print(f"[wg1-stub-worker] {msg}", flush=True)
class StubWorkerState:
"""进程级状态(bundle 文本一次读入常驻 + 配置)。"""
def __init__(self, bundle_text, callback_url):
self.bundle_text = bundle_text # pong bundle 全文(engineBundle 真值)
self.callback_url = callback_url # 回调子路由 URL
def build_callback_payload(job, bundle_text):
"""
据收到的 §6.1 job 组后端回调入参(DifyCallbackReqVO 形态)。
:param job: 收到的 §6.1 job-in(dict)
:param bundle_text: pong bundle 全文(engineBundle 真值)
:return: 回调入参 dict(traceId/status/templateId/gameConfig/engineBundle)
"""
trace_id = job.get("traceId") or job.get("job_id") # traceId 贯穿全链(与 job_id 同值)
template_id = job.get("templateId") or "generic"
brief = job.get("brief") or ""
# gameConfig 最小合法占位(§5.2):engine 路承载 meta,非填参玩法。title 由 brief 派生(截断防超 META_TITLE_MAX)。
title = (brief[:20] if brief else "弹球(stub)")
game_config = {
"templateId": template_id, # 须与包级 templateId 一致(validateSucceededPayload 一致性校验)
"title": title, # 供 meta 段派生 cover/summary
"theme": "stub-pong", # 占位主题
"engineDriven": True, # 标记本款走引擎 bundle 路(非填参 GameConfig)
}
return {
"traceId": trace_id,
"status": "succeeded",
"templateId": template_id,
"gameConfig": game_config,
"engineBundle": bundle_text, # 回调入参携 pong bundle 全文 → 后端 resolveEngineBundleText 取值落包
"assets": [],
# qualityScore/failureReason 省略(succeeded 路不需要)
}
def post_callback(callback_url, payload):
"""
把回调入参 POST 回后端内网回调子路由(不带 Authorization;@PermitAll 内网可达)。
:param callback_url: 回调 URL
:param payload: 回调入参 dict
:return: (http_status, body_text)
"""
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
callback_url, data=data, method="POST",
headers={"Content-Type": "application/json", "tenant-id": "0"}, # tenant-id 头兜底(@TenantIgnore 已忽略,带上无害)
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8", "replace")
return resp.status, body
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace") if e.fp else ""
return e.code, body
except Exception as e: # 网络异常:返回 -1 + 异常文本(不抛,便于日志记真因)
return -1, f"{type(e).__name__}: {e}"
def handle_job_async(state, job):
"""
异步处理一个 job(读 bundle → 组回调入参 → POST 回调)。
放后台线程,模拟「worker 异步生成、执行器投递握手后即返回、任务留 RUNNING 等回调」的真实时序。
"""
trace_id = job.get("traceId") or job.get("job_id")
game_id = job.get("gameId")
log(f"开始处理 job trace_id={trace_id}, gameId={game_id}, bundleLen={len(state.bundle_text)}")
payload = build_callback_payload(job, state.bundle_text)
status, body = post_callback(state.callback_url, payload)
# 截断 body 防刷屏
body_brief = body[:300] if body else ""
log(f"回调完成 trace_id={trace_id}, callbackHttp={status}, body={body_brief}")
def make_handler(state):
"""工厂:绑定 state 的 HTTP handler 类。"""
class Handler(BaseHTTPRequestHandler):
# 静默默认访问日志(用自定义 log)
def log_message(self, fmt, *args):
return
def _send_json(self, code, obj):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
# 健康探针:GET /health → 200
if self.path.rstrip("/") in ("/health", "/wg1/health"):
self._send_json(200, {"ok": True, "bundleLen": len(state.bundle_text)})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
# 派发入口:POST /wg1/generate ← 执行器投递 §6.1 job
if self.path.rstrip("/") != "/wg1/generate":
self._send_json(404, {"error": "not found", "path": self.path})
return
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length > 0 else b""
try:
job = json.loads(raw.decode("utf-8")) if raw else {}
except Exception as e:
log(f"job 解析失败:{e}")
self._send_json(400, {"accepted": False, "error": "bad job json"})
return
trace_id = job.get("traceId") or job.get("job_id")
log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, gameId={job.get('gameId')}")
# 投递握手:立即回 202(投递成功),后台异步「生成」+回调(模拟真实 worker 异步时序)
threading.Thread(target=handle_job_async, args=(state, job), daemon=True).start()
self._send_json(202, {"accepted": True, "job_id": job.get("job_id"), "traceId": trace_id})
return Handler
def main():
parser = argparse.ArgumentParser(description="WG1 stub worker(P3 派发面 3b-A:回固定 pong bundle 证管线)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口(默认 {DEFAULT_PORT})")
parser.add_argument("--bundle", default=DEFAULT_BUNDLE, help="engineBundle 真值文件(默认 pong bundle)")
parser.add_argument("--callback", default=DEFAULT_CALLBACK, help="后端内网回调子路由 URL")
args = parser.parse_args()
# 读 bundle(相对路径基于 cwd;建议在 repo 根跑)
bundle_path = args.bundle
if not os.path.isfile(bundle_path):
log(f"❌ bundle 文件不存在:{bundle_path}(cwd={os.getcwd()})")
sys.exit(2)
with open(bundle_path, "r", encoding="utf-8") as f:
bundle_text = f.read()
# 出厂自检:bundle 必含 __GameBundle 全局名(宿主据此取工厂;缺则后端落包后宿主取不到 → demo 兜底)
if "__GameBundle" not in bundle_text:
log(f"❌ bundle 缺 __GameBundle 全局名:{bundle_path}(宿主无法取 GameHostFactory)")
sys.exit(2)
log(f"bundle 已加载:{bundle_path}({len(bundle_text)} 字符,含 __GameBundle ✓)")
log(f"回调子路由:{args.callback}")
state = StubWorkerState(bundle_text, args.callback)
server = ThreadingHTTPServer(("0.0.0.0", args.port), make_handler(state))
log(f"stub worker 已启动 → http://0.0.0.0:{args.port}/wg1/generate(Ctrl-C 退出)")
try:
server.serve_forever()
except KeyboardInterrupt:
log("收到中断,退出")
server.shutdown()
if __name__ == "__main__":
main()

View File

@ -175,6 +175,11 @@ aigc:
executor: executor:
enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关 enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关
api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo
# P3 W-G1 派发面(3b-A,2026-06-14):generic 模板派发外置 worker 生成 engineBundle 入 feed
worker-url: ${AIGC_WORKER_URL:} # 外置生成 worker 端点(空=派发面无端点→generic 任务判 failed;
# 3b-A 经 .env 注入 stub worker 地址,如 http://localhost:9301/wg1/generate)
# callback-url 默认即内网回调子路由(/admin-api/aigc/dify/callback-internal,@PermitAll);如需覆盖再放开:
# callback-url: ${AIGC_CALLBACK_URL:http://localhost:48080/admin-api/aigc/dify/callback-internal}
--- #################### passport 鉴权(真实鉴权与匿名玩家 HJ-PASSPORT-EXEC-001)#################### --- #################### passport 鉴权(真实鉴权与匿名玩家 HJ-PASSPORT-EXEC-001)####################
wanxiang: wanxiang:

View File

@ -0,0 +1,307 @@
/**
* p3-pong.driver.cjs — P3 canary 3a「便宜模型生成的 pong 经 feed 引擎真渲真玩」CDP driver
* adapt 自 evidence/p2-feed/p2-feed.driver.cjs(仅 mini-desktop 跑;6c6g 禁 chrome)。
*
* 目标(母 spec 门4 + P2 门②/③/④):开真 staging studio(:4173·mock OFF) → feed 定位 pong 9301 卡 → /play
* → 断言:① 引擎真渲 pong 非「模板体系重构中」(截图+像素+活性) ② lifecycle game_loaded(via=bootGameHost,engine=real)
* ∧ game_start(经 bridge 上抛,top frame 监听捕获)③ 防 demo 兜底(无回退 demo warn ∧ 无 game_error
* ∧ sha256(manifest)==checksum 已端点侧实证 ∧ gameId/versionId 匹配)④ console 零未捕获错误
* ⑤ 真玩几步(pong 挡板/球响应:tap 底部挡板带 → 活性多帧哈希变化,证输入驱动引擎真玩)。
*
* 【pong 特化 adapt 点(与 P2 暖阳小卖部的差异)】
* - gameId/versionId 缺省 9301/93010;
* - 像素判据改 harness §6/§7 口径:亮像素 max(r,g,b)>80 计数 + maxCh + 活性(≥2 不同整帧哈希),
* 不用 P2 warm 暖色阈值(pong 黑底 + 白挡板/球/分数,非暖色;球在动故活性是更强的「真渲」证据);
* - 交互坐标取 pong play-spec:tap 底部挡板带 y≈740 不同 x(挡板触摸跟随),非小卖部 newGame/openShop。
* lifecycle 三锚(game_loaded via=bootGameHost / 无 game_error / 无重构文案)品类无关,逐字复用。
*
* 用法(cwd=game-studio):node evidence/p3-pong/p3-pong.driver.cjs [baseUrl] [gameId] [versionId]
* 缺省 baseUrl=http://localhost:4173 gameId=9301 versionId=93010
* ⚠️ baseUrl 必须 localhost(非 IP):宿主 manifest 校验用 crypto.subtle,仅安全上下文(localhost/https)可用;
* 经 IP 访问 → crypto.subtle 缺失 → sha256 抛 → 永走 demo 兜底(即使后端真包正确也渲占位)。
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const WebSocket = require('ws');
const BASE = process.argv[2] || 'http://localhost:4173';
const GAME_ID = process.argv[3] || '9301';
const VERSION_ID = process.argv[4] || '93010';
const CDP_HTTP = process.env.CDP_HTTP || 'http://localhost:9222';
const OUT_DIR = path.resolve('evidence/p3-pong');
const EXPECT_CHECKSUM = process.env.EXPECT_CHECKSUM || '';
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
function httpJson(method, urlStr) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method }, (res) => {
let buf = '';
res.on('data', (c) => (buf += c));
res.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { resolve({ raw: buf }); } });
});
req.on('error', reject);
req.end();
});
}
/** 极简 CDP 会话(send/evaluate + 事件订阅:捕获 console / exceptionThrown)。 */
class Cdp {
constructor(ws, targetId) {
this.ws = ws; this.targetId = targetId; this._id = 0; this._pending = new Map();
this.consoleMsgs = []; this.exceptions = [];
ws.on('message', (raw) => {
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.id != null && this._pending.has(msg.id)) {
const { resolve, reject } = this._pending.get(msg.id); this._pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error))); else resolve(msg.result || {});
return;
}
if (msg.method === 'Runtime.consoleAPICalled') {
const a = (msg.params.args || []).map((x) => (x.value !== undefined ? x.value : x.description)).join(' ');
this.consoleMsgs.push({ type: msg.params.type, text: String(a) });
} else if (msg.method === 'Runtime.exceptionThrown') {
const d = msg.params.exceptionDetails || {};
const t = (d.exception && (d.exception.description || d.exception.value)) || d.text || 'unknown';
this.exceptions.push(String(t));
}
});
}
send(method, params, timeoutMs) {
const id = ++this._id;
return new Promise((resolve, reject) => {
const to = setTimeout(() => { this._pending.delete(id); reject(new Error('CDP 超时:' + method)); }, timeoutMs || 40000);
this._pending.set(id, { resolve: (r) => { clearTimeout(to); resolve(r); }, reject: (e) => { clearTimeout(to); reject(e); } });
this.ws.send(JSON.stringify({ id, method, params: params || {} }));
});
}
async evaluate(expression, timeoutMs) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs);
if (r && r.exceptionDetails) throw new Error('求值异常:' + JSON.stringify(r.exceptionDetails).slice(0, 300));
return r && r.result ? r.result.value : undefined;
}
close() { try { this.ws.close(); } catch { /* ignore */ } }
}
async function connect() {
const created = await httpJson('PUT', `${CDP_HTTP}/json/new?${encodeURI('about:blank')}`);
if (!created.webSocketDebuggerUrl) throw new Error('建 target 失败:' + JSON.stringify(created).slice(0, 200));
const ws = await new Promise((resolve, reject) => {
const s = new WebSocket(created.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 });
s.on('open', () => resolve(s)); s.on('error', reject);
});
const cdp = new Cdp(ws, created.id);
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch { /* old chrome */ }
// 文档创建即注入 lifecycle 捕获器(先于 SPA + 跨 reload 常驻),杜绝漏捕 game_loaded/start。
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: INJECT_CAPTURE });
return cdp;
}
const INJECT_CAPTURE = `(function(){
if (window.__p3life) return 'already';
window.__p3life = [];
window.__p3telemetry = [];
window.addEventListener('message', function(e){
try {
var d = e.data;
if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'lifecycle' && d.payload) {
window.__p3life.push({ event: d.payload.event, data: d.payload.data || {}, t: Date.now() });
}
if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'telemetry' && d.payload) {
window.__p3telemetry.push({ event: d.payload.event, props: d.payload.props || {}, t: Date.now() });
}
} catch(_){}
}, true);
return 'installed';
})()`;
async function readLife(cdp) { return await cdp.evaluate('JSON.stringify(window.__p3life||[])').then((s) => JSON.parse(s || '[]')); }
function hasLife(life, ev) { return life.find((x) => x.event === ev) || null; }
async function saveShot(cdp, name) {
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
if (!r || !r.data) throw new Error('截图无 data');
const abs = path.join(OUT_DIR, name);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, Buffer.from(r.data, 'base64'));
return abs;
}
/** 全页截图像素统计(pong 特化:亮像素 max(r,g,b)>80 计数 + maxCh + 全屏哈希用于活性)。
* pong 黑底 + 白挡板/球/分数 → 不用 warm 暖色;亮像素 + 活性(帧哈希变化) 共证「引擎真渲且在动」。 */
async function screenshotPixelStats(cdp) {
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
if (!r || !r.data) return { err: 'no shot' };
const dataUrl = 'data:image/png;base64,' + r.data;
const expr = `(async function(){
var img = new Image();
await new Promise(function(res,rej){ img.onload=res; img.onerror=rej; img.src=${JSON.stringify(dataUrl)}; });
var cv = document.createElement('canvas'); cv.width=img.naturalWidth; cv.height=img.naturalHeight;
var g = cv.getContext('2d'); g.drawImage(img,0,0);
var d = g.getImageData(0,0,cv.width,cv.height).data, n=d.length;
var bright=0, maxCh=0, nonblack=0, hash=0;
for (var i=0;i<n;i+=4){
var R=d[i],G=d[i+1],B=d[i+2];
var m=Math.max(R,G,B); if(m>maxCh)maxCh=m;
if(m>40)nonblack++;
if(m>80)bright++; // harness §6/§7:任意亮像素(品类无关)
// 轻量整帧哈希(每 64 像素采一点,FNV-ish):用于活性「≥2 不同帧哈希」判据。
if((i % (64*4))===0){ hash=(hash*16777619 + (R<<16|G<<8|B))>>>0; }
}
return { px:(n/4), bright:bright, maxCh:maxCh, nonblack:nonblack, hash:hash>>>0, w:cv.width, h:cv.height };
})()`;
return await cdp.evaluate(expr, 60000);
}
/** 真触摸(iframe 充满视口,canvas 逻辑 390×844 映射到页面 CSS 坐标,直发 Input.dispatchTouchEvent)。 */
async function tap(cdp, cssX, cssY) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cssX, y: cssY }] });
await delay(60);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const report = {
meta: { base: BASE, gameId: GAME_ID, versionId: VERSION_ID, startedAt: new Date().toISOString(), driver: 'p3-pong.driver.cjs' },
gates: {}, checks: [], lifecycle: [], console: { errors: [], demoWarns: [] }, screenshots: [], notes: [],
};
const check = (ok, msg) => { report.checks.push({ ok: !!ok, msg }); console.log((ok ? ' ✓ ' : ' ✗ ') + msg); return !!ok; };
let cdp;
try {
// ── 步骤1:feed 页 → 确认 pong 9301 卡在流(真后端,mock OFF)──
console.log('[1] feed 页加载 + 定位 pong 9301 卡');
cdp = await connect();
await cdp.send('Page.navigate', { url: `${BASE}/feed` });
await delay(4000); // 等 feed 拉流 + 渲染
const feedProbe = await cdp.evaluate(`(function(){
var html = document.body.innerText || '';
var hasTitle = html.indexOf('弹球') >= 0 || html.indexOf('Pong') >= 0 || html.indexOf('pong') >= 0;
var links = Array.prototype.map.call(document.querySelectorAll('a,[href]'), function(a){return a.getAttribute('href')||'';});
var playLink = links.filter(function(h){return h.indexOf('/play/${GAME_ID}')>=0;})[0] || null;
return { hasTitle: hasTitle, playLink: playLink, bodyLen: html.length, sample: html.slice(0,300) };
})()`);
report.notes.push({ feedProbe });
report.screenshots.push(await saveShot(cdp, '01-feed.png'));
check(feedProbe.hasTitle, `feed 出 pong 9301 卡(标题含「弹球/Pong」;sample=${(feedProbe.sample||'').slice(0,80)})`);
report.gates.gate1_feed_card = feedProbe.hasTitle;
// ── 步骤2:整页导航 /play/9301/93010(全量 reload → 捕获器文档创建即装)──
console.log('[2] /play 引擎包分支启动(全量 reload,捕获器文档级常驻)');
await cdp.send('Page.navigate', { url: `${BASE}/play/${GAME_ID}/${VERSION_ID}` });
await delay(800);
let life = [];
const deadline = Date.now() + 25000;
while (Date.now() < deadline) {
life = await readLife(cdp);
if (hasLife(life, 'game_loaded')) break;
if (hasLife(life, 'game_error')) break;
await delay(500);
}
report.lifecycle = life;
const loaded = hasLife(life, 'game_loaded');
const started = hasLife(life, 'game_start');
const errored = hasLife(life, 'game_error');
const loadedReal = !!(loaded && loaded.data && loaded.data.via === 'bootGameHost');
check(loadedReal, `lifecycle game_loaded(via=bootGameHost) 上抛(门② 就绪锚·引擎真渲非重构中)${loaded ? ' engine=' + (loaded.data && loaded.data.engine) + ' via=' + (loaded.data && loaded.data.via) : ' [缺]'}`);
check(!!started, `lifecycle game_start 上抛(会话/计数链)${started ? ' via=' + (started.data && started.data.via) : ''}`);
check(!errored, `无 game_error(引擎接管成功,未走兜底)${errored ? ' ✗错误:' + JSON.stringify(errored.data) : ''}`);
report.gates.gate2_game_loaded_real = loadedReal;
report.gates.gate2_game_start = !!started;
report.gates.gate3_no_game_error = !errored;
await delay(1000);
const telem = await cdp.evaluate('JSON.stringify(window.__p3telemetry||[])').then((s) => JSON.parse(s || '[]'));
report.notes.push({ telemetry: telem });
const playStart = telem.find((x) => x.event === 'game_play_start') || null;
report.gates.gate2_game_play_start_telem = !!playStart;
if (playStart) check(true, `game_play_start 遥测上报(play_count 权威源;DB session 行另证)`);
await delay(1200);
report.screenshots.push(await saveShot(cdp, '02-play-engine-render.png'));
// ── 步骤3:引擎真渲像素证据(pong 特化:亮像素 + 活性,非「模板体系重构中」占位)──
console.log('[3] 引擎真渲像素(亮像素+活性) + 防占位文案');
const stats = await screenshotPixelStats(cdp);
report.notes.push({ pixelStats: stats });
const rendered = !!(stats && stats.bright > 1500 && stats.maxCh > 120);
check(rendered, `引擎真渲非占位(bright=${stats && stats.bright} maxCh=${stats && stats.maxCh} nonblack=${stats && stats.nonblack})`);
report.gates.gate2_engine_rendered = rendered;
// 兜底文案检测:top frame 出现「模板体系重构中」即兜底触发(fail)
const reconText = await cdp.evaluate(`(document.body.innerText||'').indexOf('模板体系重构中')>=0`);
check(!reconText, `top frame 无「模板体系重构中」占位文案`);
report.gates.gate3_no_reconstruction_text = !reconText;
// ── 步骤4:防 demo 兜底假阳性(console 无回退 demo warn)+ checksum 三锚 ──
console.log('[4] 防 demo 兜底假阳性 console 扫描 + checksum 三锚');
const demoWarns = cdp.consoleMsgs.filter((m) => /回退\s*demo\s*兜底|manifest 校验失败|取包清单失败|engine_bundle_present_but_global_missing/.test(m.text));
report.console.demoWarns = demoWarns.map((m) => m.text);
check(demoWarns.length === 0, `无 demo 兜底告警(回退 demo/manifest 校验失败 计 ${demoWarns.length})`);
report.gates.gate3_no_demo_fallback = demoWarns.length === 0;
if (EXPECT_CHECKSUM) {
report.notes.push({ expectChecksum: EXPECT_CHECKSUM });
check(/^[0-9a-f]{64}$/.test(EXPECT_CHECKSUM) && EXPECT_CHECKSUM !== '0'.repeat(64),
`防 demo 锚①: checksum 64hex 非占位(端点 sha256==DB 已 shell 侧实证)= ${EXPECT_CHECKSUM.slice(0, 16)}…`);
}
// ── 步骤5:真玩几步(pong 挡板/球响应:tap 底部挡板带不同 x → 活性多帧哈希变化)──
// pong play-spec inputs:tap y≈740 底部挡板带,不同 x(100/300/195/130/270)→ 挡板触摸跟随。
// 判据=多采样整帧哈希 ≥2 不同(球在动 + 挡板响应触摸 → 画面必变;静止假死会同哈希)。
console.log('[5] 真玩几步(pong 挡板/球响应)+ 活性多帧哈希');
const PLAY_X = [100, 300, 195, 130, 270];
const PLAY_Y = 740;
const frameHashes = [stats && stats.hash];
await delay(600);
for (let k = 0; k < PLAY_X.length; k++) {
await tap(cdp, PLAY_X[k], PLAY_Y);
await delay(450);
const s = await screenshotPixelStats(cdp);
frameHashes.push(s && s.hash);
}
report.screenshots.push(await saveShot(cdp, '03-after-play.png'));
const uniqHashes = Array.from(new Set(frameHashes.filter((h) => h != null)));
report.notes.push({ frameHashes, uniqHashes });
const lively = uniqHashes.length >= 2;
check(lively, `活性:真玩中多采样 ≥2 不同整帧哈希(uniq=${uniqHashes.length}/${frameHashes.length},证球运动+挡板响应非静止假死)`);
report.gates.gate5_lively_interactive = lively;
// 真玩后像素再核:仍引擎真渲
const stats2 = await screenshotPixelStats(cdp);
report.notes.push({ pixelStatsAfterPlay: stats2 });
const stillRendered = !!(stats2 && stats2.bright > 1500 && stats2.maxCh > 120);
check(stillRendered, `真玩后引擎仍真渲(bright=${stats2 && stats2.bright} maxCh=${stats2 && stats2.maxCh})`);
report.gates.gate5_still_rendered = stillRendered;
report.lifecycle = await readLife(cdp);
// ── 步骤6:console 零未捕获错误 ──
console.log('[6] console 未捕获错误扫描');
const errs = cdp.consoleMsgs.filter((m) => m.type === 'error').map((m) => m.text).concat(cdp.exceptions);
report.console.errors = errs;
const realErrs = errs.filter((t) => !/favicon|ERR_|net::|404|Failed to load resource/i.test(t));
check(realErrs.length === 0, `console 零未捕获错误(real=${realErrs.length},total=${errs.length})`);
report.gates.gate4_console_clean = realErrs.length === 0;
} catch (e) {
report.fatal = String((e && e.stack) || e);
console.error('[FATAL]', report.fatal);
} finally {
if (cdp) { try { await httpJson('GET', `${CDP_HTTP}/json/close/${cdp.targetId}`); } catch { /* ignore */ } cdp.close(); }
}
report.finishedAt = new Date().toISOString();
const passCount = report.checks.filter((c) => c.ok).length;
report.summary = { pass: passCount, total: report.checks.length };
fs.writeFileSync(path.join(OUT_DIR, 'p3-evidence.json'), JSON.stringify(report, null, 2));
console.log(`\n[RESULT] ${passCount}/${report.checks.length} checks pass`);
console.log('[RESULT] gates=' + JSON.stringify(report.gates));
console.log('[DONE] evidence → ' + path.join(OUT_DIR, 'p3-evidence.json'));
}
main();