362 lines
13 KiB
Python
362 lines
13 KiB
Python
# -*- 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
|