2026-09-16 16:25:33 +08:00

371 lines
17 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 -*-
"""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)}