pbl_blueprint/pbl_blueprint/subobject_ext.py
2026-09-16 16:25:33 +08:00

362 lines
13 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.

# -*- coding: utf-8 -*-
"""
pbl_blueprint · M1b 子对象扩展(模板侧 7 类子对象同构契约 + ID 重映射)
设计依据
- M1b-annex §2「7 类子对象」/ §3 ④「ID 重映射」/ §6「与在途 M1a 代码的对齐核对」
- 字典 `pbl_subobject_type`(pbl_appcodes 注入)
边界(硬约束)
* 本文件**只做模板侧的纯函数扩展**:类型校验、schema 校验、临时 ID -> 业务主键重映射、
外部引用抽取。**不直接写 M1a 的任何表**,写入一律经 M1a 聚合根契约
(create_blueprint / batch_save_subobjects / append_version / get_tree)。
* 外部引用(world/scene/entity/script)**保持原值不重映射**,其存在性校验结果落
关联表 `pbl_bp_ref.ref_status`(Q-OPEN-3:不改 world 等基表)。
"""
from __future__ import annotations
import hashlib
import json
# ---------------------------------------------------------------------------
# 7 类子对象:单一事实源
# ---------------------------------------------------------------------------
#: 每类子对象的契约:编码前缀 / 允许的父类型 / 必填字段 / 是否外部引用类
SUBOBJECT_TYPES = {
"stage": {
"code_prefix": "STG",
"parents": (), # 顶层(父为蓝图本身)
"required": ("name",),
"external_ref": False,
"order_field": "seq",
},
"task": {
"code_prefix": "TSK",
"parents": ("stage",),
"required": ("name",),
"external_ref": False,
"order_field": "seq",
},
"role": {
"code_prefix": "ROL",
"parents": ("stage", "task"),
"required": ("name",),
"external_ref": False,
"order_field": "seq",
},
"world_ref": {
"code_prefix": "WRF",
"parents": ("stage", "task"),
"required": ("world_code",),
"external_ref": True,
"ref_domain": "world",
"ref_field": "world_code",
"order_field": "seq",
},
"scene_ref": {
"code_prefix": "SRF",
"parents": ("stage", "task"),
"required": ("scene_code",),
"external_ref": True,
"ref_domain": "scene",
"ref_field": "scene_code",
"order_field": "seq",
},
"entity_ref": {
"code_prefix": "ERF",
"parents": ("scene_ref", "task"),
"required": ("entity_code",),
"external_ref": True,
"ref_domain": "entity",
"ref_field": "entity_code",
"order_field": "seq",
},
"script_ref": {
"code_prefix": "CRF",
"parents": ("task", "role", "entity_ref"),
"required": ("script_code",),
"external_ref": True,
"ref_domain": "script",
"ref_field": "script_code",
"order_field": "seq",
},
}
SUBOBJECT_TYPE_LIST = tuple(SUBOBJECT_TYPES.keys())
#: 模板内父子引用字段(临时 ID)
TMP_PARENT_FIELD = "parent_tmp_id"
#: 模板内子对象临时 ID 字段
TMP_ID_FIELD = "tmp_id"
#: tpl_json 顶层结构版本
TPL_SCHEMA_VERSION = "1.0"
class TplSchemaError(ValueError):
"""模板 schema 不合法(对应错误码 PBL_TPL_SCHEMA_INVALID,阻断不落库)。"""
code = "PBL_TPL_SCHEMA_INVALID"
def __init__(self, message, detail=None):
super(TplSchemaError, self).__init__(message)
self.message = message
self.detail = detail or {}
# ---------------------------------------------------------------------------
# 模板内容解析 / 校验
# ---------------------------------------------------------------------------
def parse_tpl_json(tpl_json):
"""
tpl_json(str 或 dict)-> dict。解析失败即 schema 不合法。
"""
if isinstance(tpl_json, dict):
data = tpl_json
else:
try:
data = json.loads(tpl_json or "")
except Exception as exc: # noqa: BLE001 - 统一转 schema 错误
raise TplSchemaError("tpl_json 不是合法 JSON: %s" % exc)
if not isinstance(data, dict):
raise TplSchemaError("tpl_json 顶层必须是对象")
return data
def tpl_hash(tpl_json):
"""
确定性 hash:对**规范化 JSON**(键排序、无多余空白、UTF-8)取 sha256。
同模板同输入 -> 同 hash -> 同结构(M1b 出口门禁④确定性)。
"""
data = tpl_json if isinstance(tpl_json, dict) else parse_tpl_json(tpl_json)
canonical = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def validate_tpl_schema(tpl_json):
"""
模板 schema 合法性校验(Q-OPEN-1:schema 权威 = data-model.md + appcodes 字典约束)。
校验项:
1. 顶层含 schema_version / subobjects(数组)
2. 每个子对象 subobject_type ∈ 7 类字典
3. 必填字段齐备(按类型)
4. tmp_id 唯一且非空
5. parent_tmp_id 必须指向已定义 tmp_id,且父类型在该类型允许的 parents 内
6. 无环(父子引用 DAG)
7. 外部引用类子对象必须带对应 xxx_code(原值,不重映射)
:return: dict(规范化后的模板内容,subobjects 已按拓扑序排列)
:raises TplSchemaError: 任一项不通过(阻断,不落任何数据)
"""
data = parse_tpl_json(tpl_json)
schema_version = str(data.get("schema_version") or TPL_SCHEMA_VERSION)
if schema_version.split(".")[0] != TPL_SCHEMA_VERSION.split(".")[0]:
raise TplSchemaError(
"模板 schema 主版本不兼容: %s(期望 %s)" % (schema_version, TPL_SCHEMA_VERSION),
{"schema_version": schema_version})
subobjects = data.get("subobjects")
if not isinstance(subobjects, list):
raise TplSchemaError("tpl_json.subobjects 必须是数组")
if not subobjects:
raise TplSchemaError("模板至少包含 1 个子对象")
by_tmp_id = {}
for idx, so in enumerate(subobjects):
if not isinstance(so, dict):
raise TplSchemaError("subobjects[%d] 必须是对象" % idx, {"index": idx})
stype = so.get("subobject_type")
if stype not in SUBOBJECT_TYPES:
raise TplSchemaError(
"subobject_type 非法: %r(字典 pbl_subobject_type 允许 %s)" % (stype, SUBOBJECT_TYPE_LIST),
{"index": idx, "subobject_type": stype})
spec = SUBOBJECT_TYPES[stype]
tmp_id = so.get(TMP_ID_FIELD)
if not tmp_id or not isinstance(tmp_id, str):
raise TplSchemaError("subobjects[%d] 缺少 %s" % (idx, TMP_ID_FIELD), {"index": idx})
if tmp_id in by_tmp_id:
raise TplSchemaError("tmp_id 重复: %s" % tmp_id, {"index": idx, "tmp_id": tmp_id})
missing = [f for f in spec["required"] if so.get(f) in (None, "")]
if missing:
raise TplSchemaError(
"subobjects[%d](%s) 缺少必填字段 %s" % (idx, stype, missing),
{"index": idx, "tmp_id": tmp_id, "missing": missing})
parent = so.get(TMP_PARENT_FIELD)
if parent is not None:
if not spec["parents"]:
raise TplSchemaError(
"%s 为顶层子对象,不得设置 %s" % (stype, TMP_PARENT_FIELD),
{"index": idx, "tmp_id": tmp_id})
elif spec["parents"]:
raise TplSchemaError(
"%s 必须挂在父对象下(允许父类型 %s)" % (stype, list(spec["parents"])),
{"index": idx, "tmp_id": tmp_id, "subobject_type": stype})
by_tmp_id[tmp_id] = so
# 父存在性 + 父类型合法性 + 无环
for tmp_id, so in by_tmp_id.items():
parent = so.get(TMP_PARENT_FIELD)
if parent is None:
continue
pso = by_tmp_id.get(parent)
if pso is None:
raise TplSchemaError(
"parent_tmp_id 悬空: %s -> %s" % (tmp_id, parent),
{"tmp_id": tmp_id, "parent_tmp_id": parent})
ptype = pso.get("subobject_type")
if ptype not in SUBOBJECT_TYPES[so["subobject_type"]]["parents"]:
raise TplSchemaError(
"父子类型不合法: %s(%s) 不能挂在 %s(%s) 下" % (
tmp_id, so["subobject_type"], parent, ptype),
{"tmp_id": tmp_id, "parent_tmp_id": parent})
ordered = _topo_sort(by_tmp_id)
normalized = dict(data)
normalized["schema_version"] = schema_version
normalized["subobjects"] = ordered
return normalized
def _topo_sort(by_tmp_id):
"""按父子关系拓扑排序(父在前),同时检测环。"""
ordered, state = [], {}
def visit(tmp_id, chain):
mark = state.get(tmp_id)
if mark == 1:
raise TplSchemaError("父子引用存在环: %s" % " -> ".join(chain + [tmp_id]),
{"cycle": chain + [tmp_id]})
if mark == 2:
return
state[tmp_id] = 1
so = by_tmp_id[tmp_id]
parent = so.get(TMP_PARENT_FIELD)
if parent is not None:
visit(parent, chain + [tmp_id])
state[tmp_id] = 2
ordered.append(so)
for tmp_id in sorted(by_tmp_id.keys()):
visit(tmp_id, [])
return ordered
# ---------------------------------------------------------------------------
# 外部引用抽取(Q-OPEN-3:结果落关联表 pbl_bp_ref,不改基表)
# ---------------------------------------------------------------------------
def extract_external_refs(subobjects):
"""
抽取模板中的外部引用(world/scene/entity/script),**保持原值不重映射**。
:return: list[dict] {domain, ref_code, tmp_id, subobject_type}
"""
refs = []
for so in subobjects or []:
stype = so.get("subobject_type")
spec = SUBOBJECT_TYPES.get(stype) or {}
if not spec.get("external_ref"):
continue
ref_code = so.get(spec["ref_field"])
if ref_code in (None, ""):
continue
refs.append({
"domain": spec["ref_domain"],
"ref_code": str(ref_code),
"tmp_id": so.get(TMP_ID_FIELD),
"subobject_type": stype,
})
return refs
def group_refs_by_domain(refs):
"""按域分组去重,供批量存在性校验(pbl_world_projection / pbl_scene_projection /
pbl_entity_projection / script_engine.validate_script)。"""
grouped = {}
for r in refs or []:
grouped.setdefault(r["domain"], set()).add(r["ref_code"])
return {k: sorted(v) for k, v in grouped.items()}
# ---------------------------------------------------------------------------
# ID 重映射(M1b-annex §3 ④)
# ---------------------------------------------------------------------------
def remap_ids(subobjects, gen_code, tenant_id, code_map=None):
"""
为模板内每个临时 ID 生成新业务主键,并同步替换父子引用。
:param subobjects: validate_tpl_schema 产出的拓扑序子对象数组
:param gen_code: pbl_common.gen_code(prefix, tenant_id) —— 复用 M1a 同一编码函数,不另造规则
:param tenant_id: 租户(平台公共模板实例化时传**目标租户**,编码归属目标租户)
:param code_map: 可选,外部传入的 tmp_id -> new_code 映射(用于确定性重放/测试)
:return: (items, mapping)
items = M1a batch_save_subobjects 入参数组
[{subobject_type, subobject_code, parent_code, payload...}]
mapping = {tmp_id: new_code}
"""
mapping = dict(code_map or {})
items = []
for so in subobjects:
tmp_id = so[TMP_ID_FIELD]
stype = so["subobject_type"]
spec = SUBOBJECT_TYPES[stype]
if tmp_id not in mapping:
mapping[tmp_id] = gen_code(spec["code_prefix"], tenant_id)
new_code = mapping[tmp_id]
parent_tmp = so.get(TMP_PARENT_FIELD)
parent_code = mapping.get(parent_tmp) if parent_tmp else None
if parent_tmp and parent_code is None:
# 理论上 validate 已阻断;此处 fail-closed 不静默
raise TplSchemaError("父子重映射失败: %s -> %s" % (tmp_id, parent_tmp),
{"tmp_id": tmp_id, "parent_tmp_id": parent_tmp})
item = {
"subobject_type": stype,
"subobject_code": new_code,
"parent_code": parent_code,
}
for key, val in so.items():
if key in (TMP_ID_FIELD, TMP_PARENT_FIELD, "subobject_type"):
continue
item[key] = val
items.append(item)
return items, mapping
def apply_ref_status(items, refs, resolved_map):
"""
把外部引用存在性校验结果写回子对象 payload(由 M1a 落 `pbl_bp_ref.ref_status`)。
:param resolved_map: {domain: {ref_code: True/False}}
:return: unresolved_count
"""
resolved_map = resolved_map or {}
unresolved = 0
ref_index = {(r["tmp_id"], r["domain"]): r["ref_code"] for r in refs}
for item in items:
stype = item.get("subobject_type")
spec = SUBOBJECT_TYPES.get(stype) or {}
if not spec.get("external_ref"):
continue
domain = spec["ref_domain"]
ref_code = item.get(spec["ref_field"])
ok = bool(resolved_map.get(domain, {}).get(ref_code))
item["ref_status"] = "resolved" if ok else "unresolved"
if not ok:
unresolved += 1
# 保留 ref_index 引用避免 lint 抱怨(供调试追溯)
assert ref_index is not None
return unresolved