pbl_blueprint/scripts/selfcheck.py

937 lines
42 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 M1a 自查脚本(可在机构工作空间根目录复跑)。
用法:
python3 modules/pbl_blueprint/scripts/selfcheck.py # 全量自查
python3 modules/pbl_blueprint/scripts/selfcheck.py --report PATH # 同时写报告
退出码0=全部通过1=存在失败项。
检查项(机械可验,全部基于真实落盘文件):
A 目录结构:包目录/__init__.py/init.py/pyproject.toml/scripts/load_path.py/skill/SKILL.md
B 表定义11 表 models/*.json 四段式(summary/fields/indexes/codes)、primary=["id"]、
id str32、tenant_id 首业务字段、金额 double(18,2)、索引 tenant_id 打头
C CRUD 定义11 个 json/*.json 根键 tblname+params、editable 三个 .dspy、browserfields 非空
D 代码质量py_compile 全量、禁硬编码库名、sqlor 白名单、三处注册同步
E RBACload_path.PATHS 与 init.RBAC_PATHS 一致、显式枚举无通配符
F 契约运行:内存假 sqlor 跑通 蓝图CRUD/树/fork/版本delta/模板实例化/离线兜底/发布/锁
G 租户 fail-closedtenant_id 缺失/空/通配符/越权一律拒绝
"""
import datetime
import io
import json
import os
import py_compile
import re
import sys
import traceback
HERE = os.path.dirname(os.path.abspath(__file__))
MODULE_ROOT = os.path.dirname(HERE)
PKG_DIR = os.path.join(MODULE_ROOT, "pbl_blueprint")
MODEL_DIR = os.path.join(PKG_DIR, "models")
JSON_DIR = os.path.join(PKG_DIR, "json")
if MODULE_ROOT not in sys.path:
sys.path.insert(0, MODULE_ROOT)
RESULTS = []
def check(group, name, ok, msg=""):
RESULTS.append({"group": group, "name": name, "ok": bool(ok), "msg": msg or ""})
return bool(ok)
EXPECT_TABLES = [
"pbl_blueprint",
"pbl_blueprint_node",
"pbl_blueprint_edge",
"pbl_blueprint_version",
"pbl_blueprint_version_delta",
"pbl_blueprint_template",
"pbl_blueprint_publish",
"pbl_blueprint_offline",
"pbl_blueprint_fork",
"pbl_blueprint_lock",
"pbl_blueprint_audit",
]
MONEY_FIELDS = ("budget_amount", "score_amount", "price_amount", "cost_amount", "weight")
# ------------------------------------------------------------------ A 目录结构
def check_structure():
g = "A.目录结构"
must_files = [
os.path.join(PKG_DIR, "__init__.py"),
os.path.join(PKG_DIR, "init.py"),
os.path.join(PKG_DIR, "errors.py"),
os.path.join(PKG_DIR, "tenant.py"),
os.path.join(PKG_DIR, "db.py"),
os.path.join(PKG_DIR, "crud.py"),
os.path.join(PKG_DIR, "audit.py"),
os.path.join(PKG_DIR, "service.py"),
os.path.join(MODULE_ROOT, "pyproject.toml"),
os.path.join(MODULE_ROOT, "README.md"),
os.path.join(HERE, "load_path.py"),
os.path.join(MODULE_ROOT, "skill", "SKILL.md"),
]
for f in must_files:
rel = os.path.relpath(f, MODULE_ROOT)
check(g, "存在 %s" % rel, os.path.isfile(f), "" if os.path.isfile(f) else "文件缺失")
check(g, "包目录名=模块名 pbl_blueprint/", os.path.isdir(PKG_DIR))
check(g, "models/ 目录存在", os.path.isdir(MODEL_DIR))
check(g, "json/ 目录存在", os.path.isdir(JSON_DIR))
bad = [x for x in ("module.json", "conf", "rp.json", "app.py", "Dockerfile")
if os.path.exists(os.path.join(MODULE_ROOT, x))]
check(g, "无违禁替代文件(module.json/conf/rp.json/app.py/Dockerfile)", not bad, str(bad))
# ------------------------------------------------------------------ B 表定义
def check_models():
g = "B.表定义"
files = sorted([f for f in os.listdir(MODEL_DIR) if f.endswith(".json")]) \
if os.path.isdir(MODEL_DIR) else []
names = [f[:-5] for f in files]
check(g, "models 表数量=11", len(names) == 11, "实际 %d: %s" % (len(names), names))
missing = [t for t in EXPECT_TABLES if t not in names]
extra = [t for t in names if t not in EXPECT_TABLES]
check(g, "11 表清单与预期一致", not missing and not extra,
"缺失=%s 多余=%s" % (missing, extra))
for fn in files:
tbl = fn[:-5]
path = os.path.join(MODEL_DIR, fn)
try:
with io.open(path, encoding="utf-8") as f:
m = json.load(f)
except Exception as e:
check(g, "%s JSON 可解析" % tbl, False, str(e))
continue
seg = [k for k in ("summary", "fields", "indexes", "codes") if k in m]
check(g, "%s 四段式齐备(summary/fields/indexes/codes)" % tbl, len(seg) == 4,
"%s" % [k for k in ("summary", "fields", "indexes", "codes") if k not in m])
check(g, "%s summary 非空" % tbl, bool(str(m.get("summary") or "").strip()))
check(g, '%s primary==["id"]' % tbl, m.get("primary") == ["id"], str(m.get("primary")))
flds = m.get("fields") or {}
order = list(flds.keys())
idf = flds.get("id") or {}
check(g, "%s id 为 str(32) notnull" % tbl,
idf.get("type") == "str" and int(idf.get("size") or 0) == 32 and idf.get("notnull"),
str(idf))
check(g, "%s tenant_id 为首业务字段(id 之后第 1 个)" % tbl,
len(order) >= 2 and order[0] == "id" and order[1] == "tenant_id", str(order[:3]))
tf = flds.get("tenant_id") or {}
check(g, "%s tenant_id str(32) notnull" % tbl,
tf.get("type") == "str" and int(tf.get("size") or 0) == 32 and tf.get("notnull"),
str(tf))
for name, spec in flds.items():
if name in MONEY_FIELDS and spec.get("type") == "double":
sz = spec.get("size")
good = isinstance(sz, list) and len(sz) == 2 and sz == [18, 2]
check(g, "%s.%s 金额 double(18,2)" % (tbl, name), good, str(sz))
check(g, "%s.%s 有 summary" % (tbl, name), bool(str(spec.get("summary") or "").strip()))
idxs = m.get("indexes") or {}
check(g, "%s indexes 非空" % tbl, len(idxs) > 0, "0 个索引")
for iname, ispec in idxs.items():
fs = ispec.get("fields") or []
check(g, "%s.%s 索引 tenant_id 打头" % (tbl, iname),
bool(fs) and fs[0] == "tenant_id", str(fs))
check(g, "%s.%s 有 unique 标记" % (tbl, iname), "unique" in ispec)
check(g, "%s.%s 有 summary" % (tbl, iname), bool(str(ispec.get("summary") or "").strip()))
for c in fs:
check(g, "%s.%s 索引列 %s 已定义" % (tbl, iname, c), c in flds)
codes = m.get("codes") or {}
for cname, cspec in codes.items():
items = cspec.get("items") or {}
check(g, "%s.codes.%s 有 items 且非空" % (tbl, cname), len(items) > 0)
check(g, "%s.codes.%s 有 summary" % (tbl, cname),
bool(str(cspec.get("summary") or "").strip()))
if cname in flds:
check(g, "%s.codes.%s 对应字段存在" % (tbl, cname), cname in flds)
# ------------------------------------------------------------------ C CRUD 定义
def check_crud_json():
g = "C.CRUD定义"
files = sorted([f for f in os.listdir(JSON_DIR) if f.endswith(".json")]) \
if os.path.isdir(JSON_DIR) else []
names = [f[:-5] for f in files]
check(g, "json/ CRUD 定义数量=11", len(names) == 11, "实际 %d" % len(names))
missing = [t for t in EXPECT_TABLES if t not in names]
check(g, "json/ 覆盖全部 11 表", not missing, "缺失=%s" % missing)
for fn in files:
tbl = fn[:-5]
path = os.path.join(JSON_DIR, fn)
try:
with io.open(path, encoding="utf-8") as f:
d = json.load(f)
except Exception as e:
check(g, "%s CRUD JSON 可解析" % tbl, False, str(e))
continue
keys = set(d.keys())
check(g, "%s 根键含 tblname+params" % tbl, "tblname" in keys and "params" in keys,
str(sorted(keys)))
check(g, "%s 无自创 table/list 根键" % tbl,
"table" not in keys and "list" not in keys, str(sorted(keys)))
check(g, "%s tblname 与文件名一致" % tbl, d.get("tblname") == tbl, str(d.get("tblname")))
ed = d.get("editable")
ok_ed = isinstance(ed, list) and len(ed) == 3 and all(
isinstance(x, str) and x.startswith("api/") and x.endswith(".dspy") for x in ed)
check(g, "%s editable 为 3 个 api/*.dspy" % tbl, ok_ed, str(ed))
bf = d.get("browserfields")
check(g, "%s browserfields 非空列表" % tbl,
isinstance(bf, list) and len(bf) > 0, str(bf))
params = d.get("params") or {}
check(g, "%s params 非空" % tbl, len(params) > 0, "0 字段")
porder = list(params.keys())
check(g, "%s params tenant_id 在 id 之后首位" % tbl,
len(porder) >= 2 and porder[0] == "id" and porder[1] == "tenant_id",
str(porder[:3]))
check(g, "%s params.tenant_id editable=false" % tbl,
(params.get("tenant_id") or {}).get("editable") is False,
str(params.get("tenant_id")))
check(g, "%s params.id editable=false" % tbl,
(params.get("id") or {}).get("editable") is False, str(params.get("id")))
# 与 models 字段一致性
mpath = os.path.join(MODEL_DIR, "%s.json" % tbl)
if os.path.isfile(mpath):
with io.open(mpath, encoding="utf-8") as f:
mm = json.load(f)
mfields = set((mm.get("fields") or {}).keys())
diff1 = [k for k in params if k not in mfields]
diff2 = [k for k in mfields if k not in params]
check(g, "%s params 与 models.fields 字段集一致" % tbl,
not diff1 and not diff2, "仅CRUD=%s 仅models=%s" % (diff1, diff2))
for bf_name in (bf or []):
check(g, "%s browserfields.%s 在 params 中" % (tbl, bf_name), bf_name in params)
# ------------------------------------------------------------------ D 代码质量
def check_code_quality():
g = "D.代码质量"
pys = []
for root, dirs, fs in os.walk(MODULE_ROOT):
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", ".venv")]
for f in fs:
if f.endswith(".py"):
pys.append(os.path.join(root, f))
check(g, "Python 文件数>0", len(pys) > 0, str(len(pys)))
fails = []
for p in pys:
try:
py_compile.compile(p, doraise=True, cfile=os.devnull)
except Exception as e:
fails.append("%s: %s" % (os.path.relpath(p, MODULE_ROOT), e))
check(g, "py_compile 全量编译通过(%d 文件)" % len(pys), not fails, "; ".join(fails))
hard = []
sqlor_bad = []
allowed_sqlor = {"C", "U", "D", "R", "I", "sqlExe"}
pat_hard = re.compile(r"(^|[^A-Za-z0-9_])(DBNAME\s*=|DB_NAME\s*=|dbname\s*=\s*['\"][^'\"]+['\"])")
pat_sor = re.compile(r"\bsor\.([A-Za-z_][A-Za-z0-9_]*)\s*\(")
pat_sor2 = re.compile(r"\b_sor\w*\.([A-Za-z_][A-Za-z0-9_]*)\s*\(")
for p in pys:
rel = os.path.relpath(p, MODULE_ROOT)
if rel.startswith("scripts") or rel.startswith("tests"):
continue
txt = io.open(p, encoding="utf-8").read()
for i, line in enumerate(txt.splitlines(), 1):
st = line.strip()
if st.startswith("#"):
continue
if pat_hard.search(line):
hard.append("%s:%d %s" % (rel, i, st[:80]))
for m in list(pat_sor.finditer(line)) + list(pat_sor2.finditer(line)):
if m.group(1) not in allowed_sqlor:
sqlor_bad.append("%s:%d sor.%s" % (rel, i, m.group(1)))
check(g, "无硬编码库名(DBNAME=/DB_NAME=/dbname='')", not hard, "; ".join(hard[:5]))
check(g, "sqlor 仅用白名单 C/U/D/R/I/sqlExe", not sqlor_bad, "; ".join(sqlor_bad[:5]))
# 三处注册同步
try:
import pbl_blueprint as pkg
from pbl_blueprint import init as init_mod
contract = list(getattr(pkg, "CONTRACT_FUNCTIONS", ()))
reg = list(getattr(init_mod, "REGISTER_FUNCTIONS", ()))
fns = pkg.get_contract_functions()
check(g, "__init__.CONTRACT_FUNCTIONS 非空", len(contract) > 0, str(len(contract)))
check(g, "init.REGISTER_FUNCTIONS 与 CONTRACT_FUNCTIONS 一致",
sorted(contract) == sorted(reg),
"仅__init__=%s 仅init=%s" % ([x for x in contract if x not in reg],
[x for x in reg if x not in contract]))
undef = [n for n in contract if not callable(fns.get(n))]
check(g, "契约函数均已定义且可调用", not undef, str(undef))
first_bad = []
import inspect
for n in contract:
fn = fns.get(n)
if not callable(fn):
continue
try:
args = list(inspect.getargspec(fn).args) if hasattr(inspect, "getargspec") \
else list(inspect.signature(fn).parameters.keys())
except Exception:
args = []
if not args or args[0] != "tenant_id":
first_bad.append("%s(%s)" % (n, args[:1]))
check(g, "契约函数首参一律 tenant_id", not first_bad, "; ".join(first_bad[:6]))
tables = list(getattr(init_mod, "REGISTER_TABLES", ()))
check(g, "init.REGISTER_TABLES=11 且与 models 落盘一致",
sorted(tables) == sorted(EXPECT_TABLES) and
sorted(tables) == sorted([f[:-5] for f in os.listdir(MODEL_DIR) if f.endswith(".json")]),
str(tables))
check(g, "pkg.TABLES 与 init.REGISTER_TABLES 一致",
sorted(getattr(pkg, "TABLES", ())) == sorted(tables))
except Exception as e:
check(g, "三处注册同步可校验", False, "%s\n%s" % (e, traceback.format_exc()[-500:]))
# ------------------------------------------------------------------ E RBAC
def check_rbac():
g = "E.RBAC路径"
try:
sys.path.insert(0, HERE)
import load_path as lp
from pbl_blueprint import init as init_mod
errs = lp.validate()
check(g, "load_path.PATHS 合法(无通配符/无重复/.dspy)", not errs, "; ".join(errs[:5]))
wild = [p for p in lp.PATHS if "%" in p or "*" in p or "?" in p]
check(g, "load_path 无 %/*/? 通配符", not wild, str(wild))
same = list(lp.PATHS) == list(init_mod.RBAC_PATHS)
check(g, "load_path.PATHS 与 init.RBAC_PATHS 完全一致(含顺序)", same,
"仅load_path=%s 仅init=%s" % ([x for x in lp.PATHS if x not in init_mod.RBAC_PATHS],
[x for x in init_mod.RBAC_PATHS if x not in lp.PATHS]))
wild2 = [p for p in init_mod.RBAC_PATHS if "%" in p or "*" in p]
check(g, "init.RBAC_PATHS 无通配符", not wild2, str(wild2))
check(g, "RBAC 路径数>0", len(lp.PATHS) > 0, str(len(lp.PATHS)))
cov = set()
for t in EXPECT_TABLES:
for p in lp.PATHS:
if p.startswith("/api/%s/" % t):
cov.add(t)
check(g, "11 表均有 RBAC 路径覆盖", len(cov) == 11, "未覆盖=%s" % sorted(set(EXPECT_TABLES) - cov))
except Exception as e:
check(g, "RBAC 校验可执行", False, "%s\n%s" % (e, traceback.format_exc()[-400:]))
# ------------------------------------------------------------------ F 假 sqlor
class FakeSor(object):
"""内存假 sqlor只实现白名单 C/U/D/R/I/sqlExe用于契约级跑通验证。"""
def __init__(self, dbname="fake_db"):
self.dbname = dbname
self.tables = {}
self.calls = []
def _t(self, tbl):
return self.tables.setdefault(tbl, [])
def C(self, tbl, data):
self.calls.append(("C", tbl))
rows = self._t(tbl)
row = dict(data)
pk = row.get("id")
for r in rows:
if pk is not None and r.get("id") == pk:
raise Exception("Duplicate entry for PRIMARY")
rows.append(row)
return 1
def U(self, tbl, data, where, args=None):
self.calls.append(("U", tbl))
n = 0
for r in self._t(tbl):
if _match(r, where, args):
r.update(data)
n += 1
return n
def D(self, tbl, where, args=None):
self.calls.append(("D", tbl))
rows = self._t(tbl)
keep = [r for r in rows if not _match(r, where, args)]
n = len(rows) - len(keep)
self.tables[tbl] = keep
return n
def R(self, tbl, where, args=None, fields="*", order="", limit=0, offset=0):
self.calls.append(("R", tbl))
out = [dict(r) for r in self._t(tbl) if _match(r, where, args)]
if order:
for part in reversed([x.strip() for x in order.split(",") if x.strip()]):
desc = part.endswith(" desc")
col = part.replace(" desc", "").replace(" asc", "").strip()
out.sort(key=lambda r: (r.get(col) is None, r.get(col)), reverse=desc)
if offset:
out = out[offset:]
if limit:
out = out[:limit]
if fields and fields != "*":
cols = [c.strip() for c in fields.split(",") if c.strip()]
out = [{c: r.get(c) for c in cols} for r in out]
return out
def I(self, tbl, where="", args=None):
self.calls.append(("I", tbl))
return len([r for r in self._t(tbl) if _match(r, where, args)])
def sqlExe(self, sql, args=None):
self.calls.append(("sqlExe", sql.split()[0].lower()))
s = " ".join(sql.split()).lower()
if s.startswith("update pbl_blueprint_template set use_count=use_count+1"):
a = list(args or [])
n = 0
for r in self._t("pbl_blueprint_template"):
if len(a) >= 3 and r.get("tenant_id") == a[1] and r.get("id") == a[2]:
r["use_count"] = int(r.get("use_count") or 0) + 1
r["update_time"] = a[0]
n += 1
return n
if s.startswith("update pbl_blueprint_lock set status='expired'"):
a = list(args or [])
now = a[0] if a else ""
tid = a[1] if len(a) > 1 else ""
n = 0
for r in self._t("pbl_blueprint_lock"):
if r.get("tenant_id") == tid and r.get("status") == "holding" \
and (r.get("expire_time") or "") < now:
r["status"] = "expired"
n += 1
return n
if s.startswith("create table"):
m = re.search(r"create table if not exists ([a-z_0-9]+)", s)
if m:
self._t(m.group(1))
return 0
return 0
def _match(row, where, args):
"""极简 WHERE 求值:支持 tenant_id=? / and / or / in (...) / like / 比较 / is null。"""
where = str(where or "").strip()
if not where:
return True
args = list(args or [])
pos = [0]
def take():
v = args[pos[0]] if pos[0] < len(args) else None
pos[0] += 1
return v
def eval_or(s):
parts = _split_top(s, " or ")
return any(eval_and(p) for p in parts)
def eval_and(s):
parts = _split_top(s, " and ")
return all(eval_atom(p) for p in parts)
def eval_atom(s):
s = s.strip()
while s.startswith("(") and _balanced(s):
s = s[1:-1].strip()
low = s.lower()
m = re.match(r"^([a-z_0-9]+)\s+in\s*\((.*)\)$", low)
if m:
col = m.group(1)
n = m.group(2).count("?") + 1
vals = [take() for _ in range(n)]
return row.get(col) in vals
if low.endswith(" is null"):
col = low[:-8].strip()
return row.get(col) is None
if low.endswith(" is not null"):
col = low[:-12].strip()
return row.get(col) is not None
m = re.match(r"^([a-z_0-9]+)\s*(>=|<=|!=|<>|=|>|<)\s*(\?.*)$", low)
if m:
col, op, rest = m.group(1), m.group(2), m.group(3)
val = take()
if rest.strip() != "?":
return True
rv = row.get(col)
try:
if op == "=":
return str(rv) == str(val)
if op in ("!=", "<>"):
return str(rv) != str(val)
if isinstance(rv, (int, float)) and isinstance(val, (int, float)):
return {" >": 0}.get(" ") is None and _cmp(rv, op, val)
return _cmp(str(rv or ""), op, str(val or ""))
except Exception:
return False
m = re.match(r"^([a-z_0-9]+)\s+like\s+\?$", low)
if m:
col = m.group(1)
pat = str(take() or "")
rx = "^" + re.escape(pat).replace("%", ".*").replace("_", ".") + "$"
return re.match(rx, str(row.get(col) or "")) is not None
# 未识别片段:消耗其占位符后放行
for _ in range(s.count("?")):
take()
return True
return eval_or(where)
def _cmp(a, op, b):
if op == ">":
return a > b
if op == ">=":
return a >= b
if op == "<":
return a < b
if op == "<=":
return a <= b
return False
def _split_top(s, sep):
out, depth, cur, i = [], 0, "", 0
low = s.lower()
while i < len(s):
if s[i] == "(":
depth += 1
elif s[i] == ")":
depth -= 1
if depth == 0 and low.startswith(sep, i):
out.append(cur)
cur = ""
i += len(sep)
continue
cur += s[i]
i += 1
out.append(cur)
return [x for x in out if x.strip()]
def _balanced(s):
if not s.startswith("("):
return False
d = 0
for i, ch in enumerate(s):
if ch == "(":
d += 1
elif ch == ")":
d -= 1
if d == 0:
return i == len(s) - 1
return False
def _install_fake_sor():
sor = FakeSor()
from pbl_blueprint import db as _db
_db.clear_cache()
_db.get_sor = lambda dbname=None: sor
_db.get_dbname = lambda: sor.dbname
return sor
# ------------------------------------------------------------------ F 契约跑通
def check_runtime():
g = "F.契约运行"
try:
sor = _install_fake_sor()
import pbl_blueprint as bp
from pbl_blueprint.errors import PblBlueprintError
T = "t_a"
T2 = "t_b"
r = bp.create_blueprint(T, "海洋生态PBL", subject="科学", grade="五年级", op_user="u1", sor=sor)
check(g, "create_blueprint 成功", r["success"] and r["data"]["tenant_id"] == T, str(r)[:120])
bid = r["data"]["id"]
check(g, "create_blueprint 生成 str32 id", len(bid) == 32, bid)
check(g, "create_blueprint 默认 status=draft/quality=q0",
r["data"]["status"] == "draft" and r["data"]["quality_status"] == "q0")
check(g, "create_blueprint 自动编码 PBL-BP-", str(r["data"]["code"]).startswith("PBL-BP-"),
r["data"]["code"])
got = bp.get_blueprint(T, bid, sor=sor)
check(g, "get_blueprint 命中", got["success"] and got["data"]["id"] == bid)
try:
bp.get_blueprint(T2, bid, sor=sor)
check(g, "跨租户 get 被拒(fail-closed)", False, "未抛错")
except PblBlueprintError as e:
check(g, "跨租户 get 被拒(fail-closed)", e.errcode == bp.ERR_NOT_FOUND, e.errcode)
upd = bp.update_blueprint(T, bid, {"summary": "更新简介", "duration_hours": 12},
op_user="u1", sor=sor)
check(g, "update_blueprint 成功", upd["success"] and upd["data"]["affected"] >= 1, str(upd)[:120])
try:
bp.update_blueprint(T, bid, {"tenant_id": T2}, sor=sor)
check(g, "update 禁改 tenant_id", False, "未抛错")
except PblBlueprintError as e:
check(g, "update 禁改 tenant_id", e.errcode == bp.ERR_PARAM_INVALID, e.errcode)
lst = bp.list_blueprints(T, {"subject": "科学"}, sor=sor)
check(g, "list_blueprints 条件命中", lst["data"]["total"] >= 1, str(lst["data"]["total"]))
lst2 = bp.list_blueprints(T2, {}, sor=sor)
check(g, "list_blueprints 租户隔离(他租户 0 条)", lst2["data"]["total"] == 0,
str(lst2["data"]["total"]))
# 节点7 类各建一个
node_ids = {}
for nt in bp.NODE_TYPES:
rr = bp.create_node(T, bid, nt, "%s节点" % nt, spec_json={"k": nt},
weight=1.5, op_user="u1", sor=sor)
okk = rr["success"] and rr["data"]["node_type"] == nt
node_ids[nt] = rr["data"]["id"] if okk else ""
check(g, "create_node[%s] 成功" % nt, okk, str(rr)[:120])
check(g, "7 类子对象全部可建", len([v for v in node_ids.values() if v]) == 7,
str(len(bp.NODE_TYPES)))
sub = bp.create_node(T, bid, "task", "子任务", parent_id=node_ids["task"], sor=sor)
check(g, "create_node 带 parent_id 成功", sub["success"], str(sub)[:120])
try:
bp.create_node(T, bid, "badtype", "x", sor=sor)
check(g, "非法 node_type 被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "非法 node_type 被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode)
try:
bp.create_node(T, bid, "task", "嵌套task", parent_id=node_ids["task"], sor=sor)
check(g, "task 嵌套 task 被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "task 嵌套 task 被拒", e.errcode == bp.ERR_STATE_INVALID, e.errcode)
tree = bp.get_blueprint_tree(T, bid, sor=sor)
check(g, "get_blueprint_tree node_count=8",
tree["data"]["node_count"] == 8, str(tree["data"]["node_count"]))
check(g, "get_blueprint_tree roots=7(1 个 task 子节点挂在父下)",
len(tree["data"]["roots"]) == 7, str(len(tree["data"]["roots"])))
# 边
e1 = bp.create_edge(T, bid, node_ids["role"], node_ids["task"], "unlocks", sor=sor)
check(g, "create_edge 成功", e1["success"], str(e1)[:120])
try:
bp.create_edge(T, bid, node_ids["role"], node_ids["task"], "unlocks", sor=sor)
check(g, "重复边被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "重复边被拒", e.errcode == bp.ERR_DUPLICATE, e.errcode)
try:
bp.create_edge(T, bid, node_ids["task"], node_ids["role"], "depends", sor=sor)
check(g, "成环边被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "成环边被拒", e.errcode == bp.ERR_STATE_INVALID, e.errcode)
try:
bp.create_edge(T, bid, node_ids["role"], node_ids["role"], "depends", sor=sor)
check(g, "自环边被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "自环边被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode)
# 版本 + delta
v1 = bp.save_version(T, bid, "draft", "初版", op_user="u1", sor=sor)
check(g, "save_version v1 成功", v1["success"] and v1["data"]["version"]["version_no"] == 1,
str(v1)[:150])
bp.update_node(T, node_ids["rule"], {"name": "规则改名"}, op_user="u1", sor=sor)
bp.create_node(T, bid, "resource", "新增资源", sor=sor)
v2 = bp.save_version(T, bid, "minor", "改规则+加资源", op_user="u1", sor=sor)
check(g, "save_version v2 成功", v2["success"] and v2["data"]["version"]["version_no"] == 2)
check(g, "v2 change_delta 检出变更(>=2)", v2["data"]["delta_count"] >= 2,
str(v2["data"]["delta_count"]))
d = bp.get_change_delta(T, bid, 1, 2, sor=sor)
check(g, "get_change_delta 返回差异行", d["success"] and d["data"]["total"] >= 2,
str(d["data"]["total"]))
ops = set(x["op_type"] for x in d["data"]["rows"])
check(g, "change_delta 含 add/update", "add" in ops and "update" in ops, str(ops))
vs = bp.list_versions(T, bid, sor=sor)
check(g, "list_versions 返回 2 个版本", vs["data"]["total"] == 2, str(vs["data"]["total"]))
bp2 = bp.get_blueprint(T, bid, sor=sor)
check(g, "current_version 回写为 2", int(bp2["data"]["current_version"]) == 2,
str(bp2["data"]["current_version"]))
# fork
fk = bp.fork_blueprint(T, bid, "副本蓝图", "copy", op_user="u2", sor=sor)
check(g, "fork_blueprint 成功", fk["success"], str(fk)[:150])
check(g, "fork 复制节点数=9", fk["data"]["node_count"] == 9, str(fk["data"]["node_count"]))
check(g, "fork 复制边数=1", fk["data"]["edge_count"] == 1, str(fk["data"]["edge_count"]))
newid = fk["data"]["blueprint"]["id"]
check(g, "fork 新蓝图 source=fork 且 source_id 指向源",
fk["data"]["blueprint"]["source"] == "fork" and
fk["data"]["blueprint"]["source_id"] == bid)
fkl = bp.list_forks(T, bid, "children", sor=sor)
check(g, "list_forks(children) 命中血缘", fkl["data"]["total"] >= 1,
str(fkl["data"]["total"]))
fkt = bp.get_blueprint_tree(T, newid, sor=sor)
check(g, "fork 后新蓝图树节点数=9", fkt["data"]["node_count"] == 9,
str(fkt["data"]["node_count"]))
# 模板 + 实例化
tpl_payload = {
"blueprint": {"subject": "数学", "grade": "三年级", "summary": "模板"},
"nodes": [
{"id": "n1", "code": "T1", "name": "角色A", "node_type": "role",
"spec_json": {"desc": "a"}},
{"id": "n2", "code": "T2", "name": "任务A", "node_type": "task",
"parent_id": "n1"},
{"id": "n3", "code": "T3", "name": "产出A", "node_type": "artifact"},
],
"edges": [{"from": "n1", "to": "n2", "edge_type": "unlocks"}],
}
ct = bp.create_template(T, "数学模板", category="stem", payload=tpl_payload,
price_amount=99.5, op_user="u1", sor=sor)
check(g, "create_template 成功", ct["success"], str(ct)[:150])
check(g, "create_template 统计 node_count=3/edge_count=1",
ct["data"]["node_count"] == 3 and ct["data"]["edge_count"] == 1,
"%s/%s" % (ct["data"]["node_count"], ct["data"]["edge_count"]))
tid_ = ct["data"]["id"]
inst = bp.instantiate_template(T, tid_, "实例化蓝图", owner_id="u9", sor=sor)
check(g, "instantiate_template 成功", inst["success"], str(inst)[:150])
check(g, "实例化节点数=3", inst["data"]["node_count"] == 3, str(inst["data"]["node_count"]))
check(g, "实例化边数=1", inst["data"]["edge_count"] == 1, str(inst["data"]["edge_count"]))
check(g, "实例化蓝图 source=template",
inst["data"]["blueprint"]["source"] == "template")
itree = bp.get_blueprint_tree(T, inst["data"]["blueprint"]["id"], sor=sor)
check(g, "实例化后树 roots=2(n2 挂 n1 下)", len(itree["data"]["roots"]) == 2,
str(len(itree["data"]["roots"])))
tl = bp.list_templates(T, {}, sor=sor)
check(g, "list_templates 命中", tl["data"]["total"] >= 1, str(tl["data"]["total"]))
try:
bp.instantiate_template(T2, tid_, "越权实例化", sor=sor)
check(g, "他租户实例化模板被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "他租户实例化模板被拒", e.errcode == bp.ERR_NOT_FOUND, e.errcode)
# 离线兜底
exp = bp.export_offline(T, bid, "海洋离线包", op_user="u1", sor=sor)
check(g, "export_offline 成功", exp["success"], str(exp)[:150])
check(g, "export_offline 生成 sha256 校验和(64位)",
len(exp["data"]["checksum"]) == 64, exp["data"]["checksum"][:16])
check(g, "export_offline file_size>0", int(exp["data"]["file_size"]) > 0,
str(exp["data"]["file_size"]))
off_row = [r for r in sor.tables["pbl_blueprint_offline"] if r["id"] == exp["data"]["id"]][0]
imp = bp.import_offline(T, off_row["pkg_json"], "离线导入蓝图", op_user="u1", sor=sor)
check(g, "import_offline 成功", imp["success"], str(imp)[:150])
check(g, "import_offline 节点数>0", imp["data"]["node_count"] > 0,
str(imp["data"]["node_count"]))
pay = bp.instantiate_payload(T, tpl_payload, "payload兜底实例化", sor=sor)
check(g, "instantiate_payload 离线兜底成功", pay["success"], str(pay)[:150])
try:
bp.import_offline(T, {"schema_version": "9.9", "nodes": [1]}, sor=sor)
check(g, "schema_version 不兼容被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "schema_version 不兼容被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode)
try:
bad_pkg = dict(json.loads(off_row["pkg_json"]))
bad_pkg["checksum"] = "0" * 64
bp.import_offline(T, bad_pkg, sor=sor)
check(g, "校验和不匹配被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "校验和不匹配被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode)
# 发布
pub = bp.publish_blueprint(T, bid, 0, "class", "cls_1", class_id="cls_1",
op_user="u1", sor=sor)
check(g, "publish_blueprint 成功", pub["success"], str(pub)[:150])
check(g, "发布后蓝图 status=published",
bp.get_blueprint(T, bid, sor=sor)["data"]["status"] == "published")
pub2 = bp.publish_blueprint(T, bid, 0, "class", "cls_1", class_id="cls_1",
op_user="u1", sor=sor)
check(g, "重复发布幂等(idempotent=True)",
pub2["success"] and pub2["data"]["idempotent"] is True, str(pub2)[:120])
rv = bp.revoke_publish(T, pub["data"]["publish"]["id"], op_user="u1", sor=sor)
check(g, "revoke_publish 成功", rv["success"], str(rv)[:120])
# 锁
lk = bp.lock_blueprint(T, bid, "u1", holder_name="张三", ttl_seconds=600, sor=sor)
check(g, "lock_blueprint 成功", lk["success"] and lk["data"]["rev"] == 1, str(lk)[:120])
lk2 = bp.lock_blueprint(T, bid, "u1", ttl_seconds=600, sor=sor)
check(g, "同人续锁 rev 递增", lk2["success"] and lk2["data"]["rev"] == 2,
str(lk2["data"].get("rev")))
try:
bp.lock_blueprint(T, bid, "u2", sor=sor)
check(g, "他人抢锁被拒(ERR_LOCKED)", False, "未抛错")
except PblBlueprintError as e:
check(g, "他人抢锁被拒(ERR_LOCKED)", e.errcode == bp.ERR_LOCKED, e.errcode)
try:
bp.unlock_blueprint(T, bid, "u2", sor=sor)
check(g, "非持锁人解锁被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "非持锁人解锁被拒", e.errcode == bp.ERR_FORBIDDEN, e.errcode)
ul = bp.unlock_blueprint(T, bid, "u1", sor=sor)
check(g, "持锁人解锁成功", ul["success"] and ul["data"]["released"] is True, str(ul)[:120])
cl = bp.clean_expired_locks(T, sor=sor)
check(g, "clean_expired_locks 可执行", cl["success"], str(cl)[:120])
# 统计 + 删除
st = bp.blueprint_stats(T, bid, sor=sor)
check(g, "blueprint_stats 返回 7 类节点计数",
len(st["data"]["node_by_type"]) == 7, str(st["data"]["node_by_type"]))
check(g, "blueprint_stats node_total>=9", st["data"]["node_total"] >= 9,
str(st["data"]["node_total"]))
dl = bp.delete_node(T, node_ids["resource"], op_user="u1", sor=sor)
check(g, "delete_node 成功", dl["success"], str(dl)[:120])
dbp = bp.delete_blueprint(T, newid, op_user="u1", cascade=True, sor=sor)
check(g, "delete_blueprint 级联成功", dbp["success"] and dbp["data"]["cascade"] > 0,
str(dbp["data"]))
try:
bp.get_blueprint(T, newid, sor=sor)
check(g, "删除后 get 不可见(逻辑删除)", False, "未抛错")
except PblBlueprintError as e:
check(g, "删除后 get 不可见(逻辑删除)", e.errcode == bp.ERR_NOT_FOUND, e.errcode)
# 审计 append-only
au = bp.list_audit(T, bid, sor=sor)
check(g, "审计记录已写入(>0)", au["data"]["total"] > 0, str(au["data"]["total"]))
try:
bp.crud_update("pbl_blueprint_audit", T, au["data"]["rows"][0]["id"],
{"action": "x"}, sor=sor)
check(g, "审计表禁改(append-only)", False, "未抛错")
except PblBlueprintError as e:
check(g, "审计表禁改(append-only)", e.errcode == bp.ERR_FORBIDDEN, e.errcode)
try:
bp.crud_delete("pbl_blueprint_audit", T, au["data"]["rows"][0]["id"], sor=sor)
check(g, "审计表禁删(append-only)", False, "未抛错")
except PblBlueprintError as e:
check(g, "审计表禁删(append-only)", e.errcode == bp.ERR_FORBIDDEN, e.errcode)
# 所有写操作 tenant_id 打头
no_tenant = [r for tbl, rows in sor.tables.items() for r in rows
if "tenant_id" in r and not r.get("tenant_id")]
check(g, "落库行 tenant_id 全部非空", not no_tenant, str(len(no_tenant)))
check(g, "11 张表均有数据落库", len(sor.tables) >= 11, str(sorted(sor.tables.keys())))
api_used = sorted(set(c for c, _ in sor.calls))
check(g, "运行时仅调用 sqlor 白名单 API",
set(api_used) <= {"C", "U", "D", "R", "I", "sqlExe"}, str(api_used))
except Exception as e:
check(g, "契约运行无异常", False, "%s\n%s" % (e, traceback.format_exc()[-1200:]))
# ------------------------------------------------------------------ G 租户 fail-closed
def check_tenant():
g = "G.租户fail-closed"
try:
sor = _install_fake_sor()
import pbl_blueprint as bp
from pbl_blueprint.errors import PblBlueprintError
bad_inputs = [None, "", " ", "*", "tenant%", "a" * 33, "t'or'1=1"]
for v in bad_inputs:
try:
bp.create_blueprint(v, "x", sor=sor)
check(g, "非法 tenant_id 被拒: %r" % (v,), False, "未抛错")
except PblBlueprintError as e:
check(g, "非法 tenant_id 被拒: %r" % (v,),
e.errcode == bp.ERR_TENANT_MISSING, e.errcode)
try:
bp.require_tenant("t_a", "t_b")
check(g, "上下文租户不一致被拒", False, "未抛错")
except PblBlueprintError as e:
check(g, "上下文租户不一致被拒", e.errcode == bp.ERR_FORBIDDEN, e.errcode)
w, a = bp.tenant_where("t_a", "id=?", ["x"])
check(g, "tenant_where 以 tenant_id=? 开头", str(w).startswith("tenant_id=?"), str(w))
check(g, "tenant_where 参数 tenant_id 打头", a[0] == "t_a", str(a))
inj = bp.inject_tenant("t_a", {"name": "n", "tenant_id": "hack"})
check(g, "inject_tenant 覆盖外部 tenant_id 且置首位",
list(inj.keys())[0] == "tenant_id" and inj["tenant_id"] == "t_a", str(inj))
# 契约函数首参 tenant_id运行时再验一次
import inspect
bad = []
for n in bp.CONTRACT_FUNCTIONS:
fn = getattr(bp, n, None)
if not callable(fn):
bad.append(n)
continue
try:
params = list(inspect.signature(fn).parameters.keys())
except Exception:
params = []
if not params or params[0] != "tenant_id":
bad.append(n)
check(g, "全部契约函数首参为 tenant_id", not bad, str(bad))
except Exception as e:
check(g, "租户校验可执行", False, "%s\n%s" % (e, traceback.format_exc()[-600:]))
# ------------------------------------------------------------------ H DDL
def check_ddl():
g = "H.DDL生成"
try:
from pbl_blueprint import init as init_mod
txt = init_mod.ddl_script()
check(g, "ddl_script 生成非空", len(txt) > 500, str(len(txt)))
for t in EXPECT_TABLES:
check(g, "DDL 含 create table %s" % t, ("create table if not exists `%s`" % t) in txt)
check(g, "DDL 无库名前缀硬编码", "`.`" not in txt and "use " not in txt.lower().split("\n")[0])
check(g, "金额列 DDL 为 decimal(18,2)", "decimal(18,2)" in txt)
check(g, "主键均为 id", txt.count("primary key (`id`)") == len(EXPECT_TABLES),
str(txt.count("primary key (`id`)")))
except Exception as e:
check(g, "DDL 生成可执行", False, "%s\n%s" % (e, traceback.format_exc()[-400:]))
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
report = ""
if "--report" in argv:
i = argv.index("--report")
if i + 1 < len(argv):
report = argv[i + 1]
t0 = datetime.datetime.now()
check_structure()
check_models()
check_crud_json()
check_code_quality()
check_rbac()
check_runtime()
check_tenant()
check_ddl()
t1 = datetime.datetime.now()
total = len(RESULTS)
passed = len([r for r in RESULTS if r["ok"]])
failed = [r for r in RESULTS if not r["ok"]]
lines = []
lines.append("=" * 78)
lines.append("pbl_blueprint M1a 自查报告")
lines.append("模块路径 : %s" % MODULE_ROOT)
lines.append("执行时间 : %s ~ %s (%.2fs)" % (t0.strftime("%F %T"), t1.strftime("%F %T"),
(t1 - t0).total_seconds()))
lines.append("Python : %s" % sys.version.split()[0])
lines.append("=" * 78)
groups = []
for r in RESULTS:
if r["group"] not in groups:
groups.append(r["group"])
for gp in groups:
rs = [r for r in RESULTS if r["group"] == gp]
ps = len([r for r in rs if r["ok"]])
lines.append("")
lines.append("[%s] %d/%d 通过" % (gp, ps, len(rs)))
for r in rs:
flag = "PASS" if r["ok"] else "FAIL"
lines.append(" %-4s %s%s" % (flag, r["name"], (" | " + r["msg"]) if (r["msg"] and not r["ok"]) else ""))
lines.append("")
lines.append("-" * 78)
lines.append("合计: %d 项, 通过 %d, 失败 %d" % (total, passed, len(failed)))
if failed:
lines.append("失败清单:")
for r in failed:
lines.append(" * [%s] %s | %s" % (r["group"], r["name"], r["msg"]))
lines.append("结论: %s" % ("ALL PASS" if not failed else "HAS FAILURES"))
lines.append("-" * 78)
txt = "\n".join(lines)
print(txt)
if report:
d = os.path.dirname(os.path.abspath(report))
if d and not os.path.isdir(d):
os.makedirs(d)
with io.open(report, "w", encoding="utf-8") as f:
f.write(txt + "\n")
print("报告已写入: %s" % report)
return 0 if not failed else 1
if __name__ == "__main__":
sys.exit(main())