From b0c7e641c67e41ba321ebd0574853596695e740a Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Wed, 16 Sep 2026 10:52:49 +0800 Subject: [PATCH] =?UTF-8?q?[M1a]=20pbl=5Fblueprint=20=E8=93=9D=E5=9B=BE?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E8=A1=A8=E4=B8=8ECRUD=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=BB=BA=20errors.py=EF=BC=8814=20=E7=AC=A6?= =?UTF-8?q?=E5=8F=B7=E9=94=99=E8=AF=AF=E7=A0=81=E5=86=85=E6=A0=B8=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=95=B4=E5=8C=85=20import=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=EF=BC=89+=20selfcheck.py=20=E9=80=80=E5=87=BA?= =?UTF-8?q?=E7=A0=81=E9=97=A8=E7=A6=81=EF=BC=88FAIL=E2=86=92rc=3D1?= =?UTF-8?q?=EF=BC=8C8=20=E6=A3=80=E6=9F=A5=E7=BB=84=20A~H=EF=BC=89+=20git?= =?UTF-8?q?=20add=20-A=20=E6=94=B6=E5=8F=A3=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=EF=BC=88models/json=20=E5=90=84=2011=20=E4=B8=AA=20.json?= =?UTF-8?q?=E3=80=81audit.py/crud.py=20=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 132 ++-- pbl_blueprint/__init__.py | 174 +--- pbl_blueprint/api_blueprint.py | 594 ++++++++++++++ pbl_blueprint/errors.py | 247 ++++++ pbl_blueprint/init.py | 397 +++------- pbl_blueprint/subobject.py | 413 ++++++++++ pbl_blueprint/tables.py | 348 ++++++++ pyproject.toml | 23 +- scripts/fix_selfcheck_exit.py | 169 ++++ scripts/selfcheck.py | 1354 ++++++++++++-------------------- 10 files changed, 2419 insertions(+), 1432 deletions(-) create mode 100644 pbl_blueprint/api_blueprint.py create mode 100644 pbl_blueprint/errors.py create mode 100644 pbl_blueprint/subobject.py create mode 100644 pbl_blueprint/tables.py create mode 100644 scripts/fix_selfcheck_exit.py diff --git a/README.md b/README.md index 8663a23..4803afd 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,69 @@ -# pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板(M1a) +# pbl_blueprint — 蓝图聚合根(T04,★高风险件) -PBL(Project-Based Learning)平台的**蓝图域基础模块**。蓝图是整个 PBL 系统的 -聚合根:一份蓝图定义了一个项目的学习目标、角色分工、驱动问题(Mission)、任务、 -产出物规格、证据规格与反思规格,并带完整版本历史(`change_delta` 记录对话式改 -模型的每一步)。模板(`pbl_template`)提供蓝图骨架与 LLM 不可用时的离线兜底槽位。 +11 表 + **7 类子对象泛化契约** + 版本/change_delta + fork + 模板实例化 + 编辑锁。 -## 特性 +## 11 表 -- **多租户强制打头**:所有表的第二列即 `tenant_id`,所有读写 SQL 的 WHERE 第一 - 条件必须是 `tenant_id`;上下文缺失时 **fail-closed 抛 `PblTenantMissing`**, - 绝不降级为全租户可见。 -- **聚合根 + 7 类子对象**:`learning_goal / role / mission / task / - artifact_spec / evidence_spec / reflection_spec`,统一走 - `pbl_blueprint_subobject_save|list|delete` 三个泛化契约,新增子对象类型只需在 - `api.py: SUBOBJECTS` 注册一行 + 加 models/json 定义。 -- **版本化**:任何主表/子对象变更都会自动升 `version_no` 并追加一条 - `pbl_blueprint_version` 记录(含 `change_delta` 与全量 `snapshot_json`), - 为 M2 校验引擎与 M3 Compiler 提供可追溯的模型演化证据。 -- **模板实例化 + 离线兜底**:`pbl_template_instantiate` 展开 - `blueprint_snapshot`,缺失字段用 `offline_slots[].default` 填充(LLM 不可用 - 时仍能产出可用蓝图)。 -- **派生(fork)**:深拷贝聚合根与全部子对象,并重映射子对象之间的引用 - (`mission_id / task_id / artifact_spec_id / role_id`)。 -- **状态保护**:`status='published'` 的蓝图禁止直接改主表/子对象/删除,必须先 - fork 或回退状态(`PblStateConflict`)。 +`pbl_blueprint` · `pbl_blueprint_version` · `pbl_change_delta` · `pbl_subobject` · +`pbl_subobject_field` · `pbl_subobject_rel` · `pbl_blueprint_lock` · +`pbl_blueprint_fork` · `pbl_blueprint_ref` · `pbl_blueprint_status_log` · +`pbl_blueprint_template` -## 数据表(10 张) +## 7 类子对象(泛化单表,非 7 张分表) -| 表 | 说明 | -|----|------| -| `pbl_blueprint` | 蓝图聚合根 | -| `pbl_blueprint_learning_goal` | 子对象-学习目标 | -| `pbl_blueprint_role` | 子对象-PBL 角色 | -| `pbl_blueprint_mission` | 子对象-驱动问题/Mission | -| `pbl_blueprint_task` | 子对象-任务 | -| `pbl_blueprint_artifact_spec` | 子对象-产出物规格 | -| `pbl_blueprint_evidence_spec` | 子对象-证据规格 | -| `pbl_blueprint_reflection_spec` | 子对象-反思规格 | -| `pbl_blueprint_version` | 版本快照 + change_delta | -| `pbl_template` | 模板(含离线兜底槽位) | +| obj_type | 语义 | payload 必填字段 | +|----------|------|------------------| +| `learning_goal` | 学习目标 | statement | +| `task` | 任务/关卡 | objective | +| `role` | 角色 | — | +| `artifact` | 产出物 | kind(枚举 `pbl_evidence_kind`) | +| `rubric` | 评价量规 | criteria | +| `resource` | 资源 | uri | +| `rule` | 规则 | condition, action | -DDL 基线:`models/pbl_blueprint.subobjects.sql`;表定义四段式:`models/*.json` -(build.sh 用 `json2ddl mysql .` 生成 `mysql.ddl.sql`)。 - -## 契约接口(17 个,路径 `/pbl_blueprint/api/.dspy`) - -蓝图:`pbl_blueprint_create` / `_read` / `_update` / `_delete` / `_list` / -`_tree` / `_fork` / `_get_contract` -子对象:`pbl_blueprint_subobject_save` / `_list` / `_delete` -版本:`pbl_blueprint_version_create` / `_diff` -模板:`pbl_template_list` / `_instantiate` / `_save` / `_delete` - -统一返回:`{"status":"success","data":...,"total":N}` 或 -`{"status":"error","code":"PBL_XXX","message":"..."}`。 - -## 安装与集成(宿主应用 apps/pbls) +**泛化机制**:`pbl_subobject` 单表 + `obj_type` 判别 + `payload`(longtext JSON) 承载差异字段; +`pbl_subobject_field` 存字段元数据(tenant_id='' 为平台内置),驱动 payload 校验与前端表单; +DB 无元数据时回落 `subobject.BUILTIN_SCHEMA`(离线兜底)。 +**统一契约**(7 类共用,禁止每类单开 API): ```python -# app/pbls.py -from pbl_blueprint.init import load_pbl_blueprint - -def init(): - env = ServerEnv() - env.get_module_dbname = get_module_dbname # 模块 -> 库名映射,禁止模块内硬编码 - load_pbl_common() # 先加载公共内核(tenant 上下文/审计) - load_pbl_blueprint() # 再加载蓝图域 +list_subobjects(tenant_id, blueprint_id, obj_type=None, parent_id=None) +get_subobject(tenant_id, subobject_id) +upsert_subobject(tenant_id, blueprint_id, obj_type, name, payload=..., subobject_id=None) +delete_subobject(tenant_id, subobject_id, cascade=True) +reorder_subobject(tenant_id, blueprint_id, obj_type, ordered_ids) +get_tree(tenant_id, blueprint_id) # 供 T05 校验 / T06 编译消费 +validate_payload(tenant_id, obj_type, payload, strict=True) +add_rel / list_rels / delete_rel # 跨类型连线(aligns_to/produces/assesses...) ``` -```bash -cd apps/pbls/pkgs && git clone /pbl_blueprint && pip install ./pbl_blueprint -bash modules/pbl_blueprint/build.sh # DDL + CRUD UI + 软链 -./py3/bin/python modules/pbl_blueprint/scripts/load_path.py # RBAC 路径注册 -``` +## 版本与 change_delta -## 目录 +`commit_version()` → 确定性序列化(`sort_keys=True` + 紧凑分隔符,**不含时间戳**)→ sha256 → +写 `pbl_blueprint_version` 快照 → 与上一版比对生成 `pbl_change_delta`(added/modified/removed)。 +确定性序列化是 T06 `pbl_compiler` 幂等编译的前提。 + +`rollback_version()` 不改历史:软删现有子对象 → 按快照重建 → 提交为**新版本**。 + +## fork + +`fork_blueprint(mode='deep')` 深拷贝聚合根 + 全部子对象(按 parent 拓扑序重建 idmap)+ 关系 + 外部引用, +写 `pbl_blueprint_fork` 溯源;`get_fork_lineage()` 双向溯源。 + +## C4 写保护 + +`add_ref()` 只把 world/scene/entity/kdb/script 的 **ID + 快照** 存进 `pbl_blueprint_ref`, +模块内**不存在任何对写保护域基表的 C/U/D 调用**。快照保证外部变更不影响蓝图。 + +## 状态机 ``` -pbl_blueprint/ -├── pbl_blueprint/ # Python 包:__init__.py / init.py / api.py -├── models/ # 10 张表定义(四段式 JSON)+ DDL 基线 SQL -├── json/ # 10 份 CRUD 定义 -├── wwwroot/ # index.ui / menu.ui / api/*.dspy(17 个契约薄包装) -├── init/data.json # appcodes 10 组编码 + 2 份内置模板真实种子 -├── scripts/load_path.py # RBAC 显式路径(无通配符) -├── skill/SKILL.md # agent 可读模块规范 -├── pyproject.toml / build.sh / README.md +draft ⇄ in_review → approved → published → archived + └──────────────────────────────────────────┘(archived 可回 draft) ``` +非法流转 → `PBL-CONFLICT-0001`。质量级 5 级严格递进,**禁止跳级上升**(`PBL-VALID-0002`)。 -## 自测 +## 并发 -```bash -python3 -m py_compile pbl_blueprint/*.py scripts/load_path.py -python3 scripts/selftest.py # 无 DB 环境下的契约/租户 fail-closed 静态校验 -``` +`acquire_lock/release_lock` + 子对象 `version_no` 乐观锁; +`upsert_subobject` 更新时带 `extra_where=[('version_no','=',旧值)]`,不匹配即冲突。 diff --git a/pbl_blueprint/__init__.py b/pbl_blueprint/__init__.py index 480c6eb..f368c1d 100644 --- a/pbl_blueprint/__init__.py +++ b/pbl_blueprint/__init__.py @@ -1,173 +1,3 @@ # -*- coding: utf-8 -*- -"""pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板(M1a)。 - -模块契约(对外只暴露这些名字,三处同步注册:service 定义 / 本文件导出 / init.py env 注册): - 蓝图 CRUD : create_blueprint / get_blueprint / update_blueprint / delete_blueprint / list_blueprints - 树与子对象: get_blueprint_tree / create_node / update_node / delete_node / list_nodes - create_edge / delete_edge / list_edges - fork : fork_blueprint / list_forks - 版本 : save_version / list_versions / get_version / get_change_delta / rollback_version - 模板 : create_template / list_templates / instantiate_template / instantiate_payload - 离线兜底 : export_offline / import_offline / list_offline - 发布 : publish_blueprint / revoke_publish / list_publishes - 锁 : lock_blueprint / unlock_blueprint / clean_expired_locks - 统计/审计 : blueprint_stats / write_audit / list_audit - -所有契约函数第一个参数固定 tenant_id,缺失/非法即 fail-closed 抛 PblBlueprintError。 -""" - -__version__ = "1.0.0" -__module_name__ = "pbl_blueprint" - -from .errors import ( # noqa: F401 - PblBlueprintError, - ERR_OK, - ERR_TENANT_MISSING, - ERR_PARAM_INVALID, - ERR_NOT_FOUND, - ERR_DUPLICATE, - ERR_STATE_INVALID, - ERR_LOCKED, - ERR_FORBIDDEN, - ERR_DB, - ERR_INTERNAL, - ok, - fail, - err, -) -from .tenant import require_tenant, tenant_where, inject_tenant # noqa: F401 -from .db import get_dbname, get_sor, MODULE_NAME # noqa: F401 -from .crud import ( # noqa: F401 - list_tables, - table_fields, - load_model, - build_filter, - make_crud, - crud_create, - crud_get, - crud_update, - crud_delete, - crud_list, - crud_count, - new_id, - now_str, - APPEND_ONLY_TABLES, -) -from .audit import write_audit, list_audit, timed_audit # noqa: F401 -from .service import ( # noqa: F401 - NODE_TYPES, - EDGE_TYPES, - OFFLINE_SCHEMA_VERSION, - create_blueprint, - get_blueprint, - update_blueprint, - delete_blueprint, - list_blueprints, - get_blueprint_tree, - create_node, - update_node, - delete_node, - list_nodes, - create_edge, - delete_edge, - list_edges, - fork_blueprint, - list_forks, - save_version, - list_versions, - get_version, - get_change_delta, - rollback_version, - create_template, - list_templates, - instantiate_template, - instantiate_payload, - export_offline, - import_offline, - list_offline, - publish_blueprint, - revoke_publish, - list_publishes, - lock_blueprint, - unlock_blueprint, - clean_expired_locks, - blueprint_stats, -) - -# 对外契约函数清单(init.py 按此清单注册到 ServerEnv,三处同步的唯一事实源) -CONTRACT_FUNCTIONS = ( - "create_blueprint", "get_blueprint", "update_blueprint", "delete_blueprint", - "list_blueprints", "get_blueprint_tree", "create_node", "update_node", - "delete_node", "list_nodes", "create_edge", "delete_edge", "list_edges", - "fork_blueprint", "list_forks", "save_version", "list_versions", "get_version", - "get_change_delta", "rollback_version", "create_template", "list_templates", - "instantiate_template", "instantiate_payload", "export_offline", "import_offline", - "list_offline", "publish_blueprint", "revoke_publish", "list_publishes", - "lock_blueprint", "unlock_blueprint", "clean_expired_locks", "blueprint_stats", - "write_audit", "list_audit", -) - -# 本模块 11 张表(models/*.json 为唯一事实源,此处仅缓存清单便于挂载) -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", -) - -_loaded = False - - -def get_contract_functions(): - """返回 {函数名: 函数对象},供 init.py 注册与 selfcheck 校验三处同步。""" - import sys - mod = sys.modules[__name__] - out = {} - missing = [] - for name in CONTRACT_FUNCTIONS: - fn = getattr(mod, name, None) - if fn is None or not callable(fn): - missing.append(name) - continue - out[name] = fn - if missing: - raise ImportError("pbl_blueprint 契约函数未导出: %s" % ",".join(missing)) - return out - - -def load_pbl_blueprint(env=None, dbname=None, register_tables=True, - register_functions=True, register_rbac=True): - """挂载模块到应用(由 apps/pbls 的 init() 调用)。 - - :param env: ServerEnv 实例(可选,缺省自动获取) - :param dbname: 可选显式库名(缺省由 env.get_module_dbname('pbl_blueprint') 决定,禁硬编码) - :return: {"module":..., "dbname":..., "tables": n, "functions": n, "rbac_paths": n} - """ - global _loaded - from .init import init as _init - res = _init(env=env, dbname=dbname, register_tables=register_tables, - register_functions=register_functions, register_rbac=register_rbac) - _loaded = True - return res - - -def is_loaded(): - return _loaded - - -__all__ = [ - "__version__", "__module_name__", "MODULE_NAME", "TABLES", "CONTRACT_FUNCTIONS", - "NODE_TYPES", "EDGE_TYPES", "OFFLINE_SCHEMA_VERSION", "APPEND_ONLY_TABLES", - "PblBlueprintError", "ok", "fail", "err", - "require_tenant", "tenant_where", "inject_tenant", - "get_dbname", "get_sor", "list_tables", "table_fields", "load_model", - "build_filter", "make_crud", "new_id", "now_str", - "get_contract_functions", "load_pbl_blueprint", "is_loaded", -] + list(CONTRACT_FUNCTIONS) +"""pbl_blueprint —— 蓝图聚合根与子对象、版本、模板(M1a)。""" +__version__ = "0.1.0" diff --git a/pbl_blueprint/api_blueprint.py b/pbl_blueprint/api_blueprint.py new file mode 100644 index 0000000..cb88319 --- /dev/null +++ b/pbl_blueprint/api_blueprint.py @@ -0,0 +1,594 @@ +# -*- coding: utf-8 -*- +"""蓝图 CRUD / 树 / fork / 版本 / 模板实例化(M1a)。 + +所有函数首参 tenant_id,内部经 pbl_common.tenant_crud(C1)。 +状态流转严格按 tables.STATUS_TRANSITIONS,非法流转 fail-closed。 +""" +import hashlib +import json + +from pbl_common.audit import write_audit +from pbl_common.crud_factory import tenant_crud +from pbl_common.dbutil import new_id, now_str +from pbl_common.errors import fail + +from . import subobject as so +from .tables import (BLUEPRINT_STATUS, QUALITY_LEVELS, QUALITY_ORDER, + STATUS_TRANSITIONS, SUBOBJECT_TYPES) + +MODULE = "pbl_blueprint" +_bp = tenant_crud("pbl_blueprint", MODULE) +_ver = tenant_crud("pbl_blueprint_version", MODULE) +_delta = tenant_crud("pbl_change_delta", MODULE) +_lock = tenant_crud("pbl_blueprint_lock", MODULE) +_fork = tenant_crud("pbl_blueprint_fork", MODULE) +_ref = tenant_crud("pbl_blueprint_ref", MODULE) +_slog = tenant_crud("pbl_blueprint_status_log", MODULE) +_tpl = tenant_crud("pbl_blueprint_template", MODULE) + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def create_blueprint(tenant_id, name, code=None, description="", subject="", + grade="", duration_hours=0, owner_id="", source="manual", + ext=None): + if not name or not str(name).strip(): + fail("PBL-PARAM-0001", "name 必填") + code = str(code).strip() if code else "BP-%s" % new_id()[:10].upper() + if _bp.exists(tenant_id, [("code", "=", code), ("deleted", "=", 0)]): + fail("PBL-CONFLICT-0001", "蓝图编码已存在: %s" % code) + bid = new_id("bp") + _bp.insert(tenant_id, { + "id": bid, "code": code, "name": str(name).strip(), + "description": description or "", "status": "draft", + "quality_level": "draft", "version_no": 0, "current_version_id": "", + "subject": subject or "", "grade": grade or "", + "duration_hours": int(duration_hours or 0), "owner_id": owner_id or "", + "source": source if source in ("manual", "template", "fork", "agent") else "manual", + "locked": 0, "lock_holder": "", "ext": json.dumps(ext or {}, ensure_ascii=False), + "deleted": 0, "create_time": now_str(), + }, auto_id=False) + write_audit(tenant_id, "create", "pbl_blueprint", bid, + detail={"code": code, "source": source}) + return bid + + +def get_blueprint(tenant_id, blueprint_id): + row = _bp.get(tenant_id, blueprint_id) + if not row or int(row.get("deleted", 0) or 0) == 1: + fail("PBL-NOTFOUND-0001", "蓝图不存在: %s" % blueprint_id) + row["ext"] = _loads(row.get("ext")) + return row + + +def list_blueprints(tenant_id, status=None, quality_level=None, keyword=None, + page=1, page_size=20): + where = [("deleted", "=", 0)] + if status: + _check_status(status) + where.append(("status", "=", status)) + if quality_level: + _check_quality(quality_level) + where.append(("quality_level", "=", quality_level)) + if keyword: + where.append(("name", "LIKE", "%%%s%%" % keyword)) + return _bp.list(tenant_id, where, order_by="update_time DESC, create_time DESC", + page=page, page_size=page_size) + + +def update_blueprint(tenant_id, blueprint_id, data, lock_token=None): + row = get_blueprint(tenant_id, blueprint_id) + _assert_writable(row, tenant_id, lock_token) + allowed = {"name", "description", "subject", "grade", "duration_hours", "ext"} + payload = {k: v for k, v in (data or {}).items() if k in allowed} + if not payload: + fail("PBL-PARAM-0001", "无可更新字段(允许: %s)" % ",".join(sorted(allowed))) + if "ext" in payload and not isinstance(payload["ext"], str): + payload["ext"] = json.dumps(payload["ext"], ensure_ascii=False) + if "name" in payload and not str(payload["name"]).strip(): + fail("PBL-PARAM-0002", "name 不可为空") + _bp.update(tenant_id, blueprint_id, payload) + write_audit(tenant_id, "update", "pbl_blueprint", blueprint_id, + detail={"fields": sorted(payload.keys())}) + return True + + +def delete_blueprint(tenant_id, blueprint_id, lock_token=None): + row = get_blueprint(tenant_id, blueprint_id) + _assert_writable(row, tenant_id, lock_token) + if row["status"] == "published": + fail("PBL-CONFLICT-0001", "已发布蓝图不可删除,请先归档") + _bp.update(tenant_id, blueprint_id, + {"deleted": 1, "delete_time": now_str(), "status": "archived"}) + write_audit(tenant_id, "delete", "pbl_blueprint", blueprint_id) + return True + + +def get_tree(tenant_id, blueprint_id): + """蓝图完整树(聚合根 + 7 类子对象 + 关系)。""" + bp = get_blueprint(tenant_id, blueprint_id) + tree = so.get_tree(tenant_id, blueprint_id) + tree["blueprint"] = bp + tree["rels"] = so.list_rels(tenant_id, blueprint_id) + tree["refs"] = list_refs(tenant_id, blueprint_id) + return tree + + +# --------------------------------------------------------------------------- +# 状态 / 质量 +# --------------------------------------------------------------------------- + +def change_status(tenant_id, blueprint_id, to_status, reason="", + operator_id="", operator_type="human"): + _check_status(to_status) + row = get_blueprint(tenant_id, blueprint_id) + cur = row["status"] + if to_status not in STATUS_TRANSITIONS.get(cur, ()): + fail("PBL-CONFLICT-0001", + "非法状态流转 %s -> %s(允许: %s)" + % (cur, to_status, ",".join(STATUS_TRANSITIONS.get(cur, ())) or "无")) + _bp.update(tenant_id, blueprint_id, {"status": to_status}) + _slog.insert(tenant_id, { + "blueprint_id": blueprint_id, "from_status": cur, "to_status": to_status, + "from_quality": row["quality_level"], "to_quality": row["quality_level"], + "reason": reason or "", "operator_id": operator_id, + "operator_type": operator_type, "create_time": now_str(), + }) + write_audit(tenant_id, "update", "pbl_blueprint", blueprint_id, + detail={"status": "%s->%s" % (cur, to_status), "reason": reason}) + return {"from": cur, "to": to_status} + + +def set_quality_level(tenant_id, blueprint_id, quality_level, reason=""): + """质量级由 T05 pbl_validation 回写;只允许递进或回退一级,禁止跳级上升。""" + _check_quality(quality_level) + row = get_blueprint(tenant_id, blueprint_id) + cur_i = QUALITY_ORDER.get(row["quality_level"], 0) + new_i = QUALITY_ORDER[quality_level] + if new_i > cur_i + 1: + fail("PBL-VALID-0002", + "质量级不可跳级上升 %s -> %s" % (row["quality_level"], quality_level)) + _bp.update(tenant_id, blueprint_id, {"quality_level": quality_level}) + _slog.insert(tenant_id, { + "blueprint_id": blueprint_id, "from_status": row["status"], + "to_status": row["status"], "from_quality": row["quality_level"], + "to_quality": quality_level, "reason": reason or "", + "operator_id": "", "operator_type": "system", "create_time": now_str(), + }) + write_audit(tenant_id, "validate", "pbl_blueprint", blueprint_id, + detail={"quality": "%s->%s" % (row["quality_level"], quality_level), + "reason": reason}) + return {"from": row["quality_level"], "to": quality_level} + + +# --------------------------------------------------------------------------- +# 版本 / change_delta +# --------------------------------------------------------------------------- + +def _canonical(obj): + """确定性序列化(禁时间戳/随机序进入产物 —— T06 编译幂等的前提)。""" + return json.dumps(obj, ensure_ascii=False, sort_keys=True, + separators=(",", ":")) + + +def commit_version(tenant_id, blueprint_id, change_summary="", committed_by=""): + """生成版本快照 + 与上一版的 change_delta。""" + row = get_blueprint(tenant_id, blueprint_id) + _assert_writable(row, tenant_id) + tree = get_tree(tenant_id, blueprint_id) + tree.pop("blueprint", None) # 快照只含内容,不含易变元数据 + snap_obj = { + "subobjects": [{k: v for k, v in s.items() if k != "children"} + for s in tree.get("flat", [])], + "rels": tree.get("rels", []), + "refs": tree.get("refs", []), + } + snap = _canonical(snap_obj) + snap_hash = hashlib.sha256(snap.encode("utf-8")).hexdigest() + + new_no = int(row.get("version_no", 0) or 0) + 1 + vid = new_id("bpv") + _ver.insert(tenant_id, { + "id": vid, "blueprint_id": blueprint_id, "version_no": new_no, + "snapshot": snap, "snapshot_hash": snap_hash, + "quality_level": row["quality_level"], + "change_summary": (change_summary or "")[:500], + "committed_by": committed_by, "is_current": 1, "create_time": now_str(), + }, auto_id=False) + + # 旧当前版取消标记 + 计算 delta + prev = _ver.list(tenant_id, [("blueprint_id", "=", blueprint_id), + ("is_current", "=", 1)], page_size=50) + prev = [p for p in prev if p["id"] != vid] + delta_stat = {"added": 0, "modified": 0, "removed": 0} + if prev: + p = prev[0] + _ver.update(tenant_id, p["id"], {"is_current": 0}) + delta_stat = _write_delta(tenant_id, blueprint_id, p, vid, new_no, snap_obj) + + _bp.update(tenant_id, blueprint_id, + {"version_no": new_no, "current_version_id": vid}) + write_audit(tenant_id, "commit_version", "pbl_blueprint", blueprint_id, + detail={"version_no": new_no, "hash": snap_hash[:16], **delta_stat}) + return {"version_id": vid, "version_no": new_no, + "snapshot_hash": snap_hash, "delta": delta_stat} + + +def _write_delta(tenant_id, blueprint_id, prev_ver, to_vid, to_no, new_obj): + try: + old_obj = json.loads(prev_ver.get("snapshot") or "{}") + except Exception: + old_obj = {} + old_map = {s["id"]: s for s in old_obj.get("subobjects", [])} + new_map = {s["id"]: s for s in new_obj.get("subobjects", [])} + added = [new_map[k] for k in new_map if k not in old_map] + removed = [old_map[k] for k in old_map if k not in new_map] + modified = [] + for k in new_map: + if k in old_map: + o = _canonical({kk: vv for kk, vv in old_map[k].items() + if kk not in ("version_no", "update_time")}) + n = _canonical({kk: vv for kk, vv in new_map[k].items() + if kk not in ("version_no", "update_time")}) + if o != n: + modified.append({"id": k, "before": old_map[k], "after": new_map[k]}) + _delta.insert(tenant_id, { + "blueprint_id": blueprint_id, + "from_version_id": prev_ver["id"], "to_version_id": to_vid, + "from_version_no": int(prev_ver.get("version_no", 0) or 0), + "to_version_no": to_no, + "added": json.dumps(added, ensure_ascii=False), + "modified": json.dumps(modified, ensure_ascii=False), + "removed": json.dumps(removed, ensure_ascii=False), + "stat_added": len(added), "stat_modified": len(modified), + "stat_removed": len(removed), "create_time": now_str(), + }) + return {"added": len(added), "modified": len(modified), "removed": len(removed)} + + +def list_versions(tenant_id, blueprint_id, page=1, page_size=50): + rows = _ver.list(tenant_id, [("blueprint_id", "=", blueprint_id)], + order_by="version_no DESC", page=page, page_size=page_size) + for r in rows: + r.pop("snapshot", None) # 列表不返回大字段 + return rows + + +def get_version(tenant_id, version_id, with_snapshot=True): + row = _ver.get(tenant_id, version_id) + if not row: + fail("PBL-NOTFOUND-0001", "版本不存在: %s" % version_id) + if not with_snapshot: + row.pop("snapshot", None) + return row + + +def get_delta(tenant_id, blueprint_id, from_version_id=None, to_version_id=None): + where = [("blueprint_id", "=", blueprint_id)] + if from_version_id: + where.append(("from_version_id", "=", from_version_id)) + if to_version_id: + where.append(("to_version_id", "=", to_version_id)) + rows = _delta.list(tenant_id, where, order_by="create_time DESC", page_size=20) + for r in rows: + for k in ("added", "modified", "removed"): + r[k] = _loads(r.get(k), default=[]) + return rows + + +def rollback_version(tenant_id, blueprint_id, version_id, operator_id=""): + """回滚:把目标版本快照重新实例化为子对象集合,并提交为新版本(不改历史)。""" + ver = get_version(tenant_id, version_id) + if ver["blueprint_id"] != blueprint_id: + fail("PBL-PERM-0001", "版本不属于该蓝图") + snap = json.loads(ver.get("snapshot") or "{}") + # 现有子对象全部软删 + for s in so.list_subobjects(tenant_id, blueprint_id, page_size=2000): + so.delete_subobject(tenant_id, s["id"], cascade=False) + # 按快照重建 + idmap = {} + for s in snap.get("subobjects", []): + nid = so.upsert_subobject( + tenant_id, blueprint_id, s["obj_type"], s["name"], + payload=s.get("payload"), code=s.get("code"), + parent_id=idmap.get(s.get("parent_id"), ""), + seq=s.get("seq"), strict=False, operator_type="system") + idmap[s["id"]] = nid + for r in snap.get("rels", []): + src, dst = idmap.get(r["src_id"]), idmap.get(r["dst_id"]) + if src and dst: + try: + so.add_rel(tenant_id, blueprint_id, r["rel_type"], src, dst, + weight=r.get("weight", 1.0), payload=_loads(r.get("payload"))) + except Exception: + pass + res = commit_version(tenant_id, blueprint_id, + change_summary="rollback from v%s" % ver["version_no"], + committed_by=operator_id) + write_audit(tenant_id, "rollback", "pbl_blueprint", blueprint_id, + detail={"from_version": ver["version_no"], "to": res["version_no"]}) + return res + + +# --------------------------------------------------------------------------- +# fork +# --------------------------------------------------------------------------- + +def fork_blueprint(tenant_id, source_blueprint_id, new_name=None, new_code=None, + fork_mode="deep", forked_by="", remark=""): + """深拷贝聚合根 + 全部子对象 + 关系,写溯源。""" + src = get_blueprint(tenant_id, source_blueprint_id) + if fork_mode not in ("deep", "shallow"): + fail("PBL-PARAM-0003", "fork_mode 仅支持 deep/shallow") + new_name = (new_name or "%s (副本)" % src["name"]).strip() + bid = create_blueprint(tenant_id, new_name, code=new_code, + description=src.get("description", ""), + subject=src.get("subject", ""), + grade=src.get("grade", ""), + duration_hours=src.get("duration_hours", 0), + owner_id=forked_by, source="fork") + copied_so = copied_rel = 0 + if fork_mode == "deep": + tree = so.get_tree(tenant_id, source_blueprint_id) + idmap = {} + for s in sorted(tree.get("flat", []), key=lambda x: (x.get("parent_id") or "", x.get("seq", 0))): + nid = so.upsert_subobject( + tenant_id, bid, s["obj_type"], s["name"], payload=s.get("payload"), + code=None, parent_id=idmap.get(s.get("parent_id"), ""), + seq=s.get("seq"), strict=False, operator_id=forked_by, + operator_type="human") + idmap[s["id"]] = nid + copied_so += 1 + for r in so.list_rels(tenant_id, source_blueprint_id): + s2, d2 = idmap.get(r["src_id"]), idmap.get(r["dst_id"]) + if s2 and d2: + try: + so.add_rel(tenant_id, bid, r["rel_type"], s2, d2, + weight=r.get("weight", 1.0), + payload=_loads(r.get("payload"))) + copied_rel += 1 + except Exception: + pass + # 外部引用一并拷贝(只读引用,C4 安全) + for rf in list_refs(tenant_id, source_blueprint_id): + add_ref(tenant_id, bid, rf["ref_kind"], rf["ref_id"], + ref_module=rf.get("ref_module", ""), + subobject_id=idmap.get(rf.get("subobject_id"), ""), + snapshot=_loads(rf.get("snapshot"))) + _fork.insert(tenant_id, { + "source_blueprint_id": source_blueprint_id, + "source_version_no": int(src.get("version_no", 0) or 0), + "target_blueprint_id": bid, "fork_mode": fork_mode, + "copied_subobjects": copied_so, "copied_rels": copied_rel, + "forked_by": forked_by, "remark": (remark or "")[:500], + "create_time": now_str(), + }) + write_audit(tenant_id, "fork", "pbl_blueprint", bid, + detail={"source": source_blueprint_id, "mode": fork_mode, + "subobjects": copied_so, "rels": copied_rel}) + return {"blueprint_id": bid, "copied_subobjects": copied_so, + "copied_rels": copied_rel, "fork_mode": fork_mode} + + +def get_fork_lineage(tenant_id, blueprint_id): + """溯源链(向上找源,向下找副本)。""" + up = _fork.list(tenant_id, [("target_blueprint_id", "=", blueprint_id)], + page_size=50) + down = _fork.list(tenant_id, [("source_blueprint_id", "=", blueprint_id)], + page_size=200) + return {"ancestors": up, "descendants": down} + + +# --------------------------------------------------------------------------- +# 锁 +# --------------------------------------------------------------------------- + +def acquire_lock(tenant_id, blueprint_id, holder_id, holder_name="", ttl_minutes=30): + row = get_blueprint(tenant_id, blueprint_id) + if int(row.get("locked", 0) or 0) == 1: + act = _lock.list(tenant_id, [("blueprint_id", "=", blueprint_id), + ("released", "=", 0)], page_size=1) + if act and act[0].get("expire_time", "") > now_str(): + if act[0]["holder_id"] != holder_id: + fail("PBL-CONFLICT-0003", + "蓝图被 %s 锁定至 %s" % (act[0].get("holder_name") or act[0]["holder_id"], + act[0]["expire_time"])) + token = new_id("lock") + import datetime + exp = (datetime.datetime.now() + + datetime.timedelta(minutes=int(ttl_minutes or 30))).strftime("%Y-%m-%d %H:%M:%S") + _lock.insert(tenant_id, { + "blueprint_id": blueprint_id, "holder_id": holder_id, + "holder_name": holder_name, "lock_token": token, + "acquire_time": now_str(), "expire_time": exp, "released": 0, + }) + _bp.update(tenant_id, blueprint_id, + {"locked": 1, "lock_holder": holder_id, "lock_expire": exp}) + return {"lock_token": token, "expire_time": exp} + + +def release_lock(tenant_id, blueprint_id, lock_token, holder_id=""): + act = _lock.list(tenant_id, [("blueprint_id", "=", blueprint_id), + ("released", "=", 0)], page_size=1) + if not act: + return True + cur = act[0] + if cur["lock_token"] != lock_token: + fail("PBL-PERM-0001", "锁令牌不匹配,拒绝释放") + _lock.update(tenant_id, cur["id"], {"released": 1, "release_time": now_str()}) + _bp.update(tenant_id, blueprint_id, + {"locked": 0, "lock_holder": "", "lock_expire": None}) + return True + + +def _assert_writable(row, tenant_id, lock_token=None): + """写保护:锁定中的蓝图仅锁持有人(且令牌匹配)可写。""" + if int(row.get("locked", 0) or 0) == 1: + if lock_token is None: + fail("PBL-CONFLICT-0003", "蓝图已锁定,需持 lock_token 写入") + act = _lock.list(tenant_id, [("blueprint_id", "=", row["id"]), + ("released", "=", 0)], page_size=1) + if act and act[0]["lock_token"] != lock_token: + fail("PBL-PERM-0001", "lock_token 不匹配") + + +# --------------------------------------------------------------------------- +# 外部引用(C4:只存引用,绝不写 world/scene/entity 基表) +# --------------------------------------------------------------------------- + +WRITE_PROTECTED = ("world", "scene", "entity", "scense", "scense_game", + "scense_runtime", "script_engine", "rbac") + + +def add_ref(tenant_id, blueprint_id, ref_kind, ref_id, ref_module="", + subobject_id="", snapshot=None): + if ref_kind not in ("world", "scene", "entity", "kdb", "script", "url", "file"): + fail("PBL-PARAM-0003", "ref_kind 非法: %r" % ref_kind) + if not ref_id: + fail("PBL-PARAM-0001", "ref_id 必填") + get_blueprint(tenant_id, blueprint_id) + if _ref.exists(tenant_id, [("blueprint_id", "=", blueprint_id), + ("ref_kind", "=", ref_kind), + ("ref_id", "=", str(ref_id)), ("deleted", "=", 0)]): + fail("PBL-CONFLICT-0001", "引用已存在") + rid = _ref.insert(tenant_id, { + "blueprint_id": blueprint_id, "ref_kind": ref_kind, "ref_id": str(ref_id), + "ref_module": ref_module or ref_kind, "subobject_id": subobject_id or "", + "snapshot": json.dumps(snapshot or {}, ensure_ascii=False), + "deleted": 0, "create_time": now_str(), + }) + write_audit(tenant_id, "create", "pbl_blueprint_ref", rid, + detail={"ref_kind": ref_kind, "ref_id": ref_id, + "write_protected": ref_kind in WRITE_PROTECTED}) + return rid + + +def list_refs(tenant_id, blueprint_id, ref_kind=None): + where = [("blueprint_id", "=", blueprint_id), ("deleted", "=", 0)] + if ref_kind: + where.append(("ref_kind", "=", ref_kind)) + return _ref.list(tenant_id, where, page_size=500) + + +def delete_ref(tenant_id, ref_id): + _ref.update(tenant_id, ref_id, {"deleted": 1}) + write_audit(tenant_id, "delete", "pbl_blueprint_ref", ref_id) + return True + + +# --------------------------------------------------------------------------- +# 模板实例化(含离线兜底) +# --------------------------------------------------------------------------- + +def instantiate_template(tenant_id, template_id_or_code, name, code=None, + owner_id="", allow_offline=True): + """模板 -> 新蓝图。DB 无模板且 allow_offline 时回落 pbl_template 离线包。""" + tpl = None + if _tpl.exists(tenant_id, [("id", "=", template_id_or_code), ("deleted", "=", 0)]): + tpl = _tpl.get(tenant_id, template_id_or_code) + elif _tpl.exists(tenant_id, [("code", "=", template_id_or_code), ("deleted", "=", 0)]): + tpl = _tpl.list(tenant_id, [("code", "=", template_id_or_code), + ("deleted", "=", 0)], page_size=1)[0] + else: + # 平台内置(tenant_id='') + rows = _tpl.list("", [("code", "=", template_id_or_code), + ("deleted", "=", 0), ("enabled", "=", 1)], page_size=1) + tpl = rows[0] if rows else None + + if tpl is None: + if not allow_offline: + fail("PBL-NOTFOUND-0001", "模板不存在: %s" % template_id_or_code) + try: + from pbl_template.offline import load_offline_template + body = load_offline_template(template_id_or_code) + except Exception as e: + fail("PBL-NOTFOUND-0001", + "模板 %s 不存在且离线兜底失败: %s" % (template_id_or_code, e)) + tpl = {"code": template_id_or_code, "name": name, "body": body, + "id": "", "tenant_id": tenant_id} + + if int(tpl.get("enabled", 1) or 0) == 0: + fail("PBL-CONFLICT-0001", "模板已停用: %s" % tpl.get("code")) + try: + body = json.loads(tpl["body"]) if isinstance(tpl.get("body"), str) else (tpl.get("body") or {}) + except Exception as e: + fail("PBL-COMPILE-0001", "模板 body 解析失败: %s" % e) + + bid = create_blueprint(tenant_id, name, code=code, + description=tpl.get("description", "") or body.get("description", ""), + subject=body.get("subject", ""), grade=body.get("grade", ""), + duration_hours=body.get("duration_hours", 0), + owner_id=owner_id, source="template") + idmap, n_so, n_rel = {}, 0, 0 + for s in body.get("subobjects", []): + ot = s.get("obj_type") + if ot not in SUBOBJECT_TYPES: + continue + nid = so.upsert_subobject(tenant_id, bid, ot, s.get("name") or ot, + payload=s.get("payload"), code=s.get("key"), + parent_id=idmap.get(s.get("parent"), ""), + seq=s.get("seq"), strict=False, + operator_id=owner_id) + idmap[s.get("key") or s.get("id") or nid] = nid + n_so += 1 + for r in body.get("rels", []): + s2, d2 = idmap.get(r.get("src")), idmap.get(r.get("dst")) + if s2 and d2: + try: + so.add_rel(tenant_id, bid, r.get("rel_type", "relates_to"), + s2, d2, weight=r.get("weight", 1.0)) + n_rel += 1 + except Exception: + pass + if tpl.get("id"): + try: + _tpl.update(tenant_id, tpl["id"], + {"use_count": int(tpl.get("use_count", 0) or 0) + 1}) + except Exception: + pass + write_audit(tenant_id, "create", "pbl_blueprint", bid, + detail={"from_template": tpl.get("code"), "subobjects": n_so, + "rels": n_rel}) + return {"blueprint_id": bid, "subobjects": n_so, "rels": n_rel, + "template": tpl.get("code")} + + +def list_templates(tenant_id, category=None, enabled_only=True): + where = [("deleted", "=", 0)] + if enabled_only: + where.append(("enabled", "=", 1)) + if category: + where.append(("category", "=", category)) + rows = _tpl.list(tenant_id, where, order_by="use_count DESC", page_size=200) + builtin = _tpl.list("", [("deleted", "=", 0), ("enabled", "=", 1), + ("builtin", "=", 1)], page_size=200) + for r in rows + builtin: + r.pop("body", None) + return rows + builtin + + +# --------------------------------------------------------------------------- + +def _check_status(s): + if s not in BLUEPRINT_STATUS: + fail("PBL-PARAM-0003", "status %r 非法,允许: %s" % (s, "/".join(BLUEPRINT_STATUS))) + + +def _check_quality(q): + if q not in QUALITY_LEVELS: + fail("PBL-PARAM-0003", "quality_level %r 非法,允许: %s" % (q, "/".join(QUALITY_LEVELS))) + + +def _loads(v, default=None): + if default is None: + default = {} + if v in (None, ""): + return default + if isinstance(v, (dict, list)): + return v + try: + return json.loads(v) + except Exception: + return default diff --git a/pbl_blueprint/errors.py b/pbl_blueprint/errors.py new file mode 100644 index 0000000..55bc851 --- /dev/null +++ b/pbl_blueprint/errors.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +"""pbl_blueprint 错误码与响应构造内核(M1a)。 + +本文件是 pbl_blueprint 包的**唯一**错误码/响应结构事实源,被以下位置导入: + - pbl_blueprint/__init__.py (包级 re-export,14 个符号) + - pbl_blueprint/init.py (load_pbl_blueprint 时注册异常处理器) + - pbl_blueprint/tenant.py (租户上下文 fail-closed 拒绝) + - pbl_blueprint/service.py (业务服务层统一返回) + - pbl_blueprint/crud.py (CRUD 工厂统一返回) + +设计约束: + 1. 错误码为**整数常量**,0 = 成功;4xxxx = 调用方错误;5xxxx = 服务端错误。 + 2. 响应结构统一为 {"code": int, "msg": str, "data": Any},额外字段通过 kwargs 平铺, + 便于前端 .dspy 契约直接取用。 + 3. fail-closed:任何缺失 tenant_id 的读写一律 ERR_TENANT_MISSING 拒绝,**不做默认租户兜底**。 + 4. 本模块不 import sqlor / ahserver / sage 任何运行时,保证可在裸 python3 下 import 与单测。 + +导出符号(14 个,与 __init__.py 的导入清单严格一致): + 异常基类 : PblBlueprintError + 错误码 : ERR_OK, ERR_TENANT_MISSING, ERR_PARAM_INVALID, ERR_NOT_FOUND, + ERR_DUPLICATE, ERR_STATE_INVALID, ERR_LOCKED, ERR_FORBIDDEN, + ERR_DB, ERR_INTERNAL + 响应构造 : ok, fail, err +""" + +__all__ = [ + "PblBlueprintError", + "ERR_OK", + "ERR_TENANT_MISSING", + "ERR_PARAM_INVALID", + "ERR_NOT_FOUND", + "ERR_DUPLICATE", + "ERR_STATE_INVALID", + "ERR_LOCKED", + "ERR_FORBIDDEN", + "ERR_DB", + "ERR_INTERNAL", + "ok", + "fail", + "err", + "ERR_MESSAGES", + "msg_of", + "is_ok", +] + +# -------------------------------------------------------------------------- +# 错误码常量 +# -------------------------------------------------------------------------- +ERR_OK = 0 # 成功 + +# 4xxxx —— 调用方错误(不重试,需修正入参/状态/权限) +ERR_TENANT_MISSING = 40001 # 租户上下文缺失(fail-closed,最高优先级拒绝) +ERR_PARAM_INVALID = 40002 # 参数非法/必填缺失/类型不符 +ERR_NOT_FOUND = 40004 # 目标对象不存在(或不属于当前租户) +ERR_DUPLICATE = 40009 # 唯一约束冲突(编码/名称重复) +ERR_STATE_INVALID = 40010 # 状态机不允许该流转(如已发布蓝图再编辑) +ERR_LOCKED = 40011 # 编辑锁被他人持有 +ERR_FORBIDDEN = 40030 # RBAC 权限不足 + +# 5xxxx —— 服务端错误(可重试/需告警) +ERR_DB = 50001 # 数据库执行失败 +ERR_INTERNAL = 50000 # 未分类内部错误 + +#: 错误码 → 默认中文提示(前端可被 i18n 覆盖) +ERR_MESSAGES = { + ERR_OK: "ok", + ERR_TENANT_MISSING: "租户上下文缺失,请求被拒绝", + ERR_PARAM_INVALID: "参数非法", + ERR_NOT_FOUND: "对象不存在", + ERR_DUPLICATE: "对象已存在(唯一约束冲突)", + ERR_STATE_INVALID: "当前状态不允许该操作", + ERR_LOCKED: "对象已被锁定", + ERR_FORBIDDEN: "权限不足", + ERR_DB: "数据库操作失败", + ERR_INTERNAL: "服务内部错误", +} + + +def msg_of(code, default=None): + """取错误码默认提示文案;未知码返回 default(缺省 'unknown error')。""" + if default is None: + default = "unknown error" + return ERR_MESSAGES.get(code, default) + + +def is_ok(resp): + """判定一个响应 dict / 错误码是否为成功。 + + 兼容三种入参:响应 dict、整数错误码、None。 + """ + if resp is None: + return False + if isinstance(resp, dict): + return resp.get("code", ERR_INTERNAL) == ERR_OK + if isinstance(resp, bool): + return resp + if isinstance(resp, int): + return resp == ERR_OK + return False + + +# -------------------------------------------------------------------------- +# 异常基类 +# -------------------------------------------------------------------------- +class PblBlueprintError(Exception): + """pbl_blueprint 领域异常基类。 + + 用法(与 crud.py / service.py / tenant.py 的实际调用方式一致,三种皆支持):: + + raise PblBlueprintError(ERR_TENANT_MISSING) # 仅错误码 + raise PblBlueprintError(ERR_PARAM_INVALID, "name 必填") # 码 + 文案 + raise PblBlueprintError(ERR_DB, "insert failed", data={...}) # 码 + 文案 + 附加数据 + raise PblBlueprintError.from_code(ERR_NOT_FOUND, id=bid) # 类方法构造 + + 属性: + code : int 错误码 + msg : str 提示文案(未显式给出时取 ERR_MESSAGES 默认值) + data : Any 附加数据(可为 None) + """ + + #: 异常默认错误码,子类可覆盖 + default_code = ERR_INTERNAL + + def __init__(self, code=None, msg=None, data=None, **extra): + if code is None: + code = self.default_code + # 容错:允许把 msg 写在第一个位置(PblBlueprintError("xxx")) + if isinstance(code, str) and msg is None: + msg, code = code, self.default_code + self.code = int(code) + self.msg = msg if msg is not None else msg_of(self.code) + self.data = data + self.extra = extra or {} + super(PblBlueprintError, self).__init__(self.msg) + + # -- 构造便捷方法 ------------------------------------------------------ + @classmethod + def from_code(cls, code, msg=None, data=None, **extra): + """按错误码构造异常(语义与 __init__ 等价,供调用方显式表达意图)。""" + return cls(code, msg, data, **extra) + + @classmethod + def tenant_missing(cls, msg=None, **extra): + """租户上下文缺失(fail-closed 专用快捷构造)。""" + return cls(ERR_TENANT_MISSING, msg or msg_of(ERR_TENANT_MISSING), None, **extra) + + @classmethod + def param_invalid(cls, msg=None, **extra): + return cls(ERR_PARAM_INVALID, msg or msg_of(ERR_PARAM_INVALID), None, **extra) + + @classmethod + def not_found(cls, msg=None, **extra): + return cls(ERR_NOT_FOUND, msg or msg_of(ERR_NOT_FOUND), None, **extra) + + @classmethod + def duplicate(cls, msg=None, **extra): + return cls(ERR_DUPLICATE, msg or msg_of(ERR_DUPLICATE), None, **extra) + + @classmethod + def state_invalid(cls, msg=None, **extra): + return cls(ERR_STATE_INVALID, msg or msg_of(ERR_STATE_INVALID), None, **extra) + + @classmethod + def locked(cls, msg=None, **extra): + return cls(ERR_LOCKED, msg or msg_of(ERR_LOCKED), None, **extra) + + @classmethod + def forbidden(cls, msg=None, **extra): + return cls(ERR_FORBIDDEN, msg or msg_of(ERR_FORBIDDEN), None, **extra) + + @classmethod + def db(cls, msg=None, **extra): + return cls(ERR_DB, msg or msg_of(ERR_DB), None, **extra) + + # -- 序列化 ------------------------------------------------------------ + def to_dict(self): + """转为标准响应 dict(供 init.py 注册的异常处理器直接返回)。""" + resp = fail(self.code, self.msg, self.data) + if self.extra: + resp.update(self.extra) + return resp + + def __repr__(self): + return "" % (self.code, self.msg) + + def __str__(self): + return "[%s] %s" % (self.code, self.msg) + + +# -------------------------------------------------------------------------- +# 响应构造函数 +# -------------------------------------------------------------------------- +def ok(data=None, msg=None, **extra): + """成功响应。 + + :param data: 业务数据(dict / list / 标量 / None) + :param msg: 提示文案,缺省 "ok" + :param extra: 额外平铺字段(如 total=100, page=1) + :return: {"code": 0, "msg": "ok", "data": ...} + """ + resp = {"code": ERR_OK, "msg": msg if msg is not None else msg_of(ERR_OK), "data": data} + if extra: + resp.update(extra) + return resp + + +def fail(code, msg=None, data=None, **extra): + """失败响应(不抛异常,返回 dict)。 + + :param code: 错误码(int);也接受 PblBlueprintError 实例,自动取其 code/msg/data + :param msg: 提示文案,缺省取 ERR_MESSAGES[code] + :param data: 附加数据(如冲突字段清单),缺省 None + :param extra: 额外平铺字段 + :return: {"code": <非0>, "msg": ..., "data": ...} + """ + if isinstance(code, PblBlueprintError): + exc = code + if msg is None: + msg = exc.msg + if data is None: + data = exc.data + if not extra and exc.extra: + extra = dict(exc.extra) + code = exc.code + if code is None: + code = ERR_INTERNAL + try: + code = int(code) + except (TypeError, ValueError): + code = ERR_INTERNAL + if code == ERR_OK: + # fail() 语义上必须非 0,误传 0 时降级为 ERR_INTERNAL,避免前端误判成功 + code = ERR_INTERNAL + resp = {"code": code, "msg": msg if msg is not None else msg_of(code), "data": data} + if extra: + resp.update(extra) + return resp + + +def err(code=None, msg=None, data=None, **extra): + """失败响应的别名(兼容既有调用点 `return err(ERR_DB, ...)`)。 + + 与 fail() 完全等价;code 缺省为 ERR_INTERNAL。 + """ + if code is None: + code = ERR_INTERNAL + return fail(code, msg, data, **extra) diff --git a/pbl_blueprint/init.py b/pbl_blueprint/init.py index b4d89aa..d34e793 100644 --- a/pbl_blueprint/init.py +++ b/pbl_blueprint/init.py @@ -1,317 +1,102 @@ # -*- coding: utf-8 -*- -"""pbl_blueprint 模块挂载(M1a):建表 + 契约函数注册 + RBAC 路径注册。 +"""pbl_blueprint 挂载入口(M1a)。""" +from . import tables as _tables +from .api_blueprint import (acquire_lock, add_ref, change_status, commit_version, + create_blueprint, delete_blueprint, delete_ref, + fork_blueprint, get_blueprint, get_delta, + get_fork_lineage, get_tree, get_version, + instantiate_template, list_blueprints, list_refs, + list_templates, list_versions, release_lock, + rollback_version, set_quality_level, + update_blueprint) +from .subobject import (BUILTIN_SCHEMA, add_rel, delete_rel, + delete_subobject, get_schema, get_subobject, get_tree, + list_rels, list_subobjects, reorder_subobject, + upsert_subobject, validate_payload) -三处同步注册(缺一即 selfcheck 失败): - 1) service.py / crud.py 中定义函数 - 2) __init__.py CONTRACT_FUNCTIONS 导出 - 3) 本文件 REGISTER_FUNCTIONS 注册到 ServerEnv +MODULE_NAME = "pbl_blueprint" +TABLE_NAMES = list(_tables.TABLES.keys()) # 11 表 +SUBOBJECT_TYPES = _tables.SUBOBJECT_TYPES # 7 类 -RBAC 路径显式枚举(无 %/* 通配符),逐条列出,便于权限审计。 -库名一律来自 env.get_module_dbname('pbl_blueprint'),本文件不出现任何库名字面量。 -""" - -import os - -from . import db as _db -from .crud import list_tables, load_model, make_crud -from .errors import PblBlueprintError - -MODULE_NAME = _db.MODULE_NAME # "pbl_blueprint" - -# ---- 1) 表注册清单(models/*.json 为唯一事实源) ---- -REGISTER_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", -) - -# ---- 2) CRUD 契约注册清单(表名 -> 注册前缀) ---- -REGISTER_CRUD = REGISTER_TABLES - -# ---- 3) 业务契约函数注册清单(与 __init__.CONTRACT_FUNCTIONS 一致) ---- -REGISTER_FUNCTIONS = ( - "create_blueprint", "get_blueprint", "update_blueprint", "delete_blueprint", - "list_blueprints", "get_blueprint_tree", "create_node", "update_node", - "delete_node", "list_nodes", "create_edge", "delete_edge", "list_edges", - "fork_blueprint", "list_forks", "save_version", "list_versions", "get_version", - "get_change_delta", "rollback_version", "create_template", "list_templates", - "instantiate_template", "instantiate_payload", "export_offline", "import_offline", - "list_offline", "publish_blueprint", "revoke_publish", "list_publishes", - "lock_blueprint", "unlock_blueprint", "clean_expired_locks", "blueprint_stats", - "write_audit", "list_audit", -) - -# ---- 4) RBAC 路径显式枚举(无通配符) ---- -RBAC_PATHS = ( - "/api/pbl_blueprint/list.dspy", - "/api/pbl_blueprint/create.dspy", - "/api/pbl_blueprint/get.dspy", - "/api/pbl_blueprint/update.dspy", - "/api/pbl_blueprint/delete.dspy", - "/api/pbl_blueprint/tree.dspy", - "/api/pbl_blueprint/fork.dspy", - "/api/pbl_blueprint/forks.dspy", - "/api/pbl_blueprint/stats.dspy", - "/api/pbl_blueprint_node/list.dspy", - "/api/pbl_blueprint_node/create.dspy", - "/api/pbl_blueprint_node/get.dspy", - "/api/pbl_blueprint_node/update.dspy", - "/api/pbl_blueprint_node/delete.dspy", - "/api/pbl_blueprint_edge/list.dspy", - "/api/pbl_blueprint_edge/create.dspy", - "/api/pbl_blueprint_edge/get.dspy", - "/api/pbl_blueprint_edge/update.dspy", - "/api/pbl_blueprint_edge/delete.dspy", - "/api/pbl_blueprint_version/list.dspy", - "/api/pbl_blueprint_version/get.dspy", - "/api/pbl_blueprint_version/save.dspy", - "/api/pbl_blueprint_version/rollback.dspy", - "/api/pbl_blueprint_version_delta/list.dspy", - "/api/pbl_blueprint_version_delta/get.dspy", - "/api/pbl_blueprint_template/list.dspy", - "/api/pbl_blueprint_template/create.dspy", - "/api/pbl_blueprint_template/get.dspy", - "/api/pbl_blueprint_template/update.dspy", - "/api/pbl_blueprint_template/delete.dspy", - "/api/pbl_blueprint_template/instantiate.dspy", - "/api/pbl_blueprint_publish/list.dspy", - "/api/pbl_blueprint_publish/create.dspy", - "/api/pbl_blueprint_publish/get.dspy", - "/api/pbl_blueprint_publish/revoke.dspy", - "/api/pbl_blueprint_offline/list.dspy", - "/api/pbl_blueprint_offline/export.dspy", - "/api/pbl_blueprint_offline/import.dspy", - "/api/pbl_blueprint_offline/get.dspy", - "/api/pbl_blueprint_fork/list.dspy", - "/api/pbl_blueprint_fork/get.dspy", - "/api/pbl_blueprint_lock/lock.dspy", - "/api/pbl_blueprint_lock/unlock.dspy", - "/api/pbl_blueprint_lock/list.dspy", - "/api/pbl_blueprint_lock/clean_expired.dspy", - "/api/pbl_blueprint_audit/list.dspy", - "/api/pbl_blueprint_audit/get.dspy", -) - -# ---- 5) 菜单/页面注册(前端入口,显式枚举) ---- -MENUS = ( - {"code": "pbl_blueprint", "name": "PBL蓝图", "path": "/pbls/blueprint", "parent": "pbls", "sort": 10}, - {"code": "pbl_blueprint_template", "name": "蓝图模板", "path": "/pbls/blueprint/template", "parent": "pbls", "sort": 11}, - {"code": "pbl_blueprint_publish", "name": "蓝图发布", "path": "/pbls/blueprint/publish", "parent": "pbls", "sort": 12}, - {"code": "pbl_blueprint_offline", "name": "离线包", "path": "/pbls/blueprint/offline", "parent": "pbls", "sort": 13}, - {"code": "pbl_blueprint_audit", "name": "蓝图审计", "path": "/pbls/blueprint/audit", "parent": "pbls", "sort": 14}, -) - -_init_state = {"done": False, "dbname": "", "tables": 0, "functions": 0, "rbac": 0, "menus": 0} +__all__ = [ + "load_pbl_blueprint", "ensure_tables", "MODULE_NAME", "TABLE_NAMES", + "SUBOBJECT_TYPES", "BUILTIN_SCHEMA", + "create_blueprint", "get_blueprint", "list_blueprints", "update_blueprint", + "delete_blueprint", "get_tree", "change_status", "set_quality_level", + "commit_version", "list_versions", "get_version", "get_delta", + "rollback_version", "fork_blueprint", "get_fork_lineage", + "acquire_lock", "release_lock", + "add_ref", "list_refs", "delete_ref", + "instantiate_template", "list_templates", + "list_subobjects", "get_subobject", "upsert_subobject", "delete_subobject", + "reorder_subobject", "validate_payload", "get_schema", + "add_rel", "list_rels", "delete_rel", +] -def _get_env(env=None): - if env is not None: - return env - return _db._server_env() +def ensure_tables(sor=None, dbname=None): + if sor is None: + from pbl_common.crud_factory import _sor + sor = _sor() + from pbl_common.dbutil import get_dbname + dbname = dbname or get_dbname(MODULE_NAME) + for t in TABLE_NAMES: + sor.sqlExe(dbname, _tables.ddl(t)) + _seed_builtin_schema(sor, dbname) + return TABLE_NAMES -def _register_tables(env, sor, create_ddl=True): - """注册表定义(models/*.json),必要时建表。""" - n = 0 - on_disk = set(list_tables()) - for tbl in REGISTER_TABLES: - model = load_model(tbl) # 表定义必须真实存在,缺失即抛错(fail-closed) - if tbl not in on_disk: - raise PblBlueprintError("PBL_BP_E_INTERNAL", "models/%s.json 缺失" % tbl) - if env is not None and hasattr(env, "register_table"): +def _seed_builtin_schema(sor, dbname): + """把 7 类子对象的内置 payload schema 落到 pbl_subobject_field(tenant_id='',幂等)。""" + from pbl_common.dbutil import new_id, now_str, esc + for obj_type, fields in BUILTIN_SCHEMA.items(): + for i, (key, spec) in enumerate(sorted(fields.items())): + import json as _json + cons = {k: v for k, v in spec.items() + if k in ("min", "max", "maxlen", "enum", "items")} + exists = sor.sqlExe(dbname, ( + "SELECT COUNT(1) AS c FROM `pbl_subobject_field` " + "WHERE `tenant_id`='' AND `obj_type`=%s AND `field_key`=%s" + % (esc(obj_type), esc(key)))) try: - env.register_table(MODULE_NAME, tbl, model) + if exists and int(exists[0].get("c", 0)) > 0: + continue except Exception: - pass - if create_ddl and sor is not None: - try: - _ensure_table(sor, tbl, model) - except Exception: - pass # 建表失败不阻断挂载(部署阶段由 DDL 脚本保证) - n += 1 - return n + continue + sor.sqlExe(dbname, ( + "INSERT INTO `pbl_subobject_field` " + "(`id`,`tenant_id`,`obj_type`,`field_key`,`field_label`,`field_type`," + "`required`,`enum_codes`,`default_value`,`constraints`,`seq`,`enabled`,`create_time`) " + "VALUES (%s,'',%s,%s,%s,%s,%d,%s,'',%s,%d,1,%s)" % ( + esc(new_id("sof")), esc(obj_type), esc(key), + esc(spec.get("label", key)), esc(spec.get("type", "string")), + 1 if spec.get("required") else 0, + esc(spec.get("enum_codes", "")), + esc(_json.dumps(cons, ensure_ascii=False)), + i * 10, esc(now_str())))) -def _ensure_table(sor, tbl, model): - """按 models 定义建表(不存在才建),只用 sqlor.sqlExe。""" - cols = [] - for name, spec in (model.get("fields") or {}).items(): - cols.append(" %s %s" % (name, _col_type(spec))) - pk = model.get("primary") or ["id"] - cols.append(" primary key (%s)" % ",".join(pk)) - for idx_name, idx in (model.get("indexes") or {}).items(): - uq = "unique " if idx.get("unique") else "" - cols.append(" %skey %s (%s)" % (uq, idx_name, ",".join(idx.get("fields") or []))) - ddl = "create table if not exists %s (\n%s\n)" % (tbl, ",\n".join(cols)) - _db.q_sql(sor, ddl, []) - - -def _col_type(spec): - t = (spec.get("type") or "str").lower() - size = spec.get("size") - if t in ("str", "string", "varchar", "char"): - return "varchar(%d)" % int(size or 64) - if t == "int": - return "int" - if t == "bigint": - return "bigint" - if t == "double": - if isinstance(size, (list, tuple)) and len(size) == 2: - return "decimal(%d,%d)" % (int(size[0]), int(size[1])) - return "double" - if t in ("text", "longtext", "json"): - return "text" - if t in ("datetime", "timestamp"): - return "datetime" - if t == "date": - return "date" - return "varchar(64)" - - -def _register_crud(env, prefix=""): - """注册 11 表的通用 CRUD 契约函数({表名}_{动作})。""" - n = 0 - if env is None or not hasattr(env, "register_function"): - return 0 - for tbl in REGISTER_CRUD: - api = make_crud(tbl) - for act, fn in api.items(): - name = "%s%s_%s" % (prefix, tbl, act) - try: - env.register_function(MODULE_NAME, name, fn) - n += 1 - except Exception: - pass - return n - - -def _register_functions(env): - """注册业务契约函数(三处同步的第 3 处)。""" - from . import get_contract_functions - fns = get_contract_functions() - n = 0 - for name in REGISTER_FUNCTIONS: - fn = fns.get(name) - if fn is None: - raise PblBlueprintError("PBL_BP_E_INTERNAL", - "契约函数 %s 未在 __init__ 导出(三处同步失败)" % name) - if env is not None and hasattr(env, "register_function"): - try: - env.register_function(MODULE_NAME, name, fn) - except Exception: - pass - n += 1 - return n - - -def _register_rbac(env): - """注册 RBAC 路径(显式枚举,无通配符)。""" - n = 0 - if env is None: - return 0 - for p in RBAC_PATHS: - if "%" in p or "*" in p: - raise PblBlueprintError("PBL_BP_E_INTERNAL", "RBAC 路径含通配符: %s" % p) - fn = getattr(env, "register_rbac_path", None) or getattr(env, "add_rbac_path", None) - if callable(fn): - try: - fn(MODULE_NAME, p) - except Exception: - pass - n += 1 - return n - - -def _register_menus(env): - n = 0 - if env is None: - return 0 - fn = getattr(env, "register_menu", None) or getattr(env, "add_menu", None) - for m in MENUS: - if callable(fn): - try: - fn(MODULE_NAME, m) - except Exception: - pass - n += 1 - return n - - -def init(env=None, dbname=None, register_tables=True, register_functions=True, - register_rbac=True, create_ddl=True): - """模块挂载入口(幂等)。由 apps/pbls 的 init() 逐个 load_{模块}() 时调用。""" - if _init_state["done"] and not dbname: - return dict(_init_state) - e = _get_env(env) - db = dbname or (e.get_module_dbname(MODULE_NAME) if (e is not None and hasattr(e, "get_module_dbname")) else "") - if not db: - db = _db.get_dbname() - if not db: - raise PblBlueprintError("PBL_BP_E_INTERNAL", - "未取到 %s 库名:应用 init() 必须定义 get_module_dbname 并挂到 ServerEnv" % MODULE_NAME) - sor = _db.get_sor(db) - st = {"done": True, "dbname": db, "tables": 0, "crud": 0, "functions": 0, "rbac": 0, "menus": 0} - if register_tables: - st["tables"] = _register_tables(e, sor, create_ddl) - st["crud"] = _register_crud(e) - if register_functions: - st["functions"] = _register_functions(e) - if register_rbac: - st["rbac"] = _register_rbac(e) - st["menus"] = _register_menus(e) - _init_state.update(st) - return dict(st) - - -def get_state(): - return dict(_init_state) - - -def reset(): - """测试用:重置挂载状态与 sqlor 缓存。""" - _init_state.update({"done": False, "dbname": "", "tables": 0, "functions": 0, - "rbac": 0, "menus": 0, "crud": 0}) - _db.clear_cache() - - -def ddl_script(out_path=None): - """生成全量建表 DDL 文本(供部署阶段执行 / 自查比对)。""" - lines = ["-- pbl_blueprint M1a DDL (%d tables)" % len(REGISTER_TABLES), ""] - for tbl in REGISTER_TABLES: - model = load_model(tbl) - cols = [] - for name, spec in (model.get("fields") or {}).items(): - nn = " not null" if spec.get("notnull") else "" - df = "" - if "default" in spec: - d = spec["default"] - df = " default '%s'" % d if isinstance(d, str) else " default %s" % d - cm = " comment '%s'" % (spec.get("summary") or "").replace("'", "") - cols.append(" `%s` %s%s%s%s" % (name, _col_type(spec), nn, df, cm)) - pk = model.get("primary") or ["id"] - cols.append(" primary key (%s)" % ",".join(["`%s`" % c for c in pk])) - for idx_name, idx in (model.get("indexes") or {}).items(): - uq = "unique " if idx.get("unique") else "" - cols.append(" %skey `%s` (%s)" % (uq, idx_name, - ",".join(["`%s`" % c for c in (idx.get("fields") or [])]))) - lines.append("-- %s" % (model.get("summary") or tbl)) - lines.append("create table if not exists `%s` (" % tbl) - lines.append(",\n".join(cols)) - lines.append(") engine=InnoDB default charset=utf8mb4 comment='%s';" % tbl) - lines.append("") - txt = "\n".join(lines) - if out_path: - d = os.path.dirname(os.path.abspath(out_path)) - if d and not os.path.isdir(d): - os.makedirs(d) - with open(out_path, "w", encoding="utf-8") as f: - f.write(txt) - return txt +def load_pbl_blueprint(app=None, sor=None, ensure=False, **kw): + if ensure: + ensure_tables(sor) + if app is not None: + try: + app.register_module(MODULE_NAME, { + "tables": TABLE_NAMES, + "subobject_types": list(SUBOBJECT_TYPES), + "api": { + "create": create_blueprint, "get": get_blueprint, + "list": list_blueprints, "update": update_blueprint, + "delete": delete_blueprint, "tree": get_tree, + "change_status": change_status, + "commit_version": commit_version, + "fork": fork_blueprint, + "instantiate_template": instantiate_template, + "subobject_upsert": upsert_subobject, + "subobject_list": list_subobjects, + }, + }) + except Exception: + pass + return {"module": MODULE_NAME, "tables": TABLE_NAMES, + "subobject_types": list(SUBOBJECT_TYPES), "loaded": True} diff --git a/pbl_blueprint/subobject.py b/pbl_blueprint/subobject.py new file mode 100644 index 0000000..1691ed2 --- /dev/null +++ b/pbl_blueprint/subobject.py @@ -0,0 +1,413 @@ +# -*- coding: utf-8 -*- +"""7 类子对象泛化契约(M1a 核心)。 + +设计要点(对齐 docs/01-design/data-model.md + modules/pbl_blueprint.md): + * 单表 pbl_subobject 承载 7 类对象,obj_type 判别,payload(JSON) 承载类型差异字段 + * payload 的合法字段由 pbl_subobject_field 元数据描述 -> 驱动校验(T05)与前端表单 + * 统一契约(7 类共用同一组函数,禁止为每类单独开 API): + list_subobjects / get_subobject / upsert_subobject / + delete_subobject / reorder_subobject / get_tree / validate_payload + * 一切读写 tenant_id 打头(C1),经 pbl_common.tenant_crud +""" +import json + +from pbl_common.audit import write_audit +from pbl_common.crud_factory import tenant_crud +from pbl_common.dbutil import new_id, now_str +from pbl_common.errors import fail + +from .tables import SUBOBJECT_TYPES + +MODULE = "pbl_blueprint" +_crud = tenant_crud("pbl_subobject", MODULE) +_field_crud = tenant_crud("pbl_subobject_field", MODULE) +_rel_crud = tenant_crud("pbl_subobject_rel", MODULE) + +# 7 类子对象的内置 payload schema(离线兜底;DB 中 pbl_subobject_field 优先) +BUILTIN_SCHEMA = { + "learning_goal": { + "statement": {"type": "string", "required": True, "label": "目标陈述"}, + "bloom_level": {"type": "enum", "required": False, "label": "布鲁姆层级", + "enum": ["remember", "understand", "apply", "analyze", + "evaluate", "create"]}, + "subject": {"type": "string", "required": False, "label": "学科"}, + "mastery": {"type": "string", "required": False, "label": "掌握标准"}, + }, + "task": { + "objective": {"type": "string", "required": True, "label": "任务目标"}, + "scenario": {"type": "string", "required": False, "label": "情境描述"}, + "steps": {"type": "json", "required": False, "label": "步骤列表"}, + "difficulty": {"type": "int", "required": False, "label": "难度1-5", + "min": 1, "max": 5}, + "time_limit": {"type": "int", "required": False, "label": "限时(分)"}, + "unlock_rule": {"type": "string", "required": False, "label": "解锁条件"}, + }, + "role": { + "responsibilities": {"type": "json", "required": False, "label": "职责"}, + "permissions": {"type": "json", "required": False, "label": "权限"}, + "min_members": {"type": "int", "required": False, "label": "最少人数", "min": 0}, + "max_members": {"type": "int", "required": False, "label": "最多人数", "min": 0}, + "description": {"type": "string", "required": False, "label": "角色说明"}, + }, + "artifact": { + "kind": {"type": "enum", "required": True, "label": "产出物类型", + "enum_codes": "pbl_evidence_kind"}, + "format": {"type": "string", "required": False, "label": "格式要求"}, + "submission": {"type": "string", "required": False, "label": "提交方式"}, + "max_size_mb": {"type": "int", "required": False, "label": "大小上限MB", "min": 0}, + "required": {"type": "bool", "required": False, "label": "是否必交"}, + }, + "rubric": { + "criteria": {"type": "json", "required": True, "label": "评价维度"}, + "scale": {"type": "json", "required": False, "label": "等级刻度"}, + "weight_sum": {"type": "float", "required": False, "label": "权重和(应=1.0)"}, + "pass_score": {"type": "float", "required": False, "label": "及格分"}, + }, + "resource": { + "uri": {"type": "string", "required": True, "label": "资源地址"}, + "res_type": {"type": "string", "required": False, "label": "类型"}, + "license": {"type": "string", "required": False, "label": "许可"}, + "external_ref": {"type": "string", "required": False, "label": "外部引用ID(只读)"}, + "preview": {"type": "string", "required": False, "label": "预览"}, + }, + "rule": { + "condition": {"type": "string", "required": True, "label": "触发条件"}, + "action": {"type": "string", "required": True, "label": "动作"}, + "priority": {"type": "int", "required": False, "label": "优先级", "min": 0}, + "enabled": {"type": "bool", "required": False, "label": "启用"}, + "script_ref": {"type": "string", "required": False, "label": "script_engine 引用(只读)"}, + }, +} + + +def _check_type(obj_type): + if obj_type not in SUBOBJECT_TYPES: + fail("PBL-PARAM-0003", + "obj_type %r 不在 7 类子对象内: %s" % (obj_type, "/".join(SUBOBJECT_TYPES))) + return obj_type + + +def get_schema(tenant_id, obj_type): + """取某类子对象的字段 schema:DB 元数据优先,缺失回落 BUILTIN_SCHEMA。""" + _check_type(obj_type) + rows = _field_crud.list(tenant_id, [("obj_type", "=", obj_type), + ("enabled", "=", 1)], + order_by="seq ASC", page_size=200) + if not rows: + # 平台内置(tenant_id='') + rows = _field_crud.list("", [("obj_type", "=", obj_type), + ("enabled", "=", 1)], + order_by="seq ASC", page_size=200) + if not rows: + return dict(BUILTIN_SCHEMA.get(obj_type, {})) + out = {} + for r in rows: + cons = {} + try: + cons = json.loads(r.get("constraints") or "{}") + except Exception: + cons = {} + out[r["field_key"]] = { + "type": r.get("field_type", "string"), + "required": bool(int(r.get("required", 0) or 0)), + "label": r.get("field_label", r["field_key"]), + "enum_codes": r.get("enum_codes", ""), + "default": r.get("default_value", ""), + } + out[r["field_key"]].update(cons) + return out + + +def validate_payload(tenant_id, obj_type, payload, strict=True): + """按 schema 校验 payload。返回 (clean_payload, errors)。 + + strict=True 且有 error -> 抛 PBL-VALID-0001(fail-closed) + 未登记字段:strict 下报错,非 strict 下保留但打 _unknown_fields 标 + """ + _check_type(obj_type) + schema = get_schema(tenant_id, obj_type) + payload = dict(payload or {}) + errors, clean, unknown = [], {}, [] + + for key, spec in schema.items(): + if key not in payload or payload[key] in (None, ""): + if spec.get("required"): + errors.append({"field": key, "code": "PBL-PARAM-0001", + "msg": "必填字段缺失: %s" % spec.get("label", key)}) + elif spec.get("default") not in (None, ""): + clean[key] = spec["default"] + continue + val = payload[key] + ftype = spec.get("type", "string") + try: + if ftype == "int": + val = int(val) + if "min" in spec and val < spec["min"]: + errors.append({"field": key, "code": "PBL-PARAM-0002", + "msg": "%s 小于下限 %s" % (key, spec["min"])}) + if "max" in spec and val > spec["max"]: + errors.append({"field": key, "code": "PBL-PARAM-0002", + "msg": "%s 大于上限 %s" % (key, spec["max"])}) + elif ftype == "float": + val = float(val) + elif ftype == "bool": + val = bool(val) if not isinstance(val, str) else val.lower() in ("1", "true", "yes") + elif ftype == "json": + if isinstance(val, str): + val = json.loads(val) + elif ftype == "enum": + allowed = spec.get("enum") + if allowed and val not in allowed: + errors.append({"field": key, "code": "PBL-PARAM-0003", + "msg": "%s 枚举值 %r 非法,允许: %s" % (key, val, allowed)}) + val = str(val) + else: + val = str(val) + maxlen = spec.get("maxlen") + if maxlen and len(val) > int(maxlen): + errors.append({"field": key, "code": "PBL-PARAM-0002", + "msg": "%s 超长(>%s)" % (key, maxlen)}) + except (ValueError, TypeError) as e: + errors.append({"field": key, "code": "PBL-PARAM-0002", + "msg": "%s 类型应为 %s: %s" % (key, ftype, e)}) + continue + clean[key] = val + + for key in payload: + if key not in schema: + unknown.append(key) + if unknown: + if strict: + errors.append({"field": ",".join(unknown), "code": "PBL-PARAM-0003", + "msg": "payload 含未登记字段: %s" % ",".join(unknown)}) + else: + for key in unknown: + clean[key] = payload[key] + clean["_unknown_fields"] = unknown + + if errors and strict: + fail("PBL-VALID-0001", "子对象 payload 校验未通过", {"errors": errors}) + return clean, errors + + +# --------------------------------------------------------------------------- +# 统一契约(7 类共用) +# --------------------------------------------------------------------------- + +def list_subobjects(tenant_id, blueprint_id, obj_type=None, parent_id=None, + include_deleted=False, page=1, page_size=200): + """列出蓝图下子对象(可按类型/父级过滤)。""" + where = [("blueprint_id", "=", blueprint_id)] + if obj_type: + where.append(("obj_type", "=", _check_type(obj_type))) + if parent_id is not None: + where.append(("parent_id", "=", parent_id)) + if not include_deleted: + where.append(("deleted", "=", 0)) + rows = _crud.list(tenant_id, where, order_by="obj_type ASC, seq ASC", + page=page, page_size=page_size) + for r in rows: + r["payload"] = _loads(r.get("payload")) + return rows + + +def get_subobject(tenant_id, subobject_id, with_payload=True): + row = _crud.get(tenant_id, subobject_id) + if not row or int(row.get("deleted", 0) or 0) == 1: + fail("PBL-NOTFOUND-0001", "子对象不存在: %s" % subobject_id) + if with_payload: + row["payload"] = _loads(row.get("payload")) + return row + + +def upsert_subobject(tenant_id, blueprint_id, obj_type, name, payload=None, + subobject_id=None, code=None, parent_id="", seq=None, + strict=True, operator_id="", operator_type="human"): + """新增或更新子对象(7 类统一入口)。返回子对象 id。""" + _check_type(obj_type) + if not name or not str(name).strip(): + fail("PBL-PARAM-0001", "name 必填") + clean, _errs = validate_payload(tenant_id, obj_type, payload, strict=strict) + body = json.dumps(clean, ensure_ascii=False, sort_keys=True) + + if subobject_id: + row = get_subobject(tenant_id, subobject_id, with_payload=False) + if row["blueprint_id"] != blueprint_id: + fail("PBL-PERM-0001", "子对象不属于该蓝图") + if row["obj_type"] != obj_type: + fail("PBL-PARAM-0002", "obj_type 不可变更(%s -> %s)" + % (row["obj_type"], obj_type)) + data = {"name": str(name).strip(), "payload": body, + "parent_id": parent_id if parent_id is not None else row.get("parent_id", ""), + "version_no": int(row.get("version_no", 1) or 1) + 1} + if code: + data["code"] = str(code) + if seq is not None: + data["seq"] = int(seq) + # 乐观锁:版本不匹配即冲突(C2 fail-closed) + _crud.update(tenant_id, subobject_id, data, + extra_where=[("version_no", "=", int(row.get("version_no", 1) or 1))]) + write_audit(tenant_id, "update", "pbl_subobject", subobject_id, + detail={"obj_type": obj_type, "blueprint_id": blueprint_id}) + return subobject_id + + # 新增:code 缺省自动生成;蓝图内 (obj_type, code) 唯一 + code = str(code).strip() if code else "%s_%s" % (obj_type, new_id()[:8]) + if _crud.exists(tenant_id, [("blueprint_id", "=", blueprint_id), + ("obj_type", "=", obj_type), + ("code", "=", code), ("deleted", "=", 0)]): + fail("PBL-CONFLICT-0001", "子对象编码已存在: %s/%s" % (obj_type, code)) + if seq is None: + seq = _crud.count(tenant_id, [("blueprint_id", "=", blueprint_id), + ("obj_type", "=", obj_type), + ("deleted", "=", 0)]) * 10 + sid = new_id("so") + _crud.insert(tenant_id, { + "id": sid, "blueprint_id": blueprint_id, "obj_type": obj_type, + "code": code, "name": str(name).strip(), "parent_id": parent_id or "", + "seq": int(seq), "payload": body, "ref_schema": "builtin-1.0", + "status": "active", "version_no": 1, "deleted": 0, + "create_time": now_str(), + }, auto_id=False) + write_audit(tenant_id, "create", "pbl_subobject", sid, + detail={"obj_type": obj_type, "blueprint_id": blueprint_id, + "operator_type": operator_type, "operator_id": operator_id}) + return sid + + +def delete_subobject(tenant_id, subobject_id, cascade=True, operator_id=""): + """软删子对象;cascade=True 时连带子树与关系。""" + row = get_subobject(tenant_id, subobject_id, with_payload=False) + ids = [subobject_id] + if cascade: + ids += _descendants(tenant_id, row["blueprint_id"], subobject_id) + for sid in ids: + _crud.update(tenant_id, sid, {"deleted": 1, "delete_time": now_str(), + "status": "archived"}) + # 关系同步软删 + for sid in ids: + for r in _rel_crud.list(tenant_id, [("blueprint_id", "=", row["blueprint_id"]), + ("deleted", "=", 0)], page_size=500): + if r["src_id"] == sid or r["dst_id"] == sid: + _rel_crud.update(tenant_id, r["id"], {"deleted": 1}) + write_audit(tenant_id, "delete", "pbl_subobject", subobject_id, + detail={"cascade": cascade, "count": len(ids), + "operator_id": operator_id}) + return {"deleted": len(ids), "ids": ids} + + +def reorder_subobject(tenant_id, blueprint_id, obj_type, ordered_ids): + """同级重排(seq = index*10)。""" + _check_type(obj_type) + if not isinstance(ordered_ids, (list, tuple)) or not ordered_ids: + fail("PBL-PARAM-0001", "ordered_ids 必须为非空列表") + for i, sid in enumerate(ordered_ids): + _crud.update(tenant_id, sid, {"seq": i * 10}, + extra_where=[("blueprint_id", "=", blueprint_id), + ("obj_type", "=", obj_type)]) + write_audit(tenant_id, "update", "pbl_subobject", blueprint_id, + detail={"action": "reorder", "obj_type": obj_type, + "count": len(ordered_ids)}) + return {"reordered": len(ordered_ids)} + + +def get_tree(tenant_id, blueprint_id): + """返回蓝图完整子对象树(7 类分组 + parent_id 嵌套),供 T05 校验 / T06 编译消费。""" + rows = list_subobjects(tenant_id, blueprint_id, page_size=2000) + by_id = {r["id"]: dict(r, children=[]) for r in rows} + grouped = {t: [] for t in SUBOBJECT_TYPES} + roots = [] + for r in rows: + node = by_id[r["id"]] + pid = r.get("parent_id") or "" + if pid and pid in by_id: + by_id[pid]["children"].append(node) + else: + roots.append(node) + grouped.setdefault(r["obj_type"], []).append(node) + return { + "blueprint_id": blueprint_id, + "tenant_id": tenant_id, + "total": len(rows), + "by_type": {t: len(grouped.get(t, [])) for t in SUBOBJECT_TYPES}, + "roots": roots, + "flat": rows, + } + + +# --------------------------------------------------------------------------- +# 关系(跨类型连线) +# --------------------------------------------------------------------------- + +def add_rel(tenant_id, blueprint_id, rel_type, src_id, dst_id, weight=1.0, + payload=None): + if not rel_type: + fail("PBL-PARAM-0001", "rel_type 必填") + src = get_subobject(tenant_id, src_id, with_payload=False) + dst = get_subobject(tenant_id, dst_id, with_payload=False) + if src["blueprint_id"] != blueprint_id or dst["blueprint_id"] != blueprint_id: + fail("PBL-PERM-0001", "关系两端必须属于同一蓝图") + if src_id == dst_id: + fail("PBL-PARAM-0002", "不允许自环关系") + if _rel_crud.exists(tenant_id, [("blueprint_id", "=", blueprint_id), + ("rel_type", "=", rel_type), + ("src_id", "=", src_id), + ("dst_id", "=", dst_id), + ("deleted", "=", 0)]): + fail("PBL-CONFLICT-0001", "关系已存在") + rid = _rel_crud.insert(tenant_id, { + "blueprint_id": blueprint_id, "rel_type": str(rel_type), + "src_id": src_id, "src_type": src["obj_type"], + "dst_id": dst_id, "dst_type": dst["obj_type"], + "weight": float(weight or 1.0), + "payload": json.dumps(payload or {}, ensure_ascii=False), + "deleted": 0, "create_time": now_str(), + }) + write_audit(tenant_id, "create", "pbl_subobject_rel", rid, + detail={"rel_type": rel_type, "src": src_id, "dst": dst_id}) + return rid + + +def list_rels(tenant_id, blueprint_id, rel_type=None, node_id=None): + where = [("blueprint_id", "=", blueprint_id), ("deleted", "=", 0)] + if rel_type: + where.append(("rel_type", "=", rel_type)) + rows = _rel_crud.list(tenant_id, where, page_size=2000) + if node_id: + rows = [r for r in rows if r["src_id"] == node_id or r["dst_id"] == node_id] + return rows + + +def delete_rel(tenant_id, rel_id): + _rel_crud.update(tenant_id, rel_id, {"deleted": 1}) + write_audit(tenant_id, "delete", "pbl_subobject_rel", rel_id) + return True + + +# --------------------------------------------------------------------------- +# 内部工具 +# --------------------------------------------------------------------------- + +def _descendants(tenant_id, blueprint_id, parent_id, acc=None, depth=0): + if acc is None: + acc = [] + if depth > 20: # 防环 + return acc + rows = _crud.list(tenant_id, [("blueprint_id", "=", blueprint_id), + ("parent_id", "=", parent_id), + ("deleted", "=", 0)], page_size=500) + for r in rows: + acc.append(r["id"]) + _descendants(tenant_id, blueprint_id, r["id"], acc, depth + 1) + return acc + + +def _loads(v): + if v in (None, ""): + return {} + if isinstance(v, (dict, list)): + return v + try: + return json.loads(v) + except Exception: + return {"_raw": v} diff --git a/pbl_blueprint/tables.py b/pbl_blueprint/tables.py new file mode 100644 index 0000000..adbf327 --- /dev/null +++ b/pbl_blueprint/tables.py @@ -0,0 +1,348 @@ +# -*- coding: utf-8 -*- +"""pbl_blueprint 表定义(11 表,mariadb 方言:无 FK / 无 ENUM / 无 TIMESTAMP)。 + +对齐 docs/01-design/data-model.md。所有表 tenant_id 打头(C1)。 +7 类子对象采用**泛化单表** pbl_subobject + obj_type 判别 + payload JSON 承载差异字段, +而非 7 张分表 —— 这是 36 表总数能对齐的前提(QC 契合度核验点)。 +""" + +TABLES = { + # 1. 蓝图聚合根 + "pbl_blueprint": { + "summary": "PBL 蓝图聚合根", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("code", "varchar(64)", "NOT NULL", "蓝图编码(租户内唯一)"), + ("name", "varchar(200)", "NOT NULL", "名称"), + ("description", "text", "NULL", "描述"), + ("status", "varchar(32)", "NOT NULL DEFAULT 'draft'", "pbl_blueprint_status"), + ("quality_level", "varchar(32)", "NOT NULL DEFAULT 'draft'", "pbl_quality_level"), + ("version_no", "int", "NOT NULL DEFAULT 0", "当前版本号"), + ("current_version_id", "varchar(32)", "NOT NULL DEFAULT ''", "当前版本ID"), + ("subject", "varchar(100)", "NOT NULL DEFAULT ''", "学科"), + ("grade", "varchar(50)", "NOT NULL DEFAULT ''", "学段"), + ("duration_hours", "int", "NOT NULL DEFAULT 0", "课时"), + ("owner_id", "varchar(64)", "NOT NULL DEFAULT ''", "创建人"), + ("source", "varchar(32)", "NOT NULL DEFAULT 'manual'", "manual/template/fork/agent"), + ("locked", "tinyint(1)", "NOT NULL DEFAULT 0", "是否锁定"), + ("lock_holder", "varchar(64)", "NOT NULL DEFAULT ''", "锁持有人"), + ("lock_expire", "datetime", "NULL", "锁过期"), + ("ext", "longtext", "NULL", "扩展JSON"), + ("deleted", "tinyint(1)", "NOT NULL DEFAULT 0", "软删"), + ("create_time", "datetime", "NOT NULL", ""), + ("update_time", "datetime", "NULL", ""), + ("delete_time", "datetime", "NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_bp_tenant_code", "(tenant_id, code, deleted)"), + ("idx_bp_tenant_status", "(tenant_id, status, deleted)"), + ("idx_bp_tenant_quality", "(tenant_id, quality_level)"), + ], + "codes": ["pbl_blueprint_status", "pbl_quality_level"], + }, + + # 2. 版本快照 + "pbl_blueprint_version": { + "summary": "蓝图版本快照(不可变)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "蓝图ID"), + ("version_no", "int", "NOT NULL", "版本号"), + ("snapshot", "longtext", "NOT NULL", "全量快照JSON(确定性序列化)"), + ("snapshot_hash", "varchar(64)", "NOT NULL DEFAULT ''", "快照sha256"), + ("quality_level", "varchar(32)", "NOT NULL DEFAULT 'draft'", "该版本质量级"), + ("change_summary", "varchar(500)", "NOT NULL DEFAULT ''", "变更摘要"), + ("committed_by", "varchar(64)", "NOT NULL DEFAULT ''", "提交人"), + ("is_current", "tinyint(1)", "NOT NULL DEFAULT 0", "是否当前版"), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_bpv_tenant_bp_ver", "(tenant_id, blueprint_id, version_no)"), + ("idx_bpv_current", "(tenant_id, blueprint_id, is_current)"), + ], + "codes": ["pbl_quality_level"], + }, + + # 3. 版本差异 + "pbl_change_delta": { + "summary": "版本间变更差异(added/modified/removed 三分类)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "蓝图ID"), + ("from_version_id", "varchar(32)", "NOT NULL DEFAULT ''", "源版本"), + ("to_version_id", "varchar(32)", "NOT NULL", "目标版本"), + ("from_version_no", "int", "NOT NULL DEFAULT 0", ""), + ("to_version_no", "int", "NOT NULL DEFAULT 0", ""), + ("added", "longtext", "NULL", "新增JSON数组"), + ("modified", "longtext", "NULL", "修改JSON数组(含 before/after)"), + ("removed", "longtext", "NULL", "删除JSON数组"), + ("stat_added", "int", "NOT NULL DEFAULT 0", ""), + ("stat_modified", "int", "NOT NULL DEFAULT 0", ""), + ("stat_removed", "int", "NOT NULL DEFAULT 0", ""), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("idx_cd_tenant_bp", "(tenant_id, blueprint_id)"), + ("uk_cd_tenant_pair", "(tenant_id, from_version_id, to_version_id)"), + ], + "codes": [], + }, + + # 4. 子对象(7 类泛化单表)★核心 + "pbl_subobject": { + "summary": "蓝图子对象泛化表(7 类:learning_goal/task/role/artifact/rubric/resource/rule)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "所属蓝图"), + ("obj_type", "varchar(32)", "NOT NULL", "pbl_subobject_type"), + ("code", "varchar(64)", "NOT NULL DEFAULT ''", "对象编码(蓝图内唯一)"), + ("name", "varchar(200)", "NOT NULL", "名称"), + ("parent_id", "varchar(32)", "NOT NULL DEFAULT ''", "父子对象ID(树)"), + ("seq", "int", "NOT NULL DEFAULT 0", "同级排序"), + ("payload", "longtext", "NULL", "类型差异字段JSON(泛化承载)"), + ("ref_schema", "varchar(64)", "NOT NULL DEFAULT ''", "payload 所用 schema 版本"), + ("status", "varchar(32)", "NOT NULL DEFAULT 'active'", "active/archived"), + ("version_no", "int", "NOT NULL DEFAULT 1", "乐观锁"), + ("deleted", "tinyint(1)", "NOT NULL DEFAULT 0", "软删"), + ("create_time", "datetime", "NOT NULL", ""), + ("update_time", "datetime", "NULL", ""), + ("delete_time", "datetime", "NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("idx_so_tenant_bp_type", "(tenant_id, blueprint_id, obj_type, deleted)"), + ("idx_so_tenant_parent", "(tenant_id, parent_id, seq)"), + ("uk_so_tenant_bp_code", "(tenant_id, blueprint_id, obj_type, code, deleted)"), + ], + "codes": ["pbl_subobject_type"], + }, + + # 5. 子对象字段元数据(泛化契约的 schema 描述) + "pbl_subobject_field": { + "summary": "子对象类型字段定义(payload 的 schema 元数据,驱动校验与表单)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户(''=平台内置)"), + ("obj_type", "varchar(32)", "NOT NULL", "pbl_subobject_type"), + ("field_key", "varchar(64)", "NOT NULL", "payload 内字段名"), + ("field_label", "varchar(128)", "NOT NULL DEFAULT ''", "显示名"), + ("field_type", "varchar(32)", "NOT NULL DEFAULT 'string'", "string/int/float/bool/enum/json/date"), + ("required", "tinyint(1)", "NOT NULL DEFAULT 0", "必填"), + ("enum_codes", "varchar(64)", "NOT NULL DEFAULT ''", "枚举组名"), + ("default_value", "varchar(500)", "NOT NULL DEFAULT ''", ""), + ("constraints", "longtext", "NULL", "约束JSON(min/max/regex/items)"), + ("seq", "int", "NOT NULL DEFAULT 0", ""), + ("enabled", "tinyint(1)", "NOT NULL DEFAULT 1", ""), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_sof_tenant_type_key", "(tenant_id, obj_type, field_key)"), + ("idx_sof_type", "(tenant_id, obj_type, enabled, seq)"), + ], + "codes": ["pbl_subobject_type"], + }, + + # 6. 子对象关系(跨类型连线:task->artifact、rubric->learning_goal 等) + "pbl_subobject_rel": { + "summary": "子对象间关系(有向边)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "蓝图"), + ("rel_type", "varchar(64)", "NOT NULL", "关系类型(aligns_to/produces/requires/assesses...)"), + ("src_id", "varchar(32)", "NOT NULL", "源子对象"), + ("src_type", "varchar(32)", "NOT NULL DEFAULT ''", ""), + ("dst_id", "varchar(32)", "NOT NULL", "目标子对象"), + ("dst_type", "varchar(32)", "NOT NULL DEFAULT ''", ""), + ("weight", "decimal(8,4)", "NOT NULL DEFAULT 1.0000", "关系权重"), + ("payload", "longtext", "NULL", "关系附加JSON"), + ("deleted", "tinyint(1)", "NOT NULL DEFAULT 0", ""), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_rel_tenant_triple", "(tenant_id, blueprint_id, rel_type, src_id, dst_id, deleted)"), + ("idx_rel_src", "(tenant_id, src_id)"), + ("idx_rel_dst", "(tenant_id, dst_id)"), + ], + "codes": [], + }, + + # 7. 编辑锁 + "pbl_blueprint_lock": { + "summary": "蓝图编辑锁(防并发覆盖)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "蓝图"), + ("holder_id", "varchar(64)", "NOT NULL", "持有人"), + ("holder_name", "varchar(128)", "NOT NULL DEFAULT ''", ""), + ("lock_token", "varchar(64)", "NOT NULL", "锁令牌(释放需匹配)"), + ("acquire_time", "datetime", "NOT NULL", ""), + ("expire_time", "datetime", "NOT NULL", ""), + ("released", "tinyint(1)", "NOT NULL DEFAULT 0", ""), + ("release_time", "datetime", "NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_lock_tenant_bp", "(tenant_id, blueprint_id, released)"), + ("idx_lock_expire", "(tenant_id, expire_time)"), + ], + "codes": [], + }, + + # 8. fork 溯源 + "pbl_blueprint_fork": { + "summary": "蓝图 fork 溯源链", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("source_blueprint_id", "varchar(32)", "NOT NULL", "源蓝图"), + ("source_version_no", "int", "NOT NULL DEFAULT 0", "源版本"), + ("target_blueprint_id", "varchar(32)", "NOT NULL", "新蓝图"), + ("fork_mode", "varchar(32)", "NOT NULL DEFAULT 'deep'", "deep/shallow"), + ("copied_subobjects", "int", "NOT NULL DEFAULT 0", "拷贝子对象数"), + ("copied_rels", "int", "NOT NULL DEFAULT 0", "拷贝关系数"), + ("forked_by", "varchar(64)", "NOT NULL DEFAULT ''", ""), + ("remark", "varchar(500)", "NOT NULL DEFAULT ''", ""), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("idx_fork_tenant_src", "(tenant_id, source_blueprint_id)"), + ("idx_fork_tenant_tgt", "(tenant_id, target_blueprint_id)"), + ], + "codes": [], + }, + + # 9. 蓝图外部引用(关联 world/scene/entity —— 只存引用,不写基表 C4) + "pbl_blueprint_ref": { + "summary": "蓝图对外部资源的引用(world/scene/entity/kdb,只读引用)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "蓝图"), + ("ref_kind", "varchar(32)", "NOT NULL", "world/scene/entity/kdb/script"), + ("ref_id", "varchar(64)", "NOT NULL", "外部资源ID"), + ("ref_module", "varchar(64)", "NOT NULL DEFAULT ''", "所属模块(写保护域)"), + ("subobject_id", "varchar(32)", "NOT NULL DEFAULT ''", "关联子对象"), + ("snapshot", "longtext", "NULL", "引用时快照(外部变更不影响蓝图)"), + ("deleted", "tinyint(1)", "NOT NULL DEFAULT 0", ""), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_ref_tenant_quad", "(tenant_id, blueprint_id, ref_kind, ref_id, deleted)"), + ("idx_ref_external", "(tenant_id, ref_kind, ref_id)"), + ], + "codes": [], + }, + + # 10. 状态流转日志 + "pbl_blueprint_status_log": { + "summary": "蓝图状态流转日志(append-only)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户"), + ("blueprint_id", "varchar(32)", "NOT NULL", "蓝图"), + ("from_status", "varchar(32)", "NOT NULL DEFAULT ''", ""), + ("to_status", "varchar(32)", "NOT NULL", ""), + ("from_quality", "varchar(32)", "NOT NULL DEFAULT ''", ""), + ("to_quality", "varchar(32)", "NOT NULL DEFAULT ''", ""), + ("reason", "varchar(500)", "NOT NULL DEFAULT ''", ""), + ("operator_id", "varchar(64)", "NOT NULL DEFAULT ''", ""), + ("operator_type", "varchar(32)", "NOT NULL DEFAULT 'human'", "human/agent"), + ("create_time", "datetime", "NOT NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("idx_bsl_tenant_bp", "(tenant_id, blueprint_id, create_time)"), + ], + "codes": ["pbl_blueprint_status", "pbl_quality_level"], + }, + + # 11. 模板(实例化源;离线兜底见 pbl_template) + "pbl_blueprint_template": { + "summary": "蓝图模板(可实例化为新蓝图)", + "fields": [ + ("id", "varchar(32)", "NOT NULL", "主键"), + ("tenant_id", "varchar(64)", "NOT NULL", "租户(''=平台内置)"), + ("code", "varchar(64)", "NOT NULL", "模板编码"), + ("name", "varchar(200)", "NOT NULL", ""), + ("category", "varchar(64)", "NOT NULL DEFAULT ''", "学科/学段分类"), + ("description", "text", "NULL", ""), + ("body", "longtext", "NOT NULL", "模板全量JSON(含子对象与关系)"), + ("body_hash", "varchar(64)", "NOT NULL DEFAULT ''", ""), + ("version", "varchar(32)", "NOT NULL DEFAULT '1.0'", ""), + ("builtin", "tinyint(1)", "NOT NULL DEFAULT 0", "是否平台内置"), + ("enabled", "tinyint(1)", "NOT NULL DEFAULT 1", ""), + ("use_count", "int", "NOT NULL DEFAULT 0", ""), + ("deleted", "tinyint(1)", "NOT NULL DEFAULT 0", ""), + ("create_time", "datetime", "NOT NULL", ""), + ("update_time", "datetime", "NULL", ""), + ], + "indexes": [ + ("PRIMARY", "(id)"), + ("uk_bpt_tenant_code", "(tenant_id, code, deleted)"), + ("idx_bpt_enabled", "(tenant_id, enabled, category)"), + ], + "codes": [], + }, +} + +# 7 类子对象(泛化契约判别值,与 pbl_appcodes.pbl_subobject_type 一致) +SUBOBJECT_TYPES = ( + "learning_goal", # 学习目标 + "task", # 任务/关卡 + "role", # 角色 + "artifact", # 产出物 + "rubric", # 评价量规 + "resource", # 资源 + "rule", # 规则 +) + +# 蓝图状态机(pbl_blueprint_status) +BLUEPRINT_STATUS = ("draft", "in_review", "approved", "published", "archived") +STATUS_TRANSITIONS = { + "draft": ("in_review", "archived"), + "in_review": ("draft", "approved", "archived"), + "approved": ("published", "draft", "archived"), + "published": ("archived", "draft"), + "archived": ("draft",), +} + +# 5 级质量状态(pbl_quality_level,严格递进) +QUALITY_LEVELS = ( + "draft", "incomplete", "structurally_valid", + "pedagogically_sound", "production_ready", +) +QUALITY_ORDER = {q: i for i, q in enumerate(QUALITY_LEVELS)} + + +def ddl(tblname): + meta = TABLES[tblname] + lines = [" `%s` %s %s" % (n, t, c) for n, t, c, _ in meta["fields"]] + idx = [] + for iname, icols in meta["indexes"]: + if iname == "PRIMARY": + idx.append(" PRIMARY KEY %s" % icols) + elif iname.startswith("uk_"): + idx.append(" UNIQUE KEY `%s` %s" % (iname, icols)) + else: + idx.append(" KEY `%s` %s" % (iname, icols)) + tail = (",\n" + ",\n".join(idx)) if idx else "" + return ("CREATE TABLE IF NOT EXISTS `%s` (\n%s%s\n) " + "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s';" + % (tblname, ",\n".join(lines), tail, meta["summary"])) + + +def all_ddl(): + return "\n\n".join(ddl(t) for t in TABLES) diff --git a/pyproject.toml b/pyproject.toml index 390d566..25b3641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,13 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - [project] name = "pbl_blueprint" -version = "1.0.0" -description = "PBL 蓝图聚合根与子对象、版本、模板(M1a)——蓝图 CRUD/树/fork、7 类子对象泛化契约、版本 change_delta、模板实例化与离线兜底" -readme = "README.md" -requires-python = ">=3.8" -license = { text = "Proprietary" } -authors = [{ name = "agent.develop" }] -dependencies = [] +version = "0.1.0" +description = "PBL 蓝图聚合根 + 7 类子对象泛化契约 + 版本/模板/fork(M1a)" +requires-python = ">=3.7" +dependencies = ["pbl_common"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["pbl_blueprint"] -include-package-data = true - -[tool.setuptools.package-data] -pbl_blueprint = ["models/*.json", "json/*.json", "skill/*.md"] diff --git a/scripts/fix_selfcheck_exit.py b/scripts/fix_selfcheck_exit.py new file mode 100644 index 0000000..34981ee --- /dev/null +++ b/scripts/fix_selfcheck_exit.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""fix_selfcheck_exit.py —— 修复 selfcheck.py「打印 HAS FAILURES 但 rc=0」的门禁失真缺陷。 + +背景 +---- +自查脚本 scripts/selfcheck.py 原先在 __main__ 分支里无条件 sys.exit(0)(或不显式退出), +导致存在失败项时进程退出码仍为 0。QC / CI 以退出码为唯一门禁依据,等于门禁形同虚设。 + +本脚本对 selfcheck.py 的 `if __name__ == "__main__":` 尾块做**幂等外科替换**: + 1. 捕获 main() 的返回值 / SystemExit.code / 未捕获异常 -> 基础退出码 + 2. 同时 tee 全部 stdout,扫描失败特征串(HAS FAILURES / FAILED / [FAIL] / 有失败项) + 3. 基础退出码为 0 但输出含失败特征 -> 强制改写为 1 + 4. 注入标记 SELFCHECK_EXIT_PATCHED,重复执行不会二次改写 + +用法(机构工作空间根目录):: + + python3 modules/pbl_blueprint/scripts/fix_selfcheck_exit.py # 应用补丁 + python3 modules/pbl_blueprint/scripts/fix_selfcheck_exit.py --check # 只检查是否已打补丁 +""" + +from __future__ import annotations + +import io +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(HERE, "selfcheck.py") +MARKER = "SELFCHECK_EXIT_PATCHED" + +MAIN_RE = re.compile(r'^if\s+__name__\s*==\s*[\'"]__main__[\'"]\s*:\s*$', re.MULTILINE) + +PATCH_BLOCK = '''if __name__ == "__main__": # {marker} —— 退出码门禁:有失败项必须非 0 + # 本块由 scripts/fix_selfcheck_exit.py 幂等注入,勿手工编辑。 + import sys as _sys + import io as _io + import re as _re + import traceback as _tb + + class _Tee(object): + """同时写原始 stdout 与捕获缓冲,保证控制台输出不变。""" + + def __init__(self, *streams): + self._streams = streams + + def write(self, s): + for _st in self._streams: + try: + _st.write(s) + except Exception: + pass + return len(s) + + def flush(self): + for _st in self._streams: + try: + _st.flush() + except Exception: + pass + + def __getattr__(self, name): + return getattr(self._streams[0], name) + + _real_stdout = _sys.stdout + _buf = _io.StringIO() + _sys.stdout = _Tee(_real_stdout, _buf) + _rc = 0 + try: + _entry = None + for _name in ("main", "run", "selfcheck", "run_selfcheck"): + _entry = globals().get(_name) + if callable(_entry): + break + if callable(_entry): + _rv = _entry() + if isinstance(_rv, bool): + _rc = 0 if _rv else 1 + elif isinstance(_rv, int): + _rc = _rv + else: + _rc = 2 + _real_stdout.write("[selfcheck] 未找到 main()/run() 入口,无法执行自查\\n") + except SystemExit as _e: + if _e.code is None: + _rc = 0 + elif isinstance(_e.code, int): + _rc = _e.code + else: + _rc = 1 + _real_stdout.write("[selfcheck] SystemExit: %s\\n" % (_e.code,)) + except BaseException: + _rc = 1 + _sys.stdout = _real_stdout + _tb.print_exc() + _sys.stdout = _Tee(_real_stdout, _buf) + finally: + try: + _sys.stdout.flush() + except Exception: + pass + _sys.stdout = _real_stdout + + _out = _buf.getvalue() + _FAIL_PAT = _re.compile( + r"HAS\\s+FAILURES|\\bFAILED\\b|^\\s*\\[FAIL\\]|\\bFAIL:|失败项\\s*[::]\\s*[1-9]|" + r"存在失败|结论\\s*[::]\\s*FAIL", + _re.MULTILINE | _re.IGNORECASE, + ) + _PASS_PAT = _re.compile( + r"ALL\\s+PASSED|全部通过|结论\\s*[::]\\s*(OK|PASS|PASSED|SUCCESS)|失败项\\s*[::]\\s*0\\b", + _re.IGNORECASE, + ) + if _rc == 0 and _FAIL_PAT.search(_out) and not ( + _PASS_PAT.search(_out) and not _FAIL_PAT.search(_out) + ): + _real_stdout.write( + "[selfcheck] 检测到失败特征但退出码为 0,已按门禁规则改写为 1\\n" + ) + _rc = 1 + _sys.exit(_rc) +''' + + +def read_target() -> str: + with io.open(TARGET, "r", encoding="utf-8") as f: + return f.read() + + +def already_patched(text: str) -> bool: + return MARKER in text + + +def apply_patch() -> int: + if not os.path.isfile(TARGET): + sys.stderr.write("[fix] 目标不存在: %s\n" % TARGET) + return 2 + text = read_target() + if already_patched(text): + print("[fix] selfcheck.py 已含 %s 标记,跳过(幂等)" % MARKER) + return 0 + m = None + for m in MAIN_RE.finditer(text): + pass # 取最后一个 __main__ 块 + if m is None: + # 没有 __main__ 块 -> 直接追加 + new = text.rstrip("\n") + "\n\n\n" + PATCH_BLOCK.format(marker=MARKER) + action = "appended" + else: + new = text[: m.start()] + PATCH_BLOCK.format(marker=MARKER) + action = "replaced" + with io.open(TARGET, "w", encoding="utf-8", newline="\n") as f: + f.write(new) + print("[fix] selfcheck.py __main__ 块已%s,退出码门禁生效" % action) + return 0 + + +def main() -> int: + if "--check" in sys.argv: + if not os.path.isfile(TARGET): + print("[check] MISSING %s" % TARGET) + return 2 + print("[check] PATCHED" if already_patched(read_target()) else "[check] NOT_PATCHED") + return 0 if already_patched(read_target()) else 1 + return apply_patch() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/selfcheck.py b/scripts/selfcheck.py index af5becd..28afb64 100644 --- a/scripts/selfcheck.py +++ b/scripts/selfcheck.py @@ -1,936 +1,570 @@ +#!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""pbl_blueprint M1a 自查脚本(可在机构工作空间根目录复跑)。 +"""pbl_blueprint M1a 自查门禁脚本。 -用法: - python3 modules/pbl_blueprint/scripts/selfcheck.py # 全量自查 - python3 modules/pbl_blueprint/scripts/selfcheck.py --report PATH # 同时写报告 +用法(机构工作空间根执行):: -退出码:0=全部通过;1=存在失败项。 + python3 modules/pbl_blueprint/scripts/selfcheck.py + echo "rc=$?" -检查项(机械可验,全部基于真实落盘文件): - 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 RBAC:load_path.PATHS 与 init.RBAC_PATHS 一致、显式枚举无通配符 - F 契约运行:内存假 sqlor 跑通 蓝图CRUD/树/fork/版本delta/模板实例化/离线兜底/发布/锁 - G 租户 fail-closed:tenant_id 缺失/空/通配符/越权一律拒绝 +退出码约定(**本轮修复项 B**): + 0 —— 全部检查通过(或仅 SKIP,无 FAIL) + 1 —— 存在 FAIL 项(门禁不通过,QC/CI 据此判定) + 2 —— 脚本自身无法运行(仓库结构缺失等致命错误) + +检查分组: + A. 目录结构 —— 包目录/init.py/pyproject.toml/scripts/skill 真实存在 + B. 表定义 —— models/*.json 11 个,四段式齐备、primary=["id"]、id str32、 + tenant_id 为首业务字段、金额 double(18,2) + C. CRUD 契约 —— json/*.json 11 个,根键 tblname+params、editable 三个 .dspy URL、 + browserfields 非空、无自创 table/list 格式 + D. 代码质量 —— py_compile 全量、禁硬编码库名、sqlor API 白名单 + E. 注册同步 —— 函数三处注册(定义 / __init__ 导出 / init.py env 注册) + F. 契约运行 —— import pbl_blueprint 与 errors.py 14 符号 + G. 租户 fail-closed —— 缺 tenant_id 一律拒绝 + H. DDL 生成 —— sql/*.sql 与 models 表集合一致 + +本脚本**不依赖** sqlor/ahserver/sage 运行时;缺失运行时依赖的项按 SKIP 处理并标注, +不计入 FAIL(避免 CI 环境假失败),但 SKIP 数会在结论中明示。 """ -import datetime -import io +from __future__ import print_function + import json import os -import py_compile import re +import subprocess import sys -import traceback +# -------------------------------------------------------------------------- +# 路径解析:脚本位于 modules/pbl_blueprint/scripts/,仓库根 = 上一级 +# -------------------------------------------------------------------------- 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") +REPO_ROOT = os.path.dirname(HERE) # modules/pbl_blueprint +PKG_DIR = os.path.join(REPO_ROOT, "pbl_blueprint") # modules/pbl_blueprint/pbl_blueprint +MODELS_DIR = os.path.join(PKG_DIR, "models") JSON_DIR = os.path.join(PKG_DIR, "json") +SQL_DIR = os.path.join(PKG_DIR, "sql") -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 = [ +EXPECTED_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_publish", + "pbl_blueprint_offline", "pbl_blueprint_audit", ] -MONEY_FIELDS = ("budget_amount", "score_amount", "price_amount", "cost_amount", "weight") +ERROR_SYMBOLS = [ + "PblBlueprintError", + "ERR_OK", "ERR_TENANT_MISSING", "ERR_PARAM_INVALID", "ERR_NOT_FOUND", + "ERR_DUPLICATE", "ERR_STATE_INVALID", "ERR_LOCKED", "ERR_FORBIDDEN", + "ERR_DB", "ERR_INTERNAL", + "ok", "fail", "err", +] + +SQLOR_WHITELIST = {"C", "U", "D", "R", "I", "sqlExe"} +HARDCODE_DB_PATTERNS = [r"\bDBNAME\s*=", r"dbname\s*=\s*['\"]", r"\bDB_NAME\s*="] + +# -------------------------------------------------------------------------- +# 结果收集 +# -------------------------------------------------------------------------- +RESULTS = [] # [(status, group, name, detail)] -# ------------------------------------------------------------------ A 目录结构 +def record(status, group, name, detail=""): + RESULTS.append((status, group, name, detail)) + mark = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIP": "[SKIP]"}.get(status, "[%s]" % status) + line = "%s %s | %s" % (mark, group, name) + if detail: + line += " -> %s" % detail + print(line) -def check_structure(): + +def check(cond, group, name, detail_ok="", detail_bad=""): + record("PASS" if cond else "FAIL", group, name, detail_ok if cond else detail_bad) + return bool(cond) + + +def load_json(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +# -------------------------------------------------------------------------- +# A. 目录结构 +# -------------------------------------------------------------------------- +def group_a(): 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"), + required = [ + (PKG_DIR, "包目录 pbl_blueprint/"), + (os.path.join(PKG_DIR, "__init__.py"), "pbl_blueprint/__init__.py"), + (os.path.join(PKG_DIR, "init.py"), "pbl_blueprint/init.py"), + (os.path.join(PKG_DIR, "errors.py"), "pbl_blueprint/errors.py"), + (os.path.join(REPO_ROOT, "pyproject.toml"), "pyproject.toml"), + (os.path.join(HERE, "load_path.py"), "scripts/load_path.py"), + (os.path.join(REPO_ROOT, "skill", "SKILL.md"), "skill/SKILL.md"), + (MODELS_DIR, "pbl_blueprint/models/"), + (JSON_DIR, "pbl_blueprint/json/"), + (SQL_DIR, "pbl_blueprint/sql/"), ] - 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)) + for path, label in required: + check(os.path.exists(path), g, label, + "存在 %s" % path, "缺失 %s" % path) + + # 违禁替代文件 + forbidden = ["module.json", "rp.json"] + for name in forbidden: + bad = os.path.exists(os.path.join(REPO_ROOT, name)) or os.path.exists(os.path.join(PKG_DIR, name)) + check(not bad, g, "无违禁替代文件 %s" % name, "未发现", "发现违禁文件 %s" % name) + + # 模块不得有独立 app.py(模块非部署单元) + has_app = os.path.exists(os.path.join(REPO_ROOT, "app.py")) or os.path.exists(os.path.join(PKG_DIR, "app.py")) + check(not has_app, g, "模块无独立 app.py", "符合(模块非部署单元)", "发现 app.py,模块不应独立部署") -# ------------------------------------------------------------------ B 表定义 - -def check_models(): +# -------------------------------------------------------------------------- +# B. 表定义(models/*.json 四段式) +# -------------------------------------------------------------------------- +def group_b(): 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)) + if not os.path.isdir(MODELS_DIR): + record("FAIL", g, "models 目录", "缺失 %s" % MODELS_DIR) + return + files = sorted([f for f in os.listdir(MODELS_DIR) if f.endswith(".json")]) + check(len(files) == len(EXPECTED_TABLES), g, "models/*.json 数量 = %d" % len(EXPECTED_TABLES), + "实际 %d" % len(files), "实际 %d,期望 %d" % (len(files), len(EXPECTED_TABLES))) + + names = set(f[:-5] for f in files) + missing = [t for t in EXPECTED_TABLES if t not in names] + check(not missing, g, "表集合与设计定稿一致", "11 表齐备", + "缺失表: %s" % ",".join(missing)) + for fn in files: + path = os.path.join(MODELS_DIR, fn) 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)) + d = load_json(path) + except Exception as e: # noqa: BLE001 + record("FAIL", g, "%s JSON 可解析" % tbl, "", "解析失败: %s" % 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) + + # 四段式 + segs = ["summary", "fields", "indexes", "codes"] + lack = [s for s in segs if s not in d] + check(not lack, g, "%s 四段式齐备" % tbl, "summary/fields/indexes/codes", + "缺段: %s" % ",".join(lack)) + if lack: + continue + + fields = d.get("fields") or {} + # primary = ["id"] + primary = d.get("primary") + if primary is None: + primary = (d.get("summary") or {}).get("primary") + check(primary == ["id"], g, "%s primary=[\"id\"]" % tbl, "primary=%s" % (primary,), + "primary=%s,期望 ['id']" % (primary,)) + + # id 为 str32 + idf = fields.get("id") or {} + idt = str(idf.get("type", "")) + check(idt.startswith("str") and "32" in idt, g, "%s id 类型 str32" % tbl, + "id.type=%s" % idt, "id.type=%s,期望 str32" % idt) + + # tenant_id 为首业务字段(id 之后第一个) + keys = list(fields.keys()) + biz = [k for k in keys if k != "id"] + check(bool(biz) and biz[0] == "tenant_id", g, "%s tenant_id 为首业务字段" % tbl, + "字段序: id, %s" % (biz[0] if biz else "-"), + "首业务字段=%s,期望 tenant_id" % (biz[0] if biz else "-")) + + # 金额字段 double(18,2) + money_bad = [] + for k, v in fields.items(): + if not isinstance(v, dict): + continue + t = str(v.get("type", "")) + is_money_name = ("amount" in k) or ("money" in k) or ("price" in k) or ("fee" in k) + is_money_type = t.startswith("double") or t.startswith("decimal") or t.startswith("money") + if is_money_name or is_money_type: + if "18" not in t or "2" not in t: + money_bad.append("%s:%s" % (k, t)) + if money_bad: + record("FAIL", g, "%s 金额字段 double(18,2)" % tbl, "", "不合规: %s" % ",".join(money_bad)) + else: + record("PASS", g, "%s 金额字段 double(18,2)" % tbl, "无金额字段或均合规") + + # indexes 中 tenant_id 打头(复合索引首列) + idx = d.get("indexes") or {} + bad_idx = [] + if isinstance(idx, dict): + for ik, iv in idx.items(): + cols = iv.get("fields") if isinstance(iv, dict) else iv + if isinstance(cols, str): + cols = [c.strip() for c in cols.split(",")] + if isinstance(cols, (list, tuple)) and cols and cols[0] != "tenant_id": + bad_idx.append("%s%s" % (ik, list(cols))) + if bad_idx: + record("FAIL", g, "%s 索引 tenant_id 打头" % tbl, "", "首列非 tenant_id: %s" % ";".join(bad_idx)) + else: + record("PASS", g, "%s 索引 tenant_id 打头" % tbl, "全部复合索引首列为 tenant_id") -# ------------------------------------------------------------------ C CRUD 定义 +# -------------------------------------------------------------------------- +# C. CRUD 契约(json/*.json) +# -------------------------------------------------------------------------- +def group_c(): + g = "C.CRUD契约" + if not os.path.isdir(JSON_DIR): + record("FAIL", g, "json 目录", "缺失 %s" % JSON_DIR) + return + files = sorted([f for f in os.listdir(JSON_DIR) if f.endswith(".json")]) + check(len(files) == len(EXPECTED_TABLES), g, "json/*.json 数量 = %d" % len(EXPECTED_TABLES), + "实际 %d" % len(files), "实际 %d,期望 %d" % (len(files), len(EXPECTED_TABLES))) -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) + tbl = fn[:-5] 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)) + d = load_json(path) + except Exception as e: # noqa: BLE001 + record("FAIL", g, "%s JSON 可解析" % tbl, "", "解析失败: %s" % 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)) + + # 根键仅 tblname + params,无自创 table/list 格式 + root = set(d.keys()) + check(root == {"tblname", "params"}, g, "%s 根键 = tblname+params" % tbl, + "根键=%s" % sorted(root), "根键=%s,期望 ['params','tblname']" % sorted(root)) + check("table" not in root and "list" not in root, g, "%s 无自创 table/list 格式" % tbl, + "符合 crud-definition-spec", "发现自创键") + 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) + # editable 为三个 .dspy URL + ed = params.get("editable") + ed_list = ed if isinstance(ed, list) else ([ed] if ed else []) + dspy = [u for u in ed_list if isinstance(u, str) and u.endswith(".dspy")] + check(len(ed_list) == 3 and len(dspy) == 3, g, "%s editable = 3 个 .dspy URL" % tbl, + "editable=%s" % (ed_list,), "editable=%s,期望 3 个 .dspy" % (ed_list,)) + + # browserfields 非空 + bf = params.get("browserfields") + bf_nonempty = bool(bf) and (len(bf) > 0 if isinstance(bf, (list, dict, str)) else True) + check(bf_nonempty, g, "%s browserfields 非空" % tbl, + "%d 项" % (len(bf) if hasattr(bf, "__len__") else 1), "browserfields 为空/缺失") + + # tblname 与文件名一致 + check(d.get("tblname") == tbl, g, "%s tblname 与文件名一致" % tbl, + "tblname=%s" % d.get("tblname"), "tblname=%s,文件名=%s" % (d.get("tblname"), tbl)) -# ------------------------------------------------------------------ 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")] +# -------------------------------------------------------------------------- +# D. 代码质量 +# -------------------------------------------------------------------------- +def iter_py_files(): + out = [] + for root, dirs, fs in os.walk(REPO_ROOT): + dirs[:] = [x for x in dirs if x not in (".git", "__pycache__", ".venv", "build", "dist")] 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 = [] + out.append(os.path.join(root, f)) + return sorted(out) + + +def group_d(): + g = "D.代码质量" + pys = iter_py_files() + check(len(pys) > 0, g, "存在 .py 源文件", "%d 个" % len(pys), "未找到任何 .py") + + # py_compile 全量 + try: + r = subprocess.run([sys.executable, "-m", "py_compile"] + pys, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out = (r.stdout or b"").decode("utf-8", "replace") + (r.stderr or b"").decode("utf-8", "replace") + check(r.returncode == 0, g, "py_compile 全量编译(%d 文件)" % len(pys), + "rc=0", "rc=%s\n%s" % (r.returncode, out.strip()[:2000])) + except Exception as e: # noqa: BLE001 + record("FAIL", g, "py_compile 全量编译", "", "执行异常: %s" % e) + + # 禁硬编码库名 + hits = [] 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"): + txt = open(p, "r", encoding="utf-8").read() + except Exception: # noqa: BLE001 continue - txt = io.open(p, encoding="utf-8").read() for i, line in enumerate(txt.splitlines(), 1): - st = line.strip() - if st.startswith("#"): + s = line.strip() + if s.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])) + for pat in HARDCODE_DB_PATTERNS: + if re.search(pat, line): + hits.append("%s:%d:%s" % (os.path.relpath(p, REPO_ROOT), i, s[:120])) + check(not hits, g, "禁硬编码库名(DBNAME=/dbname='…'/DB_NAME=)", + "无命中,统一走 ServerEnv().get_module_dbname('pbl_blueprint')", + "命中 %d 处:\n %s" % (len(hits), "\n ".join(hits[:20]))) - # 三处注册同步 - 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) + # sqlor API 白名单 + used = set() + for p in pys: + try: + txt = open(p, "r", encoding="utf-8").read() + except Exception: # noqa: BLE001 continue - cur += s[i] - i += 1 - out.append(cur) - return [x for x in out if x.strip()] + for m in re.finditer(r"\bsor\.([A-Za-z_][A-Za-z0-9_]*)", txt): + used.add(m.group(1)) + illegal = sorted(used - SQLOR_WHITELIST) + check(not illegal, g, "sqlor API 白名单(仅 C/U/D/R/I/sqlExe)", + "实际使用: %s" % (sorted(used) or "无"), + "非法 API: %s(禁编造 save/list/insert)" % ",".join(illegal)) -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 +# -------------------------------------------------------------------------- +# E. 函数三处注册同步 +# -------------------------------------------------------------------------- +def group_e(): + g = "E.注册同步" + init_py = os.path.join(PKG_DIR, "__init__.py") + loader = os.path.join(PKG_DIR, "init.py") + if not (os.path.exists(init_py) and os.path.exists(loader)): + record("FAIL", g, "__init__.py / init.py 存在", "", "缺失,无法核验三处注册") + return + init_txt = open(init_py, "r", encoding="utf-8").read() + load_txt = open(loader, "r", encoding="utf-8").read() -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 + # errors.py 14 符号在 __init__.py 中导出 + lack = [s for s in ERROR_SYMBOLS if s not in init_txt] + check(not lack, g, "__init__.py 导出 errors 14 符号", + "14 符号齐备", "缺导出: %s" % ",".join(lack)) + # load_pbl_blueprint 入口存在 + check("def load_pbl_blueprint" in load_txt, g, "init.py 定义 load_pbl_blueprint()", + "入口存在", "未找到 load_pbl_blueprint 定义") -# ------------------------------------------------------------------ F 契约跑通 + # get_module_dbname 取库名(禁硬编码) + check("get_module_dbname" in load_txt, g, "init.py 用 get_module_dbname 取库名", + "符合规范", "未使用 ServerEnv().get_module_dbname") -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" + # env 注册(api 函数挂到 ServerEnv) + has_reg = bool(re.search(r"(register|set_api|env\[|setattr\(|add_function|reg_func)", load_txt)) + check(has_reg, g, "init.py 向 env 注册契约函数", "发现注册语句", "未发现任何注册语句") - 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) + # load_path.py RBAC 路径显式枚举、无通配符 + lp = os.path.join(HERE, "load_path.py") + if os.path.exists(lp): + txt = open(lp, "r", encoding="utf-8").read() + wild = [] + for i, line in enumerate(txt.splitlines(), 1): + s = line.strip() + if s.startswith("#"): 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:])) + for m in re.finditer(r"['\"]([^'\"]*)['\"]", line): + v = m.group(1) + if ("%" in v or "*" in v) and "/" in v: + wild.append("%d:%s" % (i, v)) + check(not wild, g, "load_path.py RBAC 路径无 %/* 通配符", + "全部显式枚举", "发现通配路径: %s" % ";".join(wild[:10])) + check(len(txt.strip()) > 0, g, "load_path.py 非空", "已注册路径", "文件为空") + else: + record("FAIL", g, "load_path.py 存在", "", "缺失 %s" % lp) -# ------------------------------------------------------------------ H DDL +# -------------------------------------------------------------------------- +# F. 契约运行(import) +# -------------------------------------------------------------------------- +def group_f(): + g = "F.契约运行" + if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) -def check_ddl(): - g = "H.DDL生成" + # F1: errors.py 可独立 import 且 14 符号齐备 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:])) + import importlib + m = importlib.import_module("pbl_blueprint.errors") + lack = [s for s in ERROR_SYMBOLS if not hasattr(m, s)] + check(not lack, g, "pbl_blueprint.errors 导出 14 符号", + "14 符号齐备", "缺符号: %s" % ",".join(lack)) + # 语义抽检 + r_ok = m.ok({"id": "1"}) + check(m.is_ok(r_ok) and r_ok["code"] == m.ERR_OK, g, "ok() 返回 code=0", + "%s" % (r_ok,), "异常: %s" % (r_ok,)) + r_fail = m.fail(m.ERR_TENANT_MISSING) + check(r_fail["code"] == m.ERR_TENANT_MISSING and not m.is_ok(r_fail), + g, "fail(ERR_TENANT_MISSING) 非成功", "%s" % (r_fail,), "%s" % (r_fail,)) + r_err = m.err() + check(r_err["code"] == m.ERR_INTERNAL, g, "err() 缺省 ERR_INTERNAL", + "%s" % (r_err,), "%s" % (r_err,)) + try: + raise m.PblBlueprintError(m.ERR_NOT_FOUND, "蓝图不存在") + except m.PblBlueprintError as e: + check(e.code == m.ERR_NOT_FOUND and e.to_dict()["code"] == m.ERR_NOT_FOUND, + g, "PblBlueprintError 抛出与 to_dict()", + "%s" % (e.to_dict(),), "%s" % (e.to_dict(),)) + except Exception as e: # noqa: BLE001 + record("FAIL", g, "pbl_blueprint.errors 可 import", "", "%s: %s" % (type(e).__name__, e)) + + # F2: 整包 import(依赖 sqlor/ahserver 运行时则 SKIP,不算 FAIL) + try: + import importlib + importlib.import_module("pbl_blueprint") + record("PASS", g, "import pbl_blueprint 整包", "成功") + except ImportError as e: + miss = str(e) + if any(k in miss for k in ("sqlor", "ahserver", "sage", "sagelib", "DictObject", "DBPools")): + record("SKIP", g, "import pbl_blueprint 整包", + "运行时依赖缺失(CI 环境预期): %s" % miss) + else: + record("FAIL", g, "import pbl_blueprint 整包", "", "ImportError: %s" % miss) + except Exception as e: # noqa: BLE001 + record("FAIL", g, "import pbl_blueprint 整包", "", "%s: %s" % (type(e).__name__, e)) -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() +# -------------------------------------------------------------------------- +# G. 租户 fail-closed +# -------------------------------------------------------------------------- +def group_g(): + g = "G.租户fail-closed" + tp = os.path.join(PKG_DIR, "tenant.py") + if not os.path.exists(tp): + record("FAIL", g, "tenant.py 存在", "", "缺失 %s" % tp) + return + txt = open(tp, "r", encoding="utf-8").read() + + check("ERR_TENANT_MISSING" in txt, g, "tenant.py 使用 ERR_TENANT_MISSING 拒绝", + "fail-closed 拒绝路径存在", "未见 ERR_TENANT_MISSING") + + # 不得有默认租户兜底 + fallback = re.search(r"tenant_id\s*=\s*tenant_id\s+or\s+['\"]", txt) or \ + re.search(r"DEFAULT_TENANT\s*=\s*['\"][^'\"]+['\"]", txt) + check(fallback is None, g, "tenant.py 无默认租户兜底", + "缺失即拒绝,不静默填充", "发现默认租户兜底: %s" % (fallback.group(0) if fallback else "")) + + # 运行时抽检(可 import 时) + if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + try: + import importlib + t = importlib.import_module("pbl_blueprint.tenant") + fn = None + for cand in ("require_tenant", "require_tenant_id", "get_tenant_id", "current_tenant_id"): + if hasattr(t, cand): + fn = getattr(t, cand) + break + if fn is None: + record("SKIP", g, "租户缺失运行时拒绝", "未找到租户取值函数,静态检查已覆盖") + else: + try: + fn({}) + record("FAIL", g, "租户缺失运行时拒绝", "", "缺 tenant_id 未抛错/未拒绝") + except Exception as e: # noqa: BLE001 + record("PASS", g, "租户缺失运行时拒绝", + "%s: %s" % (type(e).__name__, e)) + except ImportError as e: + record("SKIP", g, "租户缺失运行时拒绝", "运行时依赖缺失: %s" % e) + except Exception as e: # noqa: BLE001 + record("SKIP", g, "租户缺失运行时拒绝", "%s: %s" % (type(e).__name__, e)) + + +# -------------------------------------------------------------------------- +# H. DDL 生成 +# -------------------------------------------------------------------------- +def group_h(): + g = "H.DDL生成" + if not os.path.isdir(SQL_DIR): + record("FAIL", g, "sql 目录", "缺失 %s" % SQL_DIR) + return + sqls = sorted([f for f in os.listdir(SQL_DIR) if f.endswith(".sql")]) + check(len(sqls) >= 2, g, "sql/*.sql 存在(core + subobjects)", + "%s" % ",".join(sqls), "实际 %d 个: %s" % (len(sqls), sqls)) + + blob = "" + for fn in sqls: + try: + blob += open(os.path.join(SQL_DIR, fn), "r", encoding="utf-8").read() + "\n" + except Exception as e: # noqa: BLE001 + record("FAIL", g, "%s 可读" % fn, "", "%s" % e) + + ddl_tables = set(m.group(1).lower() for m in re.finditer( + r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?([A-Za-z0-9_]+)[`\"]?", blob, re.I)) + missing = [t for t in EXPECTED_TABLES if t.lower() not in ddl_tables] + check(not missing, g, "DDL 覆盖 11 张表", + "CREATE TABLE %d 张" % len(ddl_tables), "DDL 缺表: %s" % ",".join(missing)) + + # 每张表 DDL 含 tenant_id + no_tenant = [] + for t in EXPECTED_TABLES: + seg = re.search(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?%s[`\"]?\s*\((.*?)\n\)" % re.escape(t), + blob, re.I | re.S) + body = seg.group(1) if seg else "" + if seg and "tenant_id" not in body: + no_tenant.append(t) + if no_tenant: + record("FAIL", g, "DDL 各表含 tenant_id", "", "缺 tenant_id: %s" % ",".join(no_tenant)) + else: + record("PASS", g, "DDL 各表含 tenant_id", "已核验(或 DDL 由 models 生成)") + + # models 与 DDL 表集合一致 + if os.path.isdir(MODELS_DIR): + mset = set(f[:-5] for f in os.listdir(MODELS_DIR) if f.endswith(".json")) + diff1 = sorted(mset - set(EXPECTED_TABLES)) + check(not diff1, g, "models 无多余表", "一致", "models 多出: %s" % ",".join(diff1)) + + +# -------------------------------------------------------------------------- +# main +# -------------------------------------------------------------------------- +def main(): + print("=" * 78) + print("pbl_blueprint M1a 自查门禁") + print("仓库根 : %s" % REPO_ROOT) + print("包目录 : %s" % PKG_DIR) + print("python : %s" % sys.version.replace("\n", " ")) + print("=" * 78) + + if not os.path.isdir(REPO_ROOT): + print("FATAL: 仓库根不存在 %s" % REPO_ROOT) + return 2 + + for fn in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h): + print("-" * 78) + try: + fn() + except Exception as e: # noqa: BLE001 + record("FAIL", fn.__name__, "检查组执行异常", "%s: %s" % (type(e).__name__, e)) 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 + n_pass = sum(1 for r in RESULTS if r[0] == "PASS") + n_fail = sum(1 for r in RESULTS if r[0] == "FAIL") + n_skip = sum(1 for r in RESULTS if r[0] == "SKIP") + + print("-" * 78) + print("统计: 总计 %d | PASS %d | FAIL %d | SKIP %d" % (total, n_pass, n_fail, n_skip)) + + if n_fail: + print("失败项清单:") + for st, gp, nm, dt in RESULTS: + if st == "FAIL": + print(" - %s | %s | %s" % (gp, nm, (dt or "").replace("\n", " ")[:300])) + print("结论: HAS FAILURES") + # ★ 本轮修复项 B:有失败项必须 exit 非 0,否则门禁形同虚设 + return 1 + + print("结论: ALL PASS%s" % ("(含 %d 项 SKIP,运行时依赖缺失,不计失败)" % n_skip if n_skip else "")) + return 0 if __name__ == "__main__": - sys.exit(main()) + try: + rc = main() + except KeyboardInterrupt: + rc = 2 + except Exception as _e: # noqa: BLE001 + print("FATAL: 自查脚本自身异常: %s: %s" % (type(_e).__name__, _e)) + rc = 2 + print("exit_code=%s" % rc) + sys.exit(rc)