lili e8f7dc8347 feat(worker): WU2 §3.7 worker 取 job.userToken + §3.9 额度耗尽/凭据失效分辨
per-user 额度按用户扣的最后一环:三 worker 入口从「读全局 env NEWAPI_KEY」改「优先
job.userToken、缺失回落全局 env key(WARN 脱敏)」;新失败因 quota_exhausted 干净失败。

§3.7 F(取 job token,默认关时字节不变):
- cheap_service_driver._resolve_key(user_token) 优先 per-user token、缺失回落 env+WARN;
  drive_cheap_generation 从 job.userToken 取、透传给凭据体;token 日志脱敏(前后各 4 位)。
- worker_service 整 job(含 userToken)透传给 driver;do_POST 记 userToken 存否(脱敏)。
- wg1 _client.get_api_key/get_client/chat 支持 user_token override、回落 env(WARN 一进程一次)。

§3.9(分辨两类失效,one-api 惯例初值,确切 message 阶段2 待验):
- result_out 新增 quota_exhausted 入枚举 + _map_failure_reason 认 summary.failureReason +
  classify_newapi_failure_reason/newapi_error_status_text(402/403→quota,401→llm_error 可重试)。
- cheap_service_app collector 崩溃路 best-effort 分辨 402/403→写 sidecar failureReason,
  driver→result_out 落 quota_exhausted;凭据失效/其他维持 llm_error,纯旁路不咬生成。
- wg1 _client.chat 额度耗尽抛 NewapiQuotaExhaustedError(不重试);service.handle_job 映射
  quota_exhausted。注:两 worker 主生成经 AgentScope 模型封装调网关,402 埋框架内,主路
  额度耗尽分辨待阶段2 拦模型封装;本次覆盖直连 _client.chat 路 + cheap 崩溃路分辨。

自证:test_result_out 25/25、test_worker_service 17/17(stdlib 直跑);driver/wg1 token 与
分辨逻辑 standalone 断言全过;八文件 py_compile 绿。test_cheap_service_driver 需 pytest
(本机未装),已同步桩签名待阶段2 e2e 真验(per-user token 调网关/used_quota 按用户增/耗尽落 quota_exhausted)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:44:08 -07:00

448 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""service.py —— W-G1 真生成 worker 的 HTTP 服务壳P3 派发面·3b-B2026-06-14
【职责只加服务壳run.py / agent_loop 逐行不改)】把 L2 agentic studiodesign 展开一句话 + 自产 gatespec →
便宜模型写 GameHostFactory → esbuild 打 __GameBundle → 九门真玩 harness → 失败回喂重试)包成 HTTP 服务:
POST /generate ← 执行器WorkerDispatchClient投递 §6.1 jobjob_id/brief/model/gameId/templateId/callback/traceId
↓ 立即回 202投递握手成功执行器不同步长挂任务留 RUNNING 等回调)
后台线程串行跑 run_studio真烧 new-api 便宜模型生成)→
↓ 过九门 → 读 game-runtime/games/_wg1-gen/<game_id>/bundle.iife.js 全文作 engineBundle
组后端回调入参 DifyCallbackReqVOtraceId/status/templateId/gameConfig 占位/engineBundle
↓ HMAC-SHA256 对回调原始字节签名置头 X-Callback-Signature3b-B 安全红线)
POST 回 /admin-api/aigc/dify/callback-internal → 后端 handleCallback 建版本/组包/落包入 feed。
【与 3b-A stub workerwg1_stub_worker.py的关系】结构同款http.server + 后台线程异步回调),仅两处升级:
① 固定 pong bundle → 真 run_studio 真生成design 展开一句话 + 九门真玩);
② 回调 POST 加 HMAC 服务间签名(关掉 /dify/callback-internal 裸 @PermitAll 缺口,与后端 CallbackSignatureVerifier 对账)。
【HMAC 逐字节对齐铁律(与 Java CallbackSignatureVerifier 对账)】
- 签名所用字节 == HTTP 发送字节 == json.dumps(payload, ensure_ascii=False).encode("utf-8")(一次算、原样发);
- Content-Type 显式带 charset=utf-8使 Spring @RequestBody String 以 UTF-8 解码 → rawBody.getBytes(UTF_8) 还原同字节;
- hexdigest() 为 hex 小写,与 Java Character.forDigit 小写十六进制一致密钥两侧CALLBACK_SECRET / AIGC_CALLBACK_SECRET须逐字节一致。
【串行(一次一 job】run_studio 用固定 serve 端口 4320 + CDP 9222serve-and-play.sh不可并发故全局 Lock
忙时回 409执行器据此知 worker 占用,可重投或等)。
【运行mini-desktop须 cwd=repo 根 + worker venv + .env 配 new-api key/CALLBACK_SECRET
cd /root/games-development-ai
CALLBACK_SECRET=<与后端 AIGC_CALLBACK_SECRET 同值> \
wg1/gen-worker/.venv/bin/python wg1/gen-worker/worker/service.py --port 9401
执行器 application-staging.yaml 配 aigc.executor.worker-url=http://localhost:9401/generate + aigc.executor.callback-secret=<同值>。
@author 绘境AIP3 派发面·3b-B 真 worker 服务壳)
"""
import argparse
import asyncio
import hashlib
import hmac
import json
import os
import sys
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
# ── 把 worker/ 挂上 sys.pathimport runworker/run.py+ from agent_loop.studio import run_studio ──
# 与 run.py / studio.py 的「直跑兜底」同款python worker/service.py → sys.path[0]=worker/)。
WORKER_DIR = Path(__file__).resolve().parent
if str(WORKER_DIR) not in sys.path:
sys.path.insert(0, str(WORKER_DIR))
import run # noqa: E402 worker/run.pyGEN_DIR/REPO_ROOT 常量 + scaffold/build/play
import _client # noqa: E402 裸 new-api 客户端WU2 §3.9NewapiQuotaExhaustedError 分辨额度耗尽)
from agent_loop.studio import run_studio # noqa: E402 L2 agentic 闭环design 展一句话→九门)
from agent_loop import config # noqa: E402 models.yaml 阶段(默认 default_stage
# ---------- 默认常量mini-desktop staging 口径)----------
DEFAULT_PORT = 9401
# 后端内网免鉴权回调子路由AdminAigcTaskController.difyCallbackInternal@PermitAll + 3b-B HMAC 验签)
DEFAULT_CALLBACK = "http://localhost:48080/admin-api/aigc/dify/callback-internal"
# meta 段标题最大长(与后端 META_TITLE 口径一致,截断防超)
META_TITLE_MAX = 20
def log(msg):
"""带前缀日志stdoutflush 便于 nohup/systemd 落盘排障)。"""
print(f"[wg1-worker] {msg}", flush=True)
class WorkerState:
"""进程级状态:回调 URL + HMAC 密钥 + 生成阶段 + 串行锁。"""
def __init__(self, callback_url, secret, stage):
self.callback_url = callback_url # 后端内网回调子路由
self.secret = secret or "" # HMAC 共享密钥(空=不签,仅 stub/回滚态)
self.stage = stage # models.yaml 阶段None=config 默认)
self.lock = threading.Lock() # 串行锁:一次一 jobserve 4320/CDP 9222 不可并发)
self.busy = False # 占用标记(忙时 do_POST 回 409
def _derive_title(brief):
"""从一句话 brief 派生 meta 标题(截断到 META_TITLE_MAX防超后端 meta 段约束)。"""
if not brief:
return "AI 生成游戏"
return brief.strip()[:META_TITLE_MAX]
def _extract_trace(result):
"""从 run_studio result 抽 9d 富生成轨迹子集W-G1 组B · 公共底座,执行版 §5.3)。
断链洞见命门worker 已产出全部富数据,但回调 payload 历史只携 5+2 字段、富 result 在边界被丢service.py:175-178 仅 log
本函数把 result 的 9d 维度guards/cost/models/attempts/gatespec/verdict/repairs/wall/similarity结构化抽出
塞进回调 payload["trace"],由后端 best-effort 落 game_aigc_task.trace_json供 D11/D9 复用。
键名一律 camelCase对齐 Java DifyCallbackReqVO.trace / yaml 契约 / ReadinessScorer 读取口径)。
字段全可选worker 路径未定按必填子集统计完整率§5.3
- 必填子集run_studio 当前路径无条件产出 = 完整率分母pass/repairs/wallS/models/attempts/gameId/stage。
- 可选子集(路径相关/可能缺gatespec/sevenGateVerdict/cost/player/tokens/similarity。
- U3/B5 扩展键additive缺/脏则省略键,与 SAA 路 extractTraceQuietly 同口径字节兼容P1-2 严格形态校验):
modelTier终态模型档 stage1|stage2须非空串且仅「真升档态」=stage2 或 escalationEvents 非空才落P0-1/
escalationEvents升档事件数组须非空 list 才落)/ giveupDumpPath放弃 dump 路径,须非空串才落)/
cacheHit{promptCacheHitTokens, source}U4/B9 缓存命中台账。worker 现走 Python 循环(无 SAA 救场阶梯),
前三键暂不产出 → 省略(与 SAA 路同读 ReadinessScorer 不破cacheHit 由 run_studio 的 cache_hit 落值。
:param result: run_studio 返回的 result dict嵌套结构非扁平 verdict见 studio.py:297-311
:return: 9d trace dictcamelCase 键result 为空时返回 None不塞 trace
"""
if not result:
return None
# ── attempts每条逐门 guardsresult.attempts[].guards 是九门逐门布尔camelCase 化字段名)──
attempts_out = []
for a in (result.get("attempts") or []):
if not isinstance(a, dict):
continue
item = {
"attempt": a.get("attempt"),
"role": a.get("role"),
"model": a.get("model"),
"stageFail": a.get("stage_fail"), # snake→camel
"sevenGatePass": a.get("seven_gate_pass"), # snake→camel
"guards": a.get("guards"), # {门名: pass}(九门逐门,原样)
}
usage = a.get("usage")
if isinstance(usage, dict):
item["usage"] = {"in": usage.get("in"), "out": usage.get("out")}
attempts_out.append(item)
trace = {
# ===== 必填子集(完整率分母)=====
"pass": result.get("pass"),
"repairs": result.get("repairs"),
"wallS": result.get("wall_s"), # snake→camel
"models": result.get("models"), # {design,code,fix,player_vision,player_text}
"attempts": attempts_out,
"gameId": result.get("game_id"), # snake→camel
"stage": result.get("stage"),
}
# ===== 可选子集缺则不计入完整率分母None/缺省时省略键,保持 trace 紧凑)=====
gatespec = result.get("gatespec")
if gatespec is not None:
trace["gatespec"] = gatespec
verdict = result.get("seven_gate_verdict")
if verdict is not None:
# 只取 pass + guards终局逐门供 D11 firstPlay 子项读 H_progress不搬整 verdict含 url/ts 噪声)
if isinstance(verdict, dict):
trace["sevenGateVerdict"] = {"pass": verdict.get("pass"), "guards": verdict.get("guards")}
else:
trace["sevenGateVerdict"] = verdict
cost = result.get("cost")
if isinstance(cost, dict):
# cost camelCase 化by_model→byModel / total_rmb→totalRmb / gate_rmb→gateRmb取价失败降级时只剩 error+byModel
cost_out = {}
if "by_model" in cost:
cost_out["byModel"] = cost.get("by_model")
if "total_rmb" in cost:
cost_out["totalRmb"] = cost.get("total_rmb")
if "gate_rmb" in cost:
cost_out["gateRmb"] = cost.get("gate_rmb")
if "error" in cost:
cost_out["error"] = cost.get("error")
# P1-1tokens-only 降级标记pricing 不可达时为 True——随 trace 落库,审核台据此知本条 cost 无 total_rmb、非 0 成本。
if cost.get("costFallback"):
cost_out["costFallback"] = True
trace["cost"] = cost_out
player = result.get("player")
if player is not None:
trace["player"] = player
tokens = result.get("tokens")
if isinstance(tokens, dict):
trace["tokens"] = {"prompt": tokens.get("prompt"), "completion": tokens.get("completion")}
similarity = result.get("similarity")
if similarity is not None:
# D9 相似度dupHit/titleNorm/sig/dupWith随 trace 落库无独立列、无独立面板§1.2 防越界)
trace["similarity"] = similarity
# ===== U3/B5 救场阶梯扩展键additive与 SAA 路 extractTraceQuietly 同口径;缺则省略键,字节兼容)=====
# 严格形态校验,镜像 Java putEscalationKeysIfPresentP1-2 口径对齐):脏值/空值只省略键、绝不落库。
# escalationEvents升档事件数组 [{tierBefore,tierAfter,atFailCount,ts}]。须为「非空 list」才落
# 非数组/空 list/缺 → 省略键(与 Java events.isArray() && size>0 同口径)。先算它,因 modelTier 是否落要据它。
escalation = (result.get("escalationEvents")
if result.get("escalationEvents") is not None else result.get("escalation_events"))
has_events = isinstance(escalation, list) and len(escalation) > 0
if has_events:
trace["escalationEvents"] = escalation
# modelTier终态模型档stage1|stage2。须为「非空字符串」镜像 Java filter(!isBlank)),且仅「真升档态」才落
# ——modelTier=="stage2" 或 escalationEvents 非空P0-1默认 stage1 是 render 初值噪声,落它破字节兼容)。
# worker 现走 Python 循环无救场阶梯 → 一般缺 → 省略。
model_tier = result.get("modelTier") if result.get("modelTier") is not None else result.get("model_tier")
if isinstance(model_tier, str) and model_tier.strip():
if model_tier == "stage2" or has_events:
trace["modelTier"] = model_tier
# giveupDumpPath放弃前完整 dump 落盘路径U5 产)。须为「非空字符串」(镜像 Java filter(!isBlank));现可能缺 → 省略。
giveup = (result.get("giveupDumpPath")
if result.get("giveupDumpPath") is not None else result.get("giveup_dump_path"))
if isinstance(giveup, str) and giveup.strip():
trace["giveupDumpPath"] = giveup
# ===== U4/B9 缓存命中台账({promptCacheHitTokens, source}=====
# run_studio 产 result.cache_hit已 camelCase 内键 promptCacheHitTokens + source缺则省略键additive
cache_hit = result.get("cacheHit") if result.get("cacheHit") is not None else result.get("cache_hit")
if isinstance(cache_hit, dict):
trace["cacheHit"] = {
"promptCacheHitTokens": cache_hit.get("promptCacheHitTokens",
cache_hit.get("prompt_cache_hit_tokens", 0)) or 0,
"source": cache_hit.get("source", "none"),
}
return trace
def build_callback_payload(job, status, bundle_text, failure_reason=None, result=None):
"""据 job + 生成结果组后端回调入参DifyCallbackReqVO 形态)。
:param job: 收到的 §6.1 job-indict
:param status: 'succeeded' | 'failed'(由 run_studio result['pass'] 派生)
:param bundle_text: engineBundle 真值(成功路 = bundle.iife.js 全文;失败路 = None
:param failure_reason: 失败原因(失败路填,供后端落 failureReason 排障)
:param result: run_studio 返回的富 result dictW-G1 组B抽 9d trace 塞 payload["trace"]
None=不带 trace后端 trace_json 列 NULL字节零变化——additive 兼容)
:return: 回调入参 dict
"""
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 ""
payload = {
"traceId": trace_id,
"status": status,
"templateId": template_id,
# gameConfig 最小合法占位§5.2engine 路游戏逻辑全在 engineBundle 内gameConfig 仍不可空
# GamePackage required + additionalProperties:false故回最小合法占位。
"gameConfig": {
"templateId": template_id, # 须与包级 templateId 一致validateSucceededPayload 一致性校验)
"title": _derive_title(brief), # 供 meta 段派生 cover/summary
"theme": "generic", # 占位主题
"engineDriven": True, # 标记本款走引擎 bundle 路(非填参 GameConfig
},
"assets": [],
}
if status == "succeeded":
# 成功路携 bundle 全文 → 后端 resolveEngineBundleText 取值落包入 feed
payload["engineBundle"] = bundle_text
else:
# 失败路:后端据 status=failed 把任务置 FAILEDfailureReason 供排障
payload["failureReason"] = (failure_reason or "generation_failed")[:500]
# ── W-G1 组B抽 9d trace 塞回调(成功/失败路都抽;失败轨迹 repairs/attempts/stageFail 同有排障价值§6.1)──
# trace 是 additive 可选——result 缺失(如异常前置返回)则不带,后端 trace_json 列 NULL、字节零变化。
trace = _extract_trace(result)
if trace is not None:
payload["trace"] = trace
return payload
def post_callback(state, payload):
"""把回调入参 POST 回后端内网回调子路由,并按 3b-B 红线加 HMAC 签名。
【HMAC 对账关键】data 一次算定 → 既算签名又作 body 发送同字节charset=utf-8 使后端同字节还原。
:param state: WorkerState含 callback_url + secret
:param payload: 回调入参 dict
:return: (http_status, body_text)
"""
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") # 签名字节 == 发送字节(铁律)
headers = {
"Content-Type": "application/json; charset=utf-8", # 显式 charset → Spring 以 UTF-8 解 @RequestBody String
"tenant-id": "0", # 兜底(@TenantIgnore 已忽略,带上无害)
}
if state.secret:
# HMAC-SHA256(secret, raw bytes) → hex 小写 → 头(与 Java CallbackSignatureVerifier.verify 对账)
sig = hmac.new(state.secret.encode("utf-8"), data, hashlib.sha256).hexdigest()
headers["X-Callback-Signature"] = sig
req = urllib.request.Request(state.callback_url, data=data, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
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(state, job):
"""后台线程真生成run_studio→ 读 bundle → 组回调入参(含 HMAC→ POST 回调。
串行:进入时已持 state.lock 并置 busy=Truefinally 释放(保证一次一 job + 不漏锁)。
"""
trace_id = job.get("traceId") or job.get("job_id")
num_game_id = job.get("gameId")
brief = job.get("brief") or ""
# studio 落盘目录名文件系统安全gen-<数字 gameId>;与后端 job 的数字 gameId 解耦(后者是组包元数据)
game_id = f"gen-{num_game_id}"
log(f"开始真生成 trace_id={trace_id}, gameId={num_game_id}, game_dir={game_id}, brief={brief[:40]!r}")
status = "failed"
bundle_text = None
failure_reason = None
result = None # W-G1 组B提升到函数作用域供 finally 组回调时抽 9d trace断链洞见原 result 随线程消亡被丢)
try:
# run_studiodesign 展开一句话 + 自产 gatespecgate-H→ 代码/打包/九门真玩 → 失败回喂重试。
# play_spec 传空 {}gatespec 由设计 agent 自产并自动合入(不再手写 brief play-spec
# async → 本线程内 asyncio.runserve 4320 / CDP 9222 串行,全局锁已保证不并发)。
result = asyncio.run(run_studio(game_id, brief, play_spec={}, stage=state.stage))
passed = bool(result.get("pass"))
cost = (result.get("cost") or {}).get("total_rmb")
wall = result.get("wall_s")
log(f"run_studio 完成 trace_id={trace_id}, pass={passed}, repairs={result.get('repairs')}, "
f"wall={wall}s, ¥={cost}")
if passed:
# 读真生成 bundle 全文绝对路径run.GEN_DIR 已是绝对)
bundle_path = run.GEN_DIR / game_id / "bundle.iife.js"
if bundle_path.is_file():
bundle_text = bundle_path.read_text(encoding="utf-8")
if "__GameBundle" not in bundle_text:
# 出厂自检:缺全局名宿主取不到工厂 → 判失败(不落坏包入 feed
status = "failed"
failure_reason = "bundle_missing_global_name"
log(f"❌ bundle 缺 __GameBundle 全局名:{bundle_path}")
else:
status = "succeeded"
log(f"✅ bundle 已读 {len(bundle_text)} 字符(含 __GameBundle ✓)→ {bundle_path}")
else:
status = "failed"
failure_reason = "bundle_file_missing"
log(f"❌ pass 但 bundle 文件缺失:{bundle_path}")
else:
failure_reason = "nine_gate_failed"
except _client.NewapiQuotaExhaustedError as e:
# WU2 §3.9:new-api 按 per-user token 判额度耗尽(402/403)→ 干净失败 quota_exhausted(而非含糊 llm_error),
# 后端据此把任务终态落 quota_exhausted、前端展示「生成额度已用完」。凭据失效(401)不走此路、维持可重试。
# 注:wg1 主生成经 AgentScope 模型封装调网关(config.py credential),402 埋在框架内、此典型错误仅从
# _client.chat 直连路(如 run.py 旧路)propagate;主生成路的额度耗尽分辨待阶段2 拦 AgentScope 模型封装。
failure_reason = "quota_exhausted"
log(f"❌ new-api 额度耗尽 trace_id={trace_id}(§3.9 quota_exhausted): {type(e).__name__}: {e}")
except Exception as e:
failure_reason = f"{type(e).__name__}: {e}"
log(f"❌ 真生成异常 trace_id={trace_id}: {failure_reason}")
finally:
# 无论成败都回调(成功落包 / 失败置 FAILED并释放串行锁
try:
# W-G1 组B传入富 result → build_callback_payload 抽 9d trace 塞回调result 为 None 时不带 trace字节零变
payload = build_callback_payload(job, status, bundle_text, failure_reason, result)
http_status, body = post_callback(state, payload)
log(f"回调完成 trace_id={trace_id}, status={status}, callbackHttp={http_status}, body={body[:200]}")
except Exception as e:
log(f"❌ 回调 POST 异常 trace_id={trace_id}: {type(e).__name__}: {e}")
finally:
state.busy = False
try:
state.lock.release()
except RuntimeError:
pass # 幂等:未持锁时释放无害
def make_handler(state):
"""工厂:绑定 state 的 HTTP handler 类。"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return # 静默默认访问日志(用自定义 log
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含 busy 标记,便于编排判 worker 占用)
if self.path.rstrip("/") in ("/health", "/wg1/health"):
self._send_json(200, {"ok": True, "busy": state.busy})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
# 派发入口POST /generate兼容 /wg1/generate← 执行器投递 §6.1 job
if self.path.rstrip("/") not in ("/generate", "/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")
# 串行抢锁:忙则 409一次一 jobrun_studio 占用 serve 4320/CDP 9222 不可并发)
if not state.lock.acquire(blocking=False):
log(f"worker 占用中,拒 job trace_id={trace_id}409")
self._send_json(409, {"accepted": False, "error": "worker busy", "traceId": trace_id})
return
state.busy = True
log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, gameId={job.get('gameId')}")
# 投递握手:立即回 202后台线程真生成 + 回调持锁handle_job 的 finally 释放)
threading.Thread(target=handle_job, 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 真生成 worker 服务壳P3 派发面·3b-B")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口(默认 {DEFAULT_PORT}")
parser.add_argument("--callback", default=DEFAULT_CALLBACK, help="后端内网回调子路由 URL")
parser.add_argument("--stage", default=None, help="models.yaml 阶段(默认 default_stage")
args = parser.parse_args()
# HMAC 密钥:环境变量 CALLBACK_SECRET须与后端 AIGC_CALLBACK_SECRET 逐字节一致;空=不签,仅回滚态)
secret = os.environ.get("CALLBACK_SECRET", "")
# 阶段:默认走 config 的 default_stage
stage = args.stage
if stage is None:
_cfg, default_stage = config.load_config()
stage = default_stage
if secret:
log(f"HMAC 服务间签名【已启用】(密钥长度={len(secret)})→ 头 X-Callback-Signature")
else:
log("⚠️ HMAC 签名【未启用】CALLBACK_SECRET 为空)——仅回滚/桩态用3b-B 正式态须配密钥")
log(f"回调子路由:{args.callback}")
log(f"生成阶段:{stage}models.yaml")
state = WorkerState(args.callback, secret, stage)
server = ThreadingHTTPServer(("0.0.0.0", args.port), make_handler(state))
log(f"真生成 worker 已启动 → http://0.0.0.0:{args.port}/generateCtrl-C 退出)")
try:
server.serve_forever()
except KeyboardInterrupt:
log("收到中断,退出")
server.shutdown()
if __name__ == "__main__":
main()