371 lines
17 KiB
Python
371 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""M1b-3 关联表判定与落地(Q-OPEN-3:不改 world 表)。
|
||
|
||
判定结论(写入 docs/M1b-annex-impl.md 备查)
|
||
--------------------------------------------
|
||
候选方案对比后选定 **方案 C:独立关联表 pbl_blueprint_ref(单向边 + 引用快照)**:
|
||
|
||
* 方案 A(在 world/scene/entity 基表加 tenant_id + blueprint_id 列)——**否决**:
|
||
侵入既有域基表,需 ALTER 生产表、影响 world/scene/entity 模块的既有 CRUD 与
|
||
导入链路,回滚成本高;且 world 被多个非 PBL 场景复用,加 PBL 专属列属职责污染。
|
||
* 方案 B(蓝图 content JSON 内嵌引用,无关联表)——**否决**:
|
||
无法反查「某 world 被哪些蓝图引用」,删除前置校验与影响面分析只能全表扫 JSON,
|
||
M2 校验引擎与 M3 编译器的引用有效性判定无索引可用。
|
||
* 方案 C(独立关联表)——**采纳**:基表零侵入、可独立回滚;正查(蓝图→引用)与
|
||
反查(对象→被引用)均有索引;引用快照支持离线展示;resolve_status 承载
|
||
引用有效性判定结果,不缓存跨域写权限。
|
||
|
||
约束
|
||
----
|
||
* 不建数据库外键(跨模块/可能跨库),有效性由 resolve_refs() 主动判定;
|
||
* 对 world/scene/entity 等被引用域**只读**,本模块永不写基表;
|
||
* 所有读写 tenant_id 强制打头,缺失 → fail-closed。
|
||
"""
|
||
|
||
from .m1b_common import (
|
||
ConflictError,
|
||
NotFoundError,
|
||
ValidationError,
|
||
as_dict,
|
||
insert,
|
||
new_id,
|
||
now_iso,
|
||
require_actor,
|
||
require_tenant,
|
||
select,
|
||
select_one,
|
||
update,
|
||
write_audit,
|
||
)
|
||
from .m1b_subobject import SUBOBJECT_KINDS
|
||
|
||
__all__ = [
|
||
"REF_TABLE", "REF_DOMAINS", "REL_TYPES", "CARDINALITIES",
|
||
"DOMAIN_TABLE_WHITELIST", "classify_ref", "add_ref", "bulk_add_refs",
|
||
"list_refs", "get_ref", "remove_ref", "resolve_refs",
|
||
"impact_of", "refs_from_content", "sync_refs_from_content",
|
||
]
|
||
|
||
REF_TABLE = "pbl_blueprint_ref"
|
||
|
||
# 被引用域白名单(只读)。表名白名单用于防止任意表注入式引用。
|
||
DOMAIN_TABLE_WHITELIST = {
|
||
"world": ("world", "world_snapshot", "world_sync"),
|
||
"scene": ("scene",),
|
||
"entity": ("entity",),
|
||
"script_engine": ("script_engine",),
|
||
"scense_game": ("scense_game", "scense"),
|
||
"drag": ("drag_canvas", "drag_template"),
|
||
"org": ("org_unit", "org_position"),
|
||
"employee": ("employee",),
|
||
"pbl": ("pbl_blueprint", "pbl_mission", "pbl_role", "pbl_learner",
|
||
"pbl_artifact_def", "pbl_problem", "pbl_project",
|
||
"pbl_driving_question", "pbl_learning_goal"),
|
||
"external": (),
|
||
}
|
||
REF_DOMAINS = tuple(DOMAIN_TABLE_WHITELIST.keys())
|
||
REL_TYPES = ("uses", "binds", "embeds", "derives_from", "replaces")
|
||
CARDINALITIES = ("1:1", "1:n", "n:1", "n:m")
|
||
|
||
|
||
def classify_ref(ref_domain, ref_table):
|
||
"""关联表判定:域/表是否在白名单内。
|
||
|
||
返回 (ok: bool, reason: str)。白名单外 → 拒绝落库(fail-closed),
|
||
避免把任意外部表名写入关联表造成越权探测面。
|
||
"""
|
||
if not ref_domain or not ref_table:
|
||
return False, "ref_domain and ref_table are required"
|
||
if ref_domain not in DOMAIN_TABLE_WHITELIST:
|
||
return False, "ref_domain not in whitelist: %s (allowed: %s)" % (
|
||
ref_domain, "/".join(REF_DOMAINS))
|
||
allowed = DOMAIN_TABLE_WHITELIST[ref_domain]
|
||
if allowed and ref_table not in allowed:
|
||
return False, "ref_table %s not allowed under domain %s (allowed: %s)" % (
|
||
ref_table, ref_domain, "/".join(allowed))
|
||
return True, "ok"
|
||
|
||
|
||
def _normalize_ref_payload(payload):
|
||
p = dict(payload or {})
|
||
domain = str(p.get("ref_domain") or "").strip()
|
||
table = str(p.get("ref_table") or "").strip()
|
||
ref_id = str(p.get("ref_id") or "").strip()
|
||
if not ref_id:
|
||
raise ValidationError("ref_id is required")
|
||
ok, reason = classify_ref(domain, table)
|
||
if not ok:
|
||
raise ValidationError(reason, code="PBL_REF_NOT_ALLOWED")
|
||
rel = str(p.get("rel_type") or "uses").strip()
|
||
if rel not in REL_TYPES:
|
||
raise ValidationError("rel_type invalid: %s (allowed: %s)" % (rel, "/".join(REL_TYPES)))
|
||
card = str(p.get("cardinality") or "n:1").strip()
|
||
if card not in CARDINALITIES:
|
||
raise ValidationError("cardinality invalid: %s" % card)
|
||
src_kind = str(p.get("src_kind") or "blueprint").strip()
|
||
if src_kind != "blueprint" and src_kind not in SUBOBJECT_KINDS:
|
||
raise ValidationError("src_kind invalid: %s" % src_kind)
|
||
src_id = p.get("src_id")
|
||
if src_kind != "blueprint" and not src_id:
|
||
raise ValidationError("src_id is required when src_kind != blueprint")
|
||
return {
|
||
"ref_domain": domain, "ref_table": table, "ref_id": ref_id,
|
||
"rel_type": rel, "cardinality": card,
|
||
"src_kind": src_kind, "src_id": src_id or None,
|
||
"required": 1 if p.get("required") in (1, True, "1", "true", "True") else 0,
|
||
"ref_snapshot": p.get("ref_snapshot") if isinstance(p.get("ref_snapshot"), (dict, list))
|
||
else as_dict(p.get("ref_snapshot"), default=None),
|
||
"seq": int(p.get("seq") or 0),
|
||
"version_id": p.get("version_id"),
|
||
}
|
||
|
||
|
||
def add_ref(db, tenant_id, blueprint_id, payload, actor=None):
|
||
"""新增关联边(幂等:同唯一键已存在则更新,不重复插入)。"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
require_actor(actor)
|
||
if not blueprint_id:
|
||
raise ValidationError("blueprint_id is required")
|
||
d = _normalize_ref_payload(payload)
|
||
now = now_iso()
|
||
conds = {"tenant_id": tenant_id, "blueprint_id": blueprint_id, "src_kind": d["src_kind"],
|
||
"src_id": d["src_id"] or "", "ref_domain": d["ref_domain"],
|
||
"ref_table": d["ref_table"], "ref_id": d["ref_id"], "rel_type": d["rel_type"],
|
||
"deleted": 0}
|
||
exist = select_one(db, REF_TABLE, conds)
|
||
if exist:
|
||
update(db, REF_TABLE, {"id": exist["id"]},
|
||
{"cardinality": d["cardinality"], "required": d["required"],
|
||
"ref_snapshot": d["ref_snapshot"] if d["ref_snapshot"] is not None else exist.get("ref_snapshot"),
|
||
"version_id": d["version_id"] if d["version_id"] is not None else exist.get("version_id"),
|
||
"seq": d["seq"], "updated_by": actor, "updated_at": now})
|
||
write_audit(db, tenant_id, "ref.upsert", REF_TABLE, exist["id"], actor=actor,
|
||
detail={"blueprint_id": blueprint_id, "ref": "%s:%s/%s" %
|
||
(d["ref_domain"], d["ref_table"], d["ref_id"])})
|
||
return {"ok": True, "id": exist["id"], "created": False, "ref": dict(exist)}
|
||
rid = new_id("bpref")
|
||
row = {
|
||
"id": rid, "tenant_id": tenant_id, "blueprint_id": blueprint_id,
|
||
"version_id": d["version_id"], "src_kind": d["src_kind"], "src_id": d["src_id"],
|
||
"ref_domain": d["ref_domain"], "ref_table": d["ref_table"], "ref_id": d["ref_id"],
|
||
"ref_snapshot": d["ref_snapshot"], "rel_type": d["rel_type"],
|
||
"cardinality": d["cardinality"], "required": d["required"],
|
||
"resolve_status": "unknown", "resolved_at": None, "seq": d["seq"],
|
||
"created_by": actor, "created_at": now, "updated_by": actor, "updated_at": now,
|
||
"deleted": 0,
|
||
}
|
||
insert(db, REF_TABLE, row)
|
||
write_audit(db, tenant_id, "ref.add", REF_TABLE, rid, actor=actor,
|
||
detail={"blueprint_id": blueprint_id, "ref": "%s:%s/%s" %
|
||
(d["ref_domain"], d["ref_table"], d["ref_id"]), "rel_type": d["rel_type"]})
|
||
return {"ok": True, "id": rid, "created": True, "ref": row}
|
||
|
||
|
||
def bulk_add_refs(db, tenant_id, blueprint_id, refs, actor=None):
|
||
"""批量新增。先全量判定(白名单/必填),任一非法整体拒绝,避免半截数据。"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
require_actor(actor)
|
||
refs = list(refs or [])
|
||
if not refs:
|
||
raise ValidationError("refs is empty")
|
||
errors, prepared = [], []
|
||
for i, r in enumerate(refs):
|
||
try:
|
||
prepared.append(_normalize_ref_payload(r))
|
||
except ValidationError as e:
|
||
errors.append({"index": i, "message": e.message, "code": e.code})
|
||
if errors:
|
||
raise ValidationError("bulk_add_refs validation failed", code="PBL_REF_VALIDATION_FAILED",
|
||
errors=errors)
|
||
ids = [add_ref(db, tenant_id, blueprint_id, d, actor=actor)["id"] for d in prepared]
|
||
return {"ok": True, "count": len(ids), "ids": ids}
|
||
|
||
|
||
def list_refs(db, tenant_id, blueprint_id=None, ref_domain=None, ref_table=None, ref_id=None,
|
||
src_kind=None, src_id=None, rel_type=None, resolve_status=None, version_id=None,
|
||
limit=500):
|
||
"""正查(蓝图→引用)与反查(对象→被引用)统一入口。tenant_id 强制。"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
conds = {"tenant_id": tenant_id, "deleted": 0}
|
||
if blueprint_id:
|
||
conds["blueprint_id"] = blueprint_id
|
||
if ref_domain:
|
||
conds["ref_domain"] = ref_domain
|
||
if ref_table:
|
||
conds["ref_table"] = ref_table
|
||
if ref_id:
|
||
conds["ref_id"] = ref_id
|
||
if src_kind:
|
||
conds["src_kind"] = src_kind
|
||
if src_id:
|
||
conds["src_id"] = src_id
|
||
if rel_type:
|
||
conds["rel_type"] = rel_type
|
||
if resolve_status:
|
||
conds["resolve_status"] = resolve_status
|
||
if version_id:
|
||
conds["version_id"] = version_id
|
||
rows = select(db, REF_TABLE, conds, order_by="seq", limit=int(limit))
|
||
return {"ok": True, "total": len(rows), "items": rows}
|
||
|
||
|
||
def get_ref(db, tenant_id, ref_id):
|
||
tenant_id = require_tenant(tenant_id)
|
||
row = select_one(db, REF_TABLE, {"id": ref_id, "tenant_id": tenant_id, "deleted": 0})
|
||
if not row:
|
||
raise NotFoundError("blueprint ref not found: %s" % ref_id)
|
||
return row
|
||
|
||
|
||
def remove_ref(db, tenant_id, ref_id, actor=None):
|
||
"""软删除关联边(不动被引用基表)。"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
require_actor(actor)
|
||
row = get_ref(db, tenant_id, ref_id)
|
||
update(db, REF_TABLE, {"id": ref_id},
|
||
{"deleted": 1, "updated_by": actor, "updated_at": now_iso()})
|
||
write_audit(db, tenant_id, "ref.remove", REF_TABLE, ref_id, actor=actor,
|
||
detail={"blueprint_id": row.get("blueprint_id"),
|
||
"ref": "%s:%s/%s" % (row.get("ref_domain"), row.get("ref_table"),
|
||
row.get("ref_id"))})
|
||
return {"ok": True, "id": ref_id, "deleted": True}
|
||
|
||
|
||
def resolve_refs(db, tenant_id, blueprint_id=None, actor=None, reader=None, limit=500):
|
||
"""引用有效性判定:逐条探测被引用对象是否存在(只读),写回 resolve_status。
|
||
|
||
reader: 可选回调 ``reader(db, ref_table, ref_id) -> row|None``,由上层注入跨模块只读
|
||
查询能力(如 world 模块的 get_world)。未注入时按 ref_snapshot 是否存在
|
||
保守判定为 unknown(不臆断 missing,避免误报)。
|
||
"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
res = list_refs(db, tenant_id, blueprint_id=blueprint_id, limit=limit)
|
||
now = now_iso()
|
||
resolved, missing, unknown = [], [], []
|
||
for r in res.get("items", []):
|
||
status = "unknown"
|
||
if callable(reader):
|
||
try:
|
||
target = reader(db, r.get("ref_table"), r.get("ref_id"))
|
||
status = "resolved" if target else "missing"
|
||
except Exception:
|
||
status = "unknown"
|
||
elif r.get("ref_snapshot"):
|
||
status = "unknown"
|
||
update(db, REF_TABLE, {"id": r["id"]},
|
||
{"resolve_status": status, "resolved_at": now, "updated_at": now})
|
||
bucket = {"resolved": resolved, "missing": missing, "unknown": unknown}[status]
|
||
bucket.append(r["id"])
|
||
if missing and actor:
|
||
write_audit(db, tenant_id, "ref.resolve", REF_TABLE, blueprint_id or "*", actor=actor,
|
||
detail={"missing_count": len(missing), "resolved": len(resolved)})
|
||
return {"ok": True, "total": res.get("total", 0), "resolved_count": len(resolved),
|
||
"missing_count": len(missing), "unknown_count": len(unknown),
|
||
"resolved": resolved, "missing": missing, "unknown": unknown,
|
||
"blocking": missing} # M2 校验:required=1 且 missing → 阻断
|
||
|
||
|
||
def impact_of(db, tenant_id, ref_domain, ref_table, ref_id):
|
||
"""反查影响面:某外部对象(如某 world)被本租户哪些蓝图引用。
|
||
|
||
供 world/scene 删除前置校验调用(只读,不阻塞基表写,不写基表)。
|
||
"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
ok, reason = classify_ref(ref_domain, ref_table)
|
||
if not ok:
|
||
raise ValidationError(reason, code="PBL_REF_NOT_ALLOWED")
|
||
res = list_refs(db, tenant_id, ref_domain=ref_domain, ref_table=ref_table, ref_id=ref_id,
|
||
limit=500)
|
||
bps = sorted({r.get("blueprint_id") for r in res.get("items", []) if r.get("blueprint_id")})
|
||
required = [r for r in res.get("items", []) if int(r.get("required") or 0)]
|
||
return {"ok": True, "ref": {"domain": ref_domain, "table": ref_table, "id": ref_id},
|
||
"ref_count": res.get("total", 0), "blueprint_ids": bps, "blueprint_count": len(bps),
|
||
"required_ref_count": len(required),
|
||
"safe_to_delete": len(required) == 0,
|
||
"warning": ("%d 个蓝图强依赖该对象,删除将导致蓝图引用失效" % len(required))
|
||
if required else None}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 从蓝图 content 自动抽取引用(编译器/校验器共用)
|
||
# --------------------------------------------------------------------------
|
||
_CONTENT_REF_KEYS = {
|
||
"world_id": ("world", "world"),
|
||
"world": ("world", "world"),
|
||
"scene_id": ("scene", "scene"),
|
||
"scene": ("scene", "scene"),
|
||
"entity_id": ("entity", "entity"),
|
||
"script_id": ("script_engine", "script_engine"),
|
||
"game_id": ("scense_game", "scense_game"),
|
||
"canvas_id": ("drag", "drag_canvas"),
|
||
}
|
||
|
||
|
||
def refs_from_content(content):
|
||
"""从蓝图/子对象 content(JSON) 中抽取跨域引用候选。
|
||
|
||
递归遍历 dict/list,命中 _CONTENT_REF_KEYS 的键即产出一条引用;
|
||
返回 [{ref_domain, ref_table, ref_id, src_kind, src_id, rel_type}]。
|
||
"""
|
||
content = as_dict(content, default=content)
|
||
out = []
|
||
|
||
def walk(node, src_kind, src_id):
|
||
if isinstance(node, dict):
|
||
kind = node.get("kind") or node.get("type") or src_kind
|
||
oid = node.get("id") or src_id
|
||
for k, v in node.items():
|
||
hit = _CONTENT_REF_KEYS.get(k)
|
||
if hit and isinstance(v, (str, int)) and str(v).strip():
|
||
out.append({"ref_domain": hit[0], "ref_table": hit[1],
|
||
"ref_id": str(v).strip(), "src_kind": kind or "blueprint",
|
||
"src_id": oid if kind and kind != "blueprint" else None,
|
||
"rel_type": "binds" if k.endswith("_id") else "uses"})
|
||
else:
|
||
walk(v, kind, oid)
|
||
elif isinstance(node, list):
|
||
for it in node:
|
||
walk(it, src_kind, src_id)
|
||
|
||
walk(content, "blueprint", None)
|
||
# 去重
|
||
seen, uniq = set(), []
|
||
for r in out:
|
||
key = (r["src_kind"], r["src_id"], r["ref_domain"], r["ref_table"], r["ref_id"], r["rel_type"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
uniq.append(r)
|
||
return uniq
|
||
|
||
|
||
def sync_refs_from_content(db, tenant_id, blueprint_id, content, actor=None, version_id=None):
|
||
"""按 content 全量同步关联边:新增缺失、软删多余(幂等,可重复执行)。"""
|
||
tenant_id = require_tenant(tenant_id)
|
||
require_actor(actor)
|
||
wanted = refs_from_content(content)
|
||
wanted_keys = {(w["src_kind"], w["src_id"] or "", w["ref_domain"], w["ref_table"],
|
||
w["ref_id"], w["rel_type"]) for w in wanted}
|
||
added, removed, failed = [], [], []
|
||
for w in wanted:
|
||
try:
|
||
res = add_ref(db, tenant_id, blueprint_id, dict(w, version_id=version_id), actor=actor)
|
||
if res.get("created"):
|
||
added.append(res["id"])
|
||
except ValidationError as e:
|
||
failed.append({"ref": w, "message": e.message})
|
||
cur = list_refs(db, tenant_id, blueprint_id=blueprint_id, limit=2000)
|
||
now = now_iso()
|
||
for r in cur.get("items", []):
|
||
key = (r.get("src_kind"), r.get("src_id") or "", r.get("ref_domain"),
|
||
r.get("ref_table"), r.get("ref_id"), r.get("rel_type"))
|
||
if key not in wanted_keys:
|
||
update(db, REF_TABLE, {"id": r["id"]},
|
||
{"deleted": 1, "updated_by": actor, "updated_at": now})
|
||
removed.append(r["id"])
|
||
write_audit(db, tenant_id, "ref.sync", REF_TABLE, blueprint_id, actor=actor,
|
||
detail={"added": len(added), "removed": len(removed), "failed": len(failed)})
|
||
return {"ok": True, "added_count": len(added), "removed_count": len(removed),
|
||
"added": added, "removed": removed, "failed": failed,
|
||
"total_wanted": len(wanted)}
|