Files
jinchanz-ComfyUI-ADIC/module/nodes_template_compose.py
jinchan 01f8439fd6 fix(template): 修复规则 JSON 生成逻辑避免 KeyError
- 仅将带 id 的画布节点规则加入 rulesJson
- 跳过无 id 的纯行输入列规则,避免 KeyError
- 保留规则原有字段并补充 frameId
- 规范代码注释,提升代码可读性
2026-09-19 21:25:07 +08:00

715 lines
32 KiB
Python
Raw Permalink 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.
import copy
import json
import traceback
import uuid
import requests
from . import template_workflow
from .template_workflow import WorkflowProtocolError
from .tool_task_client import ToolTaskClient, ToolTaskError
# 工具任务接口的 surface(场景标识):服务端不做枚举校验,仅用于场景路由 / 频控 / 落库归类
DEFAULT_TOOL_SURFACE = "template_compose"
class RemoteTemplateBatchCompose:
"""批量套版节点
接收一份模板 JSON 和多条表格数据,为每条数据生成预览图 URL 和
可重新打开编辑的画布 JSON(最外层 type 为 page)。
规则含非空 `workflows` 时(templateProtocolVersion = 2),先在合图前执行
AI hook:解析工作流参数、调用工具任务接口、把结果写回 `input[resultRuleKey]`,
再把 resolvedInput 交给原有替换/合图链路。没有 workflows 的模板完全走旧链路。
模板替换在本地完成;预览图通过远端套版服务
`POST {base_url}/open/api/agent/v1/template/replace` 合成。
`base_url` 留空时回退到 Mock 固定测试图,方便本地调试。
"""
REPLACE_API_PATH = "/open/api/agent/v1/template/replace"
# Mock 阶段返回的固定预览图信息
MOCK_PREVIEW_URL = "https://placehold.co/1200x1200.png"
MOCK_PREVIEW_WIDTH = 1200
MOCK_PREVIEW_HEIGHT = 1200
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"template_json": ("STRING", {"forceInput": True, "tooltip": "完整模板 JSON 字符串,结构遵循拓版协议"}),
"items_json": ("STRING", {"forceInput": True, "tooltip": "待处理数据数组的 JSON 字符串"}),
},
"optional": {
"base_url": ("STRING", {"default": "", "tooltip": "远端套版服务基础地址,例如 https://abcd.example;留空时使用 Mock 预览图"}),
"auth_token": ("STRING", {"default": "", "tooltip": "Bearer AK,调用远端服务时写入 Authorization 头"}),
"app_name": ("STRING", {"default": "local", "tooltip": "远端服务 appName"}),
"biz_code": ("STRING", {"default": "", "tooltip": "远端服务 bizCode"}),
"file_type": (["png", "jpg", "webp"], {"default": "png", "tooltip": "预览图文件格式"}),
"store_type": ("STRING", {"default": "oss", "tooltip": "预览图存储类型"}),
"timeout": ("INT", {"default": 300, "min": 5, "max": 1800, "tooltip": "单条远端调用超时(秒)"}),
"extra_headers": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "自定义请求头,支持 JSON 对象或每行一条 'Key: Value';"
"与 Bearer Token 合并,auth_token 非空时 Authorization 以 auth_token 为准",
},
),
"tool_base_url": (
"STRING",
{
"default": "",
"tooltip": "工具任务服务基础地址,留空时回退 base_url;两者都为空时跳过 AI hook(Mock 调试)",
},
),
"tool_call_context": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "工具任务提交体上下文(JSON 对象),原样透传,例如 "
"{\"surface\": \"...\", \"agentId\": \"...\", \"sessionId\": \"...\"};"
"requestId 与 toolCall 由节点填充",
},
),
"tool_poll_interval": ("INT", {"default": 5, "min": 1, "max": 60, "tooltip": "工具任务轮询间隔(秒)"}),
"tool_max_poll_time": (
"INT",
{"default": 600, "min": 10, "max": 3600, "tooltip": "单个工具任务最长轮询时间(秒)"},
),
"tool_batch_task_id": (
"STRING",
{
"default": "",
"tooltip": "套版批量任务的计费 AIGC Record 公共 ID,由 Java 消息工厂注入为 workflow userParam;"
"透传给工具任务提交接口用于受托计费,切勿在此填写 AK",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("results_json",)
FUNCTION = "compose"
CATEGORY = "Malette"
def compose(
self,
template_json,
items_json,
base_url="",
auth_token="",
app_name="local",
biz_code="",
file_type="png",
store_type="oss",
timeout=300,
extra_headers="",
tool_base_url="",
tool_call_context="",
tool_poll_interval=5,
tool_max_poll_time=600,
tool_batch_task_id="",
):
config = {
"base_url": (base_url or "").strip(),
"auth_token": (auth_token or "").strip(),
"app_name": (app_name or "local").strip(),
"biz_code": (biz_code or "").strip(),
"file_type": (file_type or "png").strip(),
"store_type": (store_type or "oss").strip(),
"timeout": timeout or 300,
# 节点级配置错误直接抛错,使本次工作流失败
"extra_headers": self._parse_extra_headers(extra_headers),
"tool_base_url": (tool_base_url or "").strip(),
"tool_call_context": self._parse_tool_call_context(tool_call_context),
"tool_poll_interval": tool_poll_interval or 5,
"tool_max_poll_time": tool_max_poll_time or 600,
"tool_batch_task_id": (tool_batch_task_id or "").strip(),
}
# templateJson / itemsJson 本身无法解析时直接报错,使本次工作流失败
try:
template_data = json.loads(template_json)
except json.JSONDecodeError as e:
raise ValueError(f"templateJson 解析失败: {str(e)}")
try:
items = json.loads(items_json)
except json.JSONDecodeError as e:
raise ValueError(f"itemsJson 解析失败: {str(e)}")
if not isinstance(template_data, dict):
raise ValueError("templateJson 必须是 JSON 对象")
if not isinstance(items, list):
raise ValueError("itemsJson 必须是 JSON 数组")
# 模板校验只做一次;模板不合法时所有条目返回同样的失败结果,
# 保证结果数量与输入数量一致
template_error = None
try:
self._validate_template(template_data)
except WorkflowProtocolError as e:
template_error = (e.code, e.message, e.location)
except ValueError as e:
template_error = ("TEMPLATE_INVALID", str(e), {})
if template_error is not None:
print(f"[RemoteTemplateBatchCompose] 模板校验失败: {template_error[0]} {template_error[1]}")
executor = None
if template_error is None and template_workflow.has_ai_rules(template_data.get("templateRules") or {}):
executor = self._build_tool_executor(config)
# 同一 workId 内有效输入相同的 AI 节点复用同一次执行结果
ai_cache = {}
results = []
for index, item in enumerate(items):
if not isinstance(item, dict):
results.append(self._failed_result({}, "INVALID_ITEM", f"第 {index + 1} 条数据不是 JSON 对象"))
continue
if template_error is not None:
results.append(self._failed_result(item, template_error[0], template_error[1], template_error[2]))
continue
# 单条失败只返回该条失败,不影响其他数据
try:
results.append(self._compose_one(template_data, item, config, executor, ai_cache))
except WorkflowProtocolError as e:
print(f"[RemoteTemplateBatchCompose] 第 {index + 1} 条 AI 解析失败: {e.code} {e.message}")
results.append(self._failed_result(item, e.code, e.message, e.location))
except ToolTaskError as e:
print(f"[RemoteTemplateBatchCompose] 第 {index + 1} 条 AI 执行失败: {e.code} {e.message}")
results.append(self._failed_result(item, e.code, e.message))
except Exception as e:
print(f"[RemoteTemplateBatchCompose] 第 {index + 1} 条套版失败: {str(e)}")
traceback.print_exc()
results.append(self._failed_result(item, "COMPOSE_FAILED", str(e)))
print(f"[RemoteTemplateBatchCompose] 处理完成: 共 {len(results)} 条,"
f"成功 {sum(1 for r in results if r.get('status') == 'SUCCESS')} 条")
return (json.dumps(results, ensure_ascii=False),)
def _build_tool_executor(self, config):
"""构建工具任务执行器;地址为空时返回 None,沿用 Mock 调试链路"""
tool_base_url = config.get("tool_base_url") or config.get("base_url") or ""
if not tool_base_url:
print("[RemoteTemplateBatchCompose] 未配置工具任务地址,跳过 AI hook,直接使用行数据中的原值")
return None
context = dict(config.get("tool_call_context") or {})
if not str(context.get("surface") or "").strip():
context["surface"] = DEFAULT_TOOL_SURFACE
# 套版批量任务的计费 Record 公共 ID:透传给提交接口用于受托计费(服务端仅对内部服务 AK 生效)
batch_task_id = str(config.get("tool_batch_task_id") or "").strip()
if batch_task_id:
context["templateBatchTaskId"] = batch_task_id
# 服务端按 agentId 校验工具绑定关系(public_share 例外,由 shareToken 反查)
if context["surface"] != "public_share" and not str(context.get("agentId") or "").strip():
raise ValueError("tool_call_context 缺少 agentId,工具任务接口需要它校验工具绑定关系")
return ToolTaskClient(
base_url=tool_base_url,
headers=self._build_headers(config),
context=context,
poll_interval=config.get("tool_poll_interval"),
max_poll_time=config.get("tool_max_poll_time"),
timeout=config.get("timeout"),
log_prefix="[RemoteTemplateBatchCompose]",
)
def _validate_template(self, template_data):
"""校验模板结构。
仅保留会导致后续处理直接崩溃的结构性前置(layer / rules 必须是对象)
为硬错误;其余 rule 级校验(id、节点存在性、唯一性、类型一致性)一律降级为日志,
不阻断流程,具体合法性由远端接口负责。
"""
layer = template_data.get("layer")
if not isinstance(layer, dict):
raise ValueError("模板缺少 layer 对象")
if layer.get("type") not in ("template", "frame"):
print(f"[RemoteTemplateBatchCompose] 模板校验告警: layer.type 建议为 template 或 frame,当前为: {layer.get('type')}")
rules = template_data.get("templateRules")
if not isinstance(rules, dict):
raise ValueError("模板缺少 templateRules 对象")
for field_name, rule in rules.items():
if not isinstance(rule, dict):
print(f"[RemoteTemplateBatchCompose] 模板校验告警: 规则 '{field_name}' 不是 JSON 对象,已跳过校验")
continue
rule_id = rule.get("id")
rule_type = rule.get("type")
if not rule_id:
print(f"[RemoteTemplateBatchCompose] 模板校验告警: 规则 '{field_name}' 缺少 id")
continue
nodes = self._find_nodes_by_id(layer, rule_id)
if len(nodes) == 0:
print(f"[RemoteTemplateBatchCompose] 模板校验告警: 规则 '{field_name}' 引用的节点 '{rule_id}' 在模板中不存在")
continue
if len(nodes) > 1:
print(f"[RemoteTemplateBatchCompose] 模板校验告警: 规则 '{field_name}' 引用的节点 '{rule_id}' 在模板中不唯一")
node = nodes[0]
if node.get("type") != rule_type:
print(
f"[RemoteTemplateBatchCompose] 模板校验告警: 规则 '{field_name}' 类型不一致: 规则为 '{rule_type}',节点为 '{node.get('type')}'"
)
# 规则键与节点 columnName 的一致性不在本地校验,由远端接口负责
# 含非空 workflows 时按第 2 版协议校验;老模板此处直接返回
template_workflow.validate_workflows(template_data, layer)
def _compose_one(self, template_data, item, config, executor=None, ai_cache=None):
"""处理单条数据,返回成功结果"""
work_id = item.get("workId")
input_values = item.get("input") or {}
if not isinstance(input_values, dict):
raise ValueError("input 必须是 JSON 对象")
# 忽略存量数据泄漏在 input 中的路由/执行上下文 key
resolved_input = template_workflow.strip_reserved_keys(input_values)
# AI hook 在旧替换逻辑之前,且必须在原始图层 ID 上解析 binding.layerId
generated = {}
if executor is not None:
resolved_input, generated = self._run_ai_hook(
template_data, resolved_input, item, executor, ai_cache if ai_cache is not None else {}
)
# 深拷贝模板,不修改原始模板
layer = copy.deepcopy(template_data["layer"])
rules = copy.deepcopy(template_data["templateRules"])
# 为模板根节点及所有子节点生成全局唯一的新 ID
id_mapping = self._regenerate_ids(layer)
# 只更新规则中引用的节点 ID(含 workflows 内的 binding.layerId),
# 规则其余字段完整保留。值替换由远端按 materialJson 完成,节点内不做本地替换:
# 既避免对不认识的节点类型写错字段,也不会因类型不在白名单而报错。
template_workflow.remap_rule_layer_ids(rules, id_mapping)
# 本地拼装的画布仅作兜底(Mock 或远端未返回 templateJson 时使用),
# 其中的节点值仍是模板默认值;远端返回结果时以远端为准。
# colorPalette / colorPalettes 是可选字段,原样透传,缺失时不补默认值
canvas = {
key: copy.deepcopy(value)
for key, value in template_data.items()
if key not in ("layer", "templateRules")
}
canvas["type"] = "page"
canvas["layers"] = [layer]
canvas["templateRules"] = {layer.get("name", ""): rules}
palette_key, palette = self._get_palette(template_data)
preview = self._compose_remote(layer, rules, resolved_input, palette_key, palette, item, config)
result = {
"workId": work_id,
"rowNo": item.get("rowNo"),
"attempt": item.get("attempt"),
"status": "SUCCESS",
"url": preview["url"],
"filename": preview["filename"],
"width": preview["width"],
"height": preview["height"],
# 远端返回的结果 page JSON 优先,Mock/未返回时用本地拼装的画布
"value": preview.get("canvas") or canvas,
}
if generated:
result["generated"] = generated
return result
def _run_ai_hook(self, template_data, input_values, item, executor, ai_cache):
"""按依赖顺序执行 workflows,把 AI 结果写入行数据,返回 (resolvedInput, generated)"""
rules = template_data["templateRules"]
layer_index, _ = template_workflow.index_layers(template_data["layer"])
work_id = item.get("workId") or ""
generated_patch = {}
generated_meta = {}
for result_rule_key in template_workflow.build_execution_order(rules):
workflow = template_workflow.get_workflow(rules, result_rule_key)
workflow_key = workflow.get("workflowKey")
tool_name = workflow.get("toolName")
params = template_workflow.resolve_workflow_params(
rules, layer_index, input_values, result_rule_key, generated_patch
)
input_hash = template_workflow.effective_input_hash(workflow, params)
cache_key = (work_id, tool_name, input_hash)
cached = ai_cache.get(cache_key)
if cached is not None:
value, execution_id = cached
reused = True
print(f"[RemoteTemplateBatchCompose] 复用 AI 结果: {result_rule_key} "
f"(workId: {work_id}, inputHash: {input_hash[:12]})")
else:
# 幂等身份: workId + resultRuleKey + workflowKey + effectiveInputHash
request_id = f"{work_id}:{result_rule_key}:{workflow_key}:{input_hash[:16]}"
task_result = executor.call_tool(tool_name, params, request_id)
value = template_workflow.select_workflow_output(
task_result.get("data") or {},
workflow.get("output"),
rules[result_rule_key].get("type"),
result_rule_key,
workflow_key,
)
execution_id = task_result.get("taskId") or task_result.get("publicId") or ""
ai_cache[cache_key] = (value, execution_id)
reused = False
generated_patch[result_rule_key] = value
generated_meta[result_rule_key] = [
{
"workflowKey": workflow_key,
"executionId": execution_id,
"inputHash": input_hash,
"value": value,
"reused": reused,
}
]
return template_workflow.resolve_compose_input(input_values, generated_patch), generated_meta
def _compose_remote(self, layer, rules, input_values, palette_key, color_palette, item, config):
"""调用远端套版服务完成替换与合图。
请求 `POST {base_url}/open/api/agent/v1/template/replace`:
- templateJson: 模板(字符串化 JSON)
- rulesJson: 以节点 ID 为键的规则映射,附带 frameId(字符串化 JSON)
- materialJson: 字段名 -> 表格值(字符串化 JSON)
- colorPaletteJson: 模板的配方数组(可选,模板未带配方时不传该字段)
响应 `data.url` 为预览图,`data.templateJson` 为替换完成的 frame 协议
(或整页 page JSON),`data.colorPaletteJson` 为原样带回的配方数组(可选)。
base_url 为空时返回 Mock 固定测试图,方便本地调试。
"""
work_id = item.get("workId") or f"work_{uuid.uuid4().hex[:8]}"
file_type = config.get("file_type") or "png"
fallback_filename = f"{work_id}.{file_type}"
base_url = config.get("base_url") or ""
if not base_url:
return {
"url": self.MOCK_PREVIEW_URL,
"filename": fallback_filename,
"width": self.MOCK_PREVIEW_WIDTH,
"height": self.MOCK_PREVIEW_HEIGHT,
}
# rulesJson 以节点 ID 为键,保留规则原有字段(含 comfyConfig),补充 frameId。
# 仅带 id 的画布节点规则进入 rulesJson;纯行输入列(如 workflow 的 source=row 取值源列)
# 没有 id,不对应画布节点,其值通过 materialJson 传给远端,跳过即可,避免 KeyError。
frame_id = layer.get("id")
rules_payload = {}
for field_name, rule in rules.items():
if not isinstance(rule, dict):
continue
rule_id = rule.get("id")
if not rule_id:
continue
rule_entry = dict(rule)
rule_entry["frameId"] = frame_id
rules_payload[rule_id] = rule_entry
request_body = {
"appName": config.get("app_name") or "local",
"bizCode": config.get("biz_code") or "",
"fileType": file_type,
"materialJson": json.dumps(input_values, ensure_ascii=False),
"needPreview": True,
"rulesJson": json.dumps(rules_payload, ensure_ascii=False),
"storeType": config.get("store_type") or "oss",
"templateJson": json.dumps(layer, ensure_ascii=False),
}
# colorPaletteJson 非必填,模板未带配方时不传
if color_palette is not None:
request_body["colorPaletteJson"] = json.dumps(color_palette, ensure_ascii=False)
# 合并顺序:默认头 -> 自定义头 -> Bearer Token;
# auth_token 非空时 Authorization 以 auth_token 为准
headers = self._build_headers(config)
url = base_url.rstrip("/") + self.REPLACE_API_PATH
print(f"[RemoteTemplateBatchCompose] 调用套版服务: {url} (workId: {work_id})")
response = requests.post(url, headers=headers, json=request_body, timeout=config.get("timeout") or 300)
if response.status_code >= 400:
raise RuntimeError(
f"套版服务请求失败: {response.status_code} {response.reason}, "
f"响应: {self._snippet(response.text)}"
)
try:
data = response.json()
except ValueError:
raise RuntimeError(f"套版服务返回非 JSON 内容: {self._snippet(response.text)}")
if isinstance(data, dict) and data.get("success") is False:
raise RuntimeError(f"套版服务返回失败: {data.get('message') or self._snippet(response.text)}")
payload = data.get("data") if isinstance(data, dict) else None
preview = self._extract_preview_info(payload if payload is not None else data)
if not preview.get("url"):
raise RuntimeError(f"套版服务未返回预览图 URL: {self._snippet(response.text)}")
# data.templateJson 是替换完成的结果(frame 协议或整页 page);
# data.colorPaletteJson 非必填,未返回时沿用模板自带的配方
canvas = self._build_result_canvas(
self._parse_json_field(payload, "templateJson"),
self._parse_optional_palette(payload),
layer,
rules,
palette_key,
color_palette,
)
# 尺寸缺失时回退到模板根节点尺寸
width = preview.get("width") or self._as_int(layer.get("width")) or self.MOCK_PREVIEW_WIDTH
height = preview.get("height") or self._as_int(layer.get("height")) or self.MOCK_PREVIEW_HEIGHT
return {
"url": preview["url"],
"filename": preview.get("filename") or fallback_filename,
"width": width,
"height": height,
"canvas": canvas,
}
@staticmethod
def _get_palette(template_data):
"""读取模板的配方字段,兼容 colorPalette / colorPalettes 两种命名。
返回 (使用的键名, 配方值);两者都没有时返回 (默认键名, None)。
"""
for key in ("colorPalette", "colorPalettes"):
if key in template_data:
return key, template_data[key]
return "colorPalette", None
@classmethod
def _parse_optional_palette(cls, payload):
"""解析响应中的配方字段(非必填),兼容单复数命名;解析不了就当作未返回"""
if not isinstance(payload, dict):
return None
for key in ("colorPaletteJson", "colorPalettesJson", "colorPalette", "colorPalettes"):
if key not in payload:
continue
try:
parsed = cls._parse_json_field(payload, key)
except RuntimeError as e:
print(f"[RemoteTemplateBatchCompose] 忽略无法解析的 {key}: {str(e)}")
return None
if parsed is not None:
return parsed
return None
@staticmethod
def _parse_json_field(payload, key):
"""解析响应中的字符串化 JSON 字段,缺失或为空时返回 None"""
if not isinstance(payload, dict):
return None
value = payload.get(key)
if value is None or value == "":
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError as e:
raise RuntimeError(f"套版服务返回的 {key} 解析失败: {str(e)}")
raise RuntimeError(f"套版服务返回的 {key} 类型不支持: {type(value).__name__}")
@staticmethod
def _build_result_canvas(result_root, result_palette, layer, rules, palette_key, local_palette):
"""把远端返回的结果组装成可编辑画布(最外层 type 为 page)。
远端 templateJson 既可能是替换后的 frame 协议,也可能已是整页 page;
配方优先用远端带回的,其次回退模板自带的,都没有时不写该字段
(colorPalette / colorPalettes 均为可选,沿用入参使用的命名)。
"""
if not isinstance(result_root, dict):
# 远端未返回结果,交由调用方使用本地兜底画布
return None
palette = result_palette if result_palette is not None else local_palette
if result_root.get("type") == "page":
canvas = result_root
canvas.setdefault("templateRules", {layer.get("name", ""): rules})
else:
# frame / template 根:包装成 page,规则沿用替换后的节点 ID
canvas = {
"type": "page",
"layers": [result_root],
"templateRules": {result_root.get("name") or layer.get("name", ""): rules},
}
if palette is not None:
canvas[palette_key] = palette
return canvas
def _extract_preview_info(self, payload):
"""从响应中提取预览图信息,兼容字符串 URL 与常见字段命名"""
if isinstance(payload, str):
return {"url": payload}
if not isinstance(payload, dict):
return {}
url = None
for key in ("url", "previewUrl", "preview_url", "imageUrl", "image_url", "fileUrl", "file_url", "previewImage"):
value = payload.get(key)
if isinstance(value, str) and value:
url = value
break
filename = None
for key in ("filename", "fileName", "name"):
value = payload.get(key)
if isinstance(value, str) and value:
filename = value
break
return {
"url": url,
"filename": filename,
"width": self._as_int(payload.get("width")),
"height": self._as_int(payload.get("height")),
}
@staticmethod
def _build_headers(config):
"""合并顺序:默认头 -> 自定义头 -> Bearer Token;
auth_token 非空时 Authorization 以 auth_token 为准
"""
headers = {
"Content-Type": "application/json",
"Accept": "*/*",
}
headers.update(config.get("extra_headers") or {})
auth_token = config.get("auth_token") or ""
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
return headers
@staticmethod
def _parse_tool_call_context(tool_call_context):
"""解析工具任务提交体上下文,requestId 和 toolCall 由节点填充,不允许覆盖"""
text = (tool_call_context or "").strip()
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"tool_call_context JSON 解析失败: {str(e)}")
if not isinstance(parsed, dict):
raise ValueError("tool_call_context 必须是 JSON 对象")
return {key: value for key, value in parsed.items() if key not in ("requestId", "toolCall")}
@staticmethod
def _parse_extra_headers(extra_headers):
"""解析自定义请求头,支持 JSON 对象或每行一条 'Key: Value'"""
text = (extra_headers or "").strip()
if not text:
return {}
if text.startswith("{"):
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"extra_headers JSON 解析失败: {str(e)}")
if not isinstance(parsed, dict):
raise ValueError("extra_headers JSON 必须是对象")
headers = {}
for key, value in parsed.items():
if not str(key).strip():
raise ValueError("extra_headers 中存在空的 header 名")
headers[str(key).strip()] = str(value)
return headers
headers = {}
for line_no, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line:
continue
key, sep, value = line.partition(":")
if not sep or not key.strip():
raise ValueError(f"extra_headers 第 {line_no} 行格式不正确,应为 'Key: Value': {line}")
headers[key.strip()] = value.strip()
return headers
@staticmethod
def _as_int(value):
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(round(value))
return None
@staticmethod
def _snippet(text, limit=500):
text = text or ""
return text[:limit] + ("...(已截断)" if len(text) > limit else "")
def _regenerate_ids(self, root):
"""为节点树生成全局唯一的新 ID,返回 旧ID->新ID 映射"""
id_mapping = {}
def walk(node):
old_id = node.get("id")
if old_id is not None:
new_id = f"{node.get('type', 'layer')}_{uuid.uuid4().hex}"
node["id"] = new_id
id_mapping[old_id] = new_id
children = node.get("layers")
if isinstance(children, list):
for child in children:
if isinstance(child, dict):
walk(child)
walk(root)
return id_mapping
def _find_nodes_by_id(self, root, node_id):
"""在模板子树中查找指定 ID 的全部节点"""
found = []
def walk(node):
if node.get("id") == node_id:
found.append(node)
children = node.get("layers")
if isinstance(children, list):
for child in children:
if isinstance(child, dict):
walk(child)
walk(root)
return found
def _failed_result(self, item, error_code, error_message, location=None):
result = {
"workId": item.get("workId"),
"rowNo": item.get("rowNo"),
"attempt": item.get("attempt"),
"status": "FAILED",
"errorCode": error_code,
"errorMessage": error_message,
}
# 能定位时附带 resultRuleKey / workflowKey / inputKey / layerId / parameterPath
for key, value in (location or {}).items():
if key not in result:
result[key] = value
return result