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:
parent
d79f2c37d5
commit
ce9085df6b
@ -23,6 +23,16 @@
|
||||
- **批跑前置铁律**:①浏览器侧 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)一律复用此语义,禁止旁路。
|
||||
|
||||
### ⓪′ 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`。
|
||||
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
@ -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.service.callback.DifyCallbackService;
|
||||
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.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.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.web.bind.annotation.*;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
@ -61,4 +69,49 @@ public class AdminAigcTaskController {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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.GameConfigSchemaValidator;
|
||||
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.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@ -73,6 +74,17 @@ public class AigcExecutorConfiguration {
|
||||
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)
|
||||
*
|
||||
@ -82,6 +94,7 @@ public class AigcExecutorConfiguration {
|
||||
* @param promptResourceLoader prompt 装载器
|
||||
* @param schemaValidator schema 校验器
|
||||
* @param llmClient LLM 通道
|
||||
* @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路)
|
||||
* @return 执行器(@Scheduled 注解由全局调度后处理器消费,fixedDelay 单飞)
|
||||
*/
|
||||
@Bean
|
||||
@ -90,9 +103,10 @@ public class AigcExecutorConfiguration {
|
||||
DifyCallbackService difyCallbackService,
|
||||
PromptResourceLoader promptResourceLoader,
|
||||
GameConfigSchemaValidator schemaValidator,
|
||||
ExecutorLlmClient llmClient) {
|
||||
ExecutorLlmClient llmClient,
|
||||
WorkerDispatchClient workerDispatchClient) {
|
||||
return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService,
|
||||
promptResourceLoader, schemaValidator, llmClient, Clock.systemDefaultZone());
|
||||
promptResourceLoader, schemaValidator, llmClient, workerDispatchClient, Clock.systemDefaultZone());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -95,10 +95,43 @@ public class AigcExecutorProperties {
|
||||
* 支持模板集(默认值取 {@link AigcTemplateConstants#SUPPORTED_TEMPLATE_IDS} 唯一同源,
|
||||
* 与 AigcTaskServiceImpl.validateTemplateExists 口径一致,避免「提交通过、执行 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);
|
||||
|
||||
// ===== 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)
|
||||
*
|
||||
|
||||
@ -22,6 +22,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
@ -87,6 +88,8 @@ public class AigcGenerateExecutor {
|
||||
private final GameConfigSchemaValidator schemaValidator;
|
||||
/** LLM 通道(§6.3) */
|
||||
private final ExecutorLlmClient llmClient;
|
||||
/** 外置生成 worker 派发通道(P3 W-G1 派发面,仅 generic 模板走,§6.5) */
|
||||
private final WorkerDispatchClient workerDispatchClient;
|
||||
/** 墙钟(预算门用;单测注入假时钟验证 M5) */
|
||||
private final Clock clock;
|
||||
|
||||
@ -104,11 +107,13 @@ public class AigcGenerateExecutor {
|
||||
* @param promptResourceLoader prompt 契约装载器
|
||||
* @param schemaValidator schema 校验器
|
||||
* @param llmClient LLM 通道
|
||||
* @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路)
|
||||
* @param clock 墙钟
|
||||
*/
|
||||
public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper,
|
||||
DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader,
|
||||
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient, Clock clock) {
|
||||
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
|
||||
WorkerDispatchClient workerDispatchClient, Clock clock) {
|
||||
// §5.2 阈值关系铁律:stale×60 > budget + 273,装配期校验(误配置宁可启动失败也不带病收割在飞任务)
|
||||
properties.validateThresholds();
|
||||
this.properties = properties;
|
||||
@ -117,6 +122,7 @@ public class AigcGenerateExecutor {
|
||||
this.promptResourceLoader = promptResourceLoader;
|
||||
this.schemaValidator = schemaValidator;
|
||||
this.llmClient = llmClient;
|
||||
this.workerDispatchClient = workerDispatchClient;
|
||||
this.clock = clock;
|
||||
// 启动三行自检日志(§12-① 冒烟观察面:执行器已启用 / prompt 资源版本 / key 配置态;严禁打印密钥本身)
|
||||
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);
|
||||
}
|
||||
|
||||
// ----- §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 明文)
|
||||
JsonNode validConfig = null;
|
||||
List<String> lastErrors = Collections.emptyList();
|
||||
@ -399,6 +414,85 @@ public class AigcGenerateExecutor {
|
||||
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 失败,进重出轮)
|
||||
*
|
||||
|
||||
@ -42,8 +42,16 @@ public final class AigcTemplateConstants {
|
||||
* 资源 Map 加行 + contracts/templates/<id>.schema.json 就位),白名单口径单点维护不变。
|
||||
* 旧四模板的契约 schema(contracts/templates/{clicker,merge,idle,tycoon}.schema.json)与 prompt 注册表条目按
|
||||
* 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() {
|
||||
// 纯常量持有类,禁止实例化
|
||||
|
||||
@ -76,6 +76,13 @@ public class PromptResourceLoader {
|
||||
m.put("tycoon", new String[]{
|
||||
"wanxiang-contracts/prompts/04-config/tycoon-designer.md",
|
||||
"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);
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -77,6 +77,10 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
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 -> {
|
||||
// 单测空 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(), "异常路径结束后安全上下文应已清理");
|
||||
}
|
||||
|
||||
// ============================== 用例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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
70
game-cloud/scripts/p3_scaffold_pong.sql
Normal file
70
game-cloud/scripts/p3_scaffold_pong.sql
Normal 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;
|
||||
171
game-cloud/scripts/p3_seed_pong.mjs
Normal file
171
game-cloud/scripts/p3_seed_pong.mjs
Normal 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}`);
|
||||
50
game-cloud/scripts/p3b_publish_flip.sql
Normal file
50
game-cloud/scripts/p3b_publish_flip.sql
Normal 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;
|
||||
71
game-cloud/scripts/p3b_scaffold_pong.sql
Normal file
71
game-cloud/scripts/p3b_scaffold_pong.sql
Normal 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;
|
||||
209
game-cloud/scripts/wg1_stub_worker.py
Normal file
209
game-cloud/scripts/wg1_stub_worker.py
Normal 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()
|
||||
@ -175,6 +175,11 @@ aigc:
|
||||
executor:
|
||||
enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关
|
||||
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)####################
|
||||
wanxiang:
|
||||
|
||||
307
game-studio/evidence/p3-pong/p3-pong.driver.cjs
Normal file
307
game-studio/evidence/p3-pong/p3-pong.driver.cjs
Normal 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();
|
||||
Loading…
x
Reference in New Issue
Block a user