[M1a] pbl_blueprint 蓝图核心表与CRUD契约:新建 errors.py(14 符号错误码内核,修复整包 import 失败)+ selfcheck.py 退出码门禁(FAIL→rc=1,8 检查组 A~H)+ git add -A 收口工作区(models/json 各 11 个 .json、audit.py/crud.py 等)

This commit is contained in:
agent.develop 2026-09-16 10:52:49 +08:00
parent 1225421d73
commit b0c7e641c6
10 changed files with 2419 additions and 1432 deletions

132
README.md
View File

@ -1,95 +1,69 @@
# pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板M1a
# pbl_blueprint — 蓝图聚合根T04★高风险件
PBLProject-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/<name>.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 <repo>/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/*.dspy17 个契约薄包装)
├── 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','=',旧值)]`,不匹配即冲突。

View File

@ -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"

View File

@ -0,0 +1,594 @@
# -*- coding: utf-8 -*-
"""蓝图 CRUD / 树 / fork / 版本 / 模板实例化M1a
所有函数首参 tenant_id内部经 pbl_common.tenant_crudC1
状态流转严格按 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

247
pbl_blueprint/errors.py Normal file
View File

@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
"""pbl_blueprint 错误码与响应构造内核M1a
本文件是 pbl_blueprint 包的**唯一**错误码/响应结构事实源被以下位置导入
- pbl_blueprint/__init__.py 包级 re-export14 个符号
- 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 "<PblBlueprintError code=%s msg=%r>" % (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)

View File

@ -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_fieldtenant_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}

413
pbl_blueprint/subobject.py Normal file
View File

@ -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):
"""取某类子对象的字段 schemaDB 元数据优先,缺失回落 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-0001fail-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}

348
pbl_blueprint/tables.py Normal file
View File

@ -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", "约束JSONmin/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)

View File

@ -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 类子对象泛化契约 + 版本/模板/forkM1a"
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"]

View File

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""fix_selfcheck_exit.py —— 修复 selfcheck.py「打印 HAS FAILURES 但 rc=0」的门禁失真缺陷。
背景
----
自查脚本 scripts/selfcheck.py 原先在 __main__ 分支里无条件 sys.exit(0)或不显式退出
导致存在失败项时进程退出码仍为 0QC / 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())

File diff suppressed because it is too large Load Diff