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% 冻结下一批发布开关。
- **回调写链**真实9bf3d54唯一写入路径=`DifyCallbackService.handleCallback`三表同事务task/version/runtime_packageputManifest 未命中显式失败checksum 两段序列化消解自指——任何新生成来源M-b 执行器/未来真 Dify一律复用此语义禁止旁路。
### ⓪′ P3 派发面generic 外置 worker 生成 engineBundle 入 feed3b-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.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-A2026-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 stagingworker 与后端
* mini-desktop localhost 3b-B worker / 未来外部接入前必须补服务间签名校验
* HMAC/共享密钥或 mTLS与上方 /dify/callback Dify 接入补签名TODO 同治上线前若本路由
* 可被外网路由到须在网关层显式拦截或加签名门否则任意外部请求可伪造回调驱动落包安全红线
* TenantIgnoreworker 无租户上下文 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 三表写链建版本/组包/落包/回填含受理置 RUNNINGUPDATE/INSERT updater/creator
// null 直出撞 NOT NULLDataIntegrityViolation任务卡 RUNNINGRBAC /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.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.5JDK HttpClient 投递 §6.1 job generic 路用
*
* @param properties 执行器配置worker-url / 派发超时
* @return 派发通道客户端
*/
@Bean
public WorkerDispatchClient aigcWorkerDispatchClient(AigcExecutorProperties properties) {
return new WorkerDispatchClient(properties);
}
/**
* 生成执行器§5tick 触发面 + 执行体构造时执行 §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());
}
}

View File

@ -95,10 +95,43 @@ public class AigcExecutorProperties {
* 支持模板集默认值取 {@link AigcTemplateConstants#SUPPORTED_TEMPLATE_IDS} 唯一同源
* AigcTaskServiceImpl.validateTemplateExists 口径一致避免提交通过执行 no_template_match割裂
* M-c 已扩 mergeclicker+merge idle/tycoondodge/runner/match 已降 P1 不进集合 no_template_match
* 不在集合内 failed + no_template_matchM1
* 不在集合内 failed + no_template_matchM1P3 W-G1 点亮 generic单一通用默认即 ["generic"]
*/
private List<String> supportedTemplates = new ArrayList<>(AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS);
// ===== P3 W-G1 派发面3b-A2026-06-14generic 模板走外置 worker 生成 =====
/**
* 触发派发外置 worker 的模板 idP3 范式切换边界仅此 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-internalstaging application-staging.yaml 占位符注入
* {@code ${AIGC_WORKER_URL:}}默认空串 = 派发面无端点 generic 任务 dispatch 即判 failed(llm_error)
* 不烧 LLM可观测不静默卡死3b-A stub worker回已知 pong bundle3b-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.targetworker 据此把 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
*

View File

@ -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 投递 workerworker 异步产
// 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 经回调入参 engineBundlePOST
* /admin-api/aigc/dify/callback-internal@PermitAll 内网回调子路由 复用 handleCallback 唯一写入路径
* 建版本/组包/落包 任务终态本方法只负责投递不写终态终态由 worker 回调驱动
*
* 投递成功后任务状态任务保持 RUNNINGprocessCandidate CAS 已置 1worker 回调命中即终态化
* 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 renderCtxtemplate_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_msmaxLlmCalls 对齐 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 callbacktype=httptarget=内网免鉴权回调子路由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()); // 贯穿 jobresult 的幂等键 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-V4worker 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 失败进重出轮
*

View File

@ -42,8 +42,16 @@ public final class AigcTemplateConstants {
* 资源 Map 加行 + contracts/templates/&lt;id&gt;.schema.json 就位白名单口径单点维护不变
* 旧四模板的契约 schemacontracts/templates/{clicker,merge,idle,tycoon}.schema.json prompt 注册表条目按
* W-CLEAN 范围OUT保留不动仅本白名单清空使其不可被生成
*
* P3 W-G1 点亮 generic2026-06-143b-A 派发面 plumbing新模板体系落地注册单一通用 templateId
* {@code "generic"}O-P3-1 单一通用口径品类区分留 prompt 两处消费点validateTemplateExists /
* 执行器预检同步见 generic不产生提交通过执行 no_template_match割裂配套PromptResourceLoader
* .TEMPLATE_RESOURCES generic generic-coder.md + generic.schema.jsonbrick-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() {
// 纯常量持有类禁止实例化

View File

@ -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-G12026-06-143b-Ageneric 通用模板资源编码 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);
}

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-A2026-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 造梦AIP3 派发面
*/
@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);
}
/**
* 生产默认 senderJDK 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));
// 派发握手不带 Authorizationworker 端点内网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
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-19P3 派发面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;
}
/** 把投递响应包成 SenderResponseworker 投递握手成功=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关键·范式切换senderLLM 通道零交互
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-processsender 被调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 走进程内 LLMsender 被调 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 行
-- IDgameId=9301 / versionId=93010 / pkgId=93010 / feedRankId=93010高位区与 9201/9202 零碰撞)
-- engineBundle 由后续 p3_seed_pong.mjs 9301 覆写 package_json本脚本先建空 package_json 占位行)
-- 幂等:先 DELETE 同 ID 行再 INSERT纯重建可重复执行
-- tenantstaging 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 指向 93010launch_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_rankzone_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 缺省 9301p3_scaffold_pong.sql 建的脚手架游戏P2 9201/ref
* - bundle 路径指向 ponggame-runtime/games/_wg1-gen/pong/bundle.iife.jsP2 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) 生成 SQLnode game-cloud/scripts/p3_seed_pong.mjs [gameId] > /tmp/p3_seed_pong.sql
* gameId 缺省 9301bundle 路径固定 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 造梦AIP3 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 缺省 9301p3_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 = [];
// manifestbundleSize/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 灌库桩 SQLpong 引擎游戏 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 当前生效版本 IDgame_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_jsonversionId 占位回填真实 @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=1checksum 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 闭环。
-- 自动选 NgameId=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 Ngen_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 指向回调新版本 Nfeed 卡 /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 直写)。
-- 本脚本只建一款【已发布 projectgameId=9302+ 占位 version(93020)+ feed_rank】
-- ① 提交 generic 任务携 gameId=9302project 须先存在);
-- ② 回调链建【新】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 行
-- IDgameId=9302 / 占位 versionId=93020 / pkgId=93020 / feedRankId=93020高位区与 9201/9301 零碰撞)
-- 占位 version 93020 的 package_json 为空feed 卡先有占位包,翻到回调新版本 N 后服务真 pong
-- 幂等:先 DELETE 同 gameId/占位 ID 行再 INSERT纯重建可重复执行。注意不删回调新建的 versionN≠93020
-- tenantstaging 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 先指占位 93020flip 后改指回调新版本 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 派发面 e2egeneric 任务派发 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_packagestatus=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_rankzone_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 workerP3 W-G1 派发面·3b-A2026-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专证管线
workerL1 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但回调入参形态对齐后端 DifyCallbackReqVOtraceId/status/templateId/gameConfig/engineBundle
而非直接回 §6.1 result-out 全形态因为 3b-A 复用 HTTP 回调路worker/dify/callback-internal
后端回调入参 VO 即落点§6.1 result-out selfCheck/costYuan 等字段是 workerpipeline队列路语义
HTTP 回调路下由后端从 DifyCallbackReqVO engineBundleresolveEngineBundleText落包二者等价
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 造梦AIP3 派发面·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-indict
:param bundle_text: pong bundle 全文engineBundle 真值
:return: 回调入参 dicttraceId/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.2engine 路承载 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 workerP3 派发面 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/generateCtrl-C 退出)")
try:
server.serve_forever()
except KeyboardInterrupt:
log("收到中断,退出")
server.shutdown()
if __name__ == "__main__":
main()

View File

@ -175,6 +175,11 @@ aigc:
executor:
enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关
api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo
# P3 W-G1 派发面3b-A2026-06-14generic 模板派发外置 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:

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-spectap 底部挡板带 y740 不同 x挡板触摸跟随非小卖部 newGame/openShop
* lifecycle 三锚game_loaded via=bootGameHost / game_error / 无重构文案品类无关逐字复用
*
* 用法cwd=game-studionode 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 {
// ── 步骤1feed 页 → 确认 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 inputstap y≈740 底部挡板带,不同 x100/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);
// ── 步骤6console 零未捕获错误 ──
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();