diff --git a/pbl_blueprint/__init__.py b/pbl_blueprint/__init__.py index e601a9d..480c6eb 100644 --- a/pbl_blueprint/__init__.py +++ b/pbl_blueprint/__init__.py @@ -1,9 +1,173 @@ -"""pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板模块(M1a)。 +# -*- coding: utf-8 -*- +"""pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板(M1a)。 -对外唯一入口:load_pbl_blueprint(env=None) -所有读写 tenant_id 强制打头,缺失租户上下文一律 fail-closed 拒绝。 +模块契约(对外只暴露这些名字,三处同步注册: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。 """ -from .init import load_pbl_blueprint +__version__ = "1.0.0" +__module_name__ = "pbl_blueprint" -__all__ = ['load_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) diff --git a/pbl_blueprint/audit.py b/pbl_blueprint/audit.py new file mode 100644 index 0000000..aa6c902 --- /dev/null +++ b/pbl_blueprint/audit.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""蓝图审计(M1a):append-only 写入 pbl_blueprint_audit。 + +审计失败不阻断主流程(记 warn),但审计表本身禁改禁删(crud.APPEND_ONLY_TABLES)。 +""" + +import time + +from . import db as _db +from .crud import new_id, now_str, APPEND_ONLY_TABLES +from .tenant import require_tenant + +AUDIT_TABLE = "pbl_blueprint_audit" +assert AUDIT_TABLE in APPEND_ONLY_TABLES + +_warns = [] + + +def write_audit(tenant_id, blueprint_id, action, target_type="blueprint", target_id="", + version_no=0, before=None, after=None, result="success", err_code="", + op_user="", op_source="api", client_ip="", cost_ms=0, sor=None): + """写一条审计记录(append-only)。tenant_id 强制打头。""" + tid = require_tenant(tenant_id) + row = { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id or "", + "target_type": target_type or "blueprint", + "target_id": target_id or "", + "action": action or "", + "version_no": int(version_no or 0), + "before_json": _db.dumps(before) if before is not None else "", + "after_json": _db.dumps(after) if after is not None else "", + "result": result or "success", + "err_code": err_code or "", + "cost_ms": int(cost_ms or 0), + "op_user": op_user or "", + "op_source": op_source or "api", + "client_ip": client_ip or "", + "create_time": now_str(), + } + try: + s = sor or _db.get_sor() + _db.q_insert(s, AUDIT_TABLE, row) + except Exception as e: # 审计失败不阻断业务 + _warns.append("audit write failed: %s" % e) + return None + return row + + +def timed_audit(tenant_id, blueprint_id, action, **kw): + """上下文管理器:自动计时 + 成功/失败落审计。""" + return _TimedAudit(tenant_id, blueprint_id, action, kw) + + +class _TimedAudit(object): + def __init__(self, tenant_id, blueprint_id, action, kw): + self.tenant_id = tenant_id + self.blueprint_id = blueprint_id + self.action = action + self.kw = kw or {} + self.t0 = 0.0 + + def __enter__(self): + self.t0 = time.time() + return self + + def __exit__(self, exc_type, exc_val, tb): + cost = int((time.time() - self.t0) * 1000) + if exc_type is None: + write_audit(self.tenant_id, self.blueprint_id, self.action, + result="success", cost_ms=cost, **self.kw) + else: + write_audit(self.tenant_id, self.blueprint_id, self.action, + result="fail", err_code=str(getattr(exc_val, "errcode", exc_type.__name__)), + cost_ms=cost, **self.kw) + return False # 不吞异常 + + +def list_audit(tenant_id, blueprint_id="", action="", page=1, page_size=50, sor=None): + """查审计(只读,tenant_id 打头)。""" + from .crud import crud_list, build_filter + conds = {} + if blueprint_id: + conds["blueprint_id"] = blueprint_id + if action: + conds["action"] = action + w, a = build_filter(AUDIT_TABLE, conds) + return crud_list(AUDIT_TABLE, tenant_id, w, a, "*", "create_time desc", page, page_size, True, sor) + + +def get_warns(): + return list(_warns) diff --git a/pbl_blueprint/crud.py b/pbl_blueprint/crud.py new file mode 100644 index 0000000..5abcd9d --- /dev/null +++ b/pbl_blueprint/crud.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +"""CRUD 工厂(M1a):为 11 张表统一生成 tenant_id 强制打头的增删改查契约。 + +设计要点: +1. 所有生成的函数第一个参数固定为 tenant_id,内部 require_tenant 校验,缺失即 fail-closed 抛错; +2. 所有 WHERE 条件由 tenant_where() 生成,`tenant_id=?` 永远在最前,杜绝跨租户读写; +3. 只用 sqlor 白名单 API:C / U / D / R / I / sqlExe(封装在 db.py); +4. 删除默认逻辑删除(deleted=1),审计表 append-only 禁改禁删; +5. 表名/字段名来自 models/*.json 白名单,禁止拼接外部传入的表名列名(防注入)。 +""" + +import os +import json as _json +import uuid +import datetime + +from . import db as _db +from .errors import ( + ERR_PARAM_INVALID, + ERR_NOT_FOUND, + ERR_DUPLICATE, + ERR_FORBIDDEN, + PblBlueprintError, + ok, +) +from .tenant import require_tenant, tenant_where, inject_tenant + +MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models") + +# append-only 表:只允许 C / R,禁 U / D +APPEND_ONLY_TABLES = ("pbl_blueprint_audit",) + +# 不可由外部写入的服务端字段 +SERVER_FIELDS = ( + "create_user", + "create_time", + "update_user", + "update_time", + "deleted", +) + +_model_cache = {} + + +def new_id(): + """生成 str32 主键(UUID 去横线)。""" + return uuid.uuid4().hex + + +def now_str(): + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def load_model(tbl): + """读取 models/{tbl}.json 表定义(带缓存),返回 dict。""" + if tbl in _model_cache: + return _model_cache[tbl] + path = os.path.join(MODEL_DIR, "%s.json" % tbl) + if not os.path.isfile(path): + raise PblBlueprintError(ERR_PARAM_INVALID, "未知表: %s" % tbl) + with open(path, "r", encoding="utf-8") as f: + m = _json.load(f) + _model_cache[tbl] = m + return m + + +def list_tables(): + """列出本模块全部表名(来自 models 目录,真实落盘为准)。""" + if not os.path.isdir(MODEL_DIR): + return [] + out = [] + for fn in sorted(os.listdir(MODEL_DIR)): + if fn.endswith(".json"): + out.append(fn[:-5]) + return out + + +def table_fields(tbl): + """返回表字段名列表(保持定义顺序,tenant_id 在 id 之后即首业务字段)。""" + m = load_model(tbl) + return list((m.get("fields") or {}).keys()) + + +def _check_fields(tbl, data): + """字段白名单校验:拒绝未定义字段(防注入/防脏写)。""" + allowed = set(table_fields(tbl)) + bad = [k for k in data.keys() if k not in allowed] + if bad: + raise PblBlueprintError(ERR_PARAM_INVALID, "表 %s 无字段: %s" % (tbl, ",".join(bad))) + + +def _check_required(tbl, data, partial=False): + """notnull 且无 default 的字段必填校验。""" + m = load_model(tbl) + miss = [] + for name, spec in (m.get("fields") or {}).items(): + if name in ("id", "tenant_id"): + continue + if not spec.get("notnull"): + continue + if "default" in spec: + continue + if partial and name not in data: + continue + if name not in data or data[name] in (None, ""): + miss.append(name) + if miss: + raise PblBlueprintError(ERR_PARAM_INVALID, "缺少必填字段: %s" % ",".join(miss)) + + +def _apply_default(tbl, data): + """填充 default 值。""" + m = load_model(tbl) + out = dict(data) + for name, spec in (m.get("fields") or {}).items(): + if name in out and out[name] not in (None, ""): + continue + if "default" in spec: + out[name] = spec["default"] + return out + + +# ---------------------------------------------------------------- 通用 CRUD + +def crud_create(tbl, tenant_id, data, op_user="", sor=None): + """新增一行。tenant_id 强制打头注入,返回新行 dict。""" + tid = require_tenant(tenant_id) + row = inject_tenant(tid, data or {}) + _check_fields(tbl, row) + row = _apply_default(tbl, row) + if not row.get("id"): + row["id"] = new_id() + _check_required(tbl, row) + row["create_time"] = now_str() + if op_user: + row["create_user"] = op_user + if "update_time" in table_fields(tbl): + row["update_time"] = row["create_time"] + if op_user and "update_user" in table_fields(tbl): + row["update_user"] = op_user + if "deleted" in table_fields(tbl) and not row.get("deleted"): + row["deleted"] = 0 + s = sor or _db.get_sor() + try: + _db.q_insert(s, tbl, row) + except PblBlueprintError: + raise + except Exception as e: + msg = str(e) + if "Duplicate" in msg or "UNIQUE" in msg.upper(): + raise PblBlueprintError(ERR_DUPLICATE, "唯一约束冲突: %s" % msg) + raise PblBlueprintError(ERR_PARAM_INVALID, "写入失败: %s" % msg) + return row + + +def crud_get(tbl, tenant_id, row_id, fields="*", sor=None, include_deleted=False): + """按主键取单行(tenant_id 打头过滤)。不存在抛 ERR_NOT_FOUND。""" + tid = require_tenant(tenant_id) + rid = (row_id or "").strip() if isinstance(row_id, str) else row_id + if not rid: + raise PblBlueprintError(ERR_PARAM_INVALID, "id 不能为空") + extra = "" if include_deleted else "deleted=0" + where, args = tenant_where(tid, "id=?" + (" and " + extra if extra else ""), [rid]) + s = sor or _db.get_sor() + row = _db.q_one(s, tbl, where, args, fields) + if not row: + raise PblBlueprintError(ERR_NOT_FOUND, "%s[%s] 不存在或不属于当前租户" % (tbl, rid)) + return row + + +def crud_update(tbl, tenant_id, row_id, data, op_user="", sor=None): + """按主键更新(tenant_id 打头过滤),返回受影响行数。append-only 表拒绝。""" + tid = require_tenant(tenant_id) + if tbl in APPEND_ONLY_TABLES: + raise PblBlueprintError(ERR_FORBIDDEN, "%s 为 append-only 表,禁止更新" % tbl) + rid = (row_id or "").strip() if isinstance(row_id, str) else row_id + if not rid: + raise PblBlueprintError(ERR_PARAM_INVALID, "id 不能为空") + payload = dict(data or {}) + for k in ("id", "tenant_id"): + payload.pop(k, None) + for k in ("create_user", "create_time"): + payload.pop(k, None) + if not payload: + raise PblBlueprintError(ERR_PARAM_INVALID, "无待更新字段") + _check_fields(tbl, payload) + _check_required(tbl, payload, partial=True) + if "update_time" in table_fields(tbl): + payload["update_time"] = now_str() + if op_user and "update_user" in table_fields(tbl): + payload["update_user"] = op_user + crud_get(tbl, tid, rid, "id", sor=sor) # 存在性 + 租户归属校验 + where, args = tenant_where(tid, "id=? and deleted=0", [rid]) + s = sor or _db.get_sor() + return _db.q_update(s, tbl, payload, where, args) + + +def crud_delete(tbl, tenant_id, row_id, op_user="", sor=None, hard=False): + """删除(默认逻辑删除 deleted=1)。append-only 表拒绝。""" + tid = require_tenant(tenant_id) + if tbl in APPEND_ONLY_TABLES: + raise PblBlueprintError(ERR_FORBIDDEN, "%s 为 append-only 表,禁止删除" % tbl) + rid = (row_id or "").strip() if isinstance(row_id, str) else row_id + if not rid: + raise PblBlueprintError(ERR_PARAM_INVALID, "id 不能为空") + crud_get(tbl, tid, rid, "id", sor=sor) + where, args = tenant_where(tid, "id=? and deleted=0", [rid]) + s = sor or _db.get_sor() + if hard: + return _db.q_delete(s, tbl, where, args) + return _db.q_update(s, tbl, {"deleted": 1, "update_time": now_str()}, where, args) + + +def crud_list(tbl, tenant_id, where="", args=None, fields="*", order="", page=1, + page_size=20, include_deleted=False, sor=None): + """分页列表查询。tenant_id 打头,返回 {rows,total,page,page_size}。 + + where/args 只接受内部构造的参数化条件;禁止把外部字符串直接当 SQL 片段传入 + (调用方须用 build_filter 生成)。 + """ + tid = require_tenant(tenant_id) + conds = [] if include_deleted else ["deleted=0"] + if where: + conds.append(where) + w, a = tenant_where(tid, " and ".join(conds), list(args or [])) + try: + page = max(1, int(page or 1)) + page_size = min(500, max(1, int(page_size or 20))) + except Exception: + raise PblBlueprintError(ERR_PARAM_INVALID, "page/page_size 非法") + s = sor or _db.get_sor() + total = _db.q_count(s, tbl, w, a) + rows = _db.q_select(s, tbl, w, a, fields, order or "", page_size, (page - 1) * page_size) + return {"rows": rows or [], "total": total, "page": page, "page_size": page_size} + + +def crud_count(tbl, tenant_id, where="", args=None, include_deleted=False, sor=None): + """计数(tenant_id 打头)。""" + tid = require_tenant(tenant_id) + conds = [] if include_deleted else ["deleted=0"] + if where: + conds.append(where) + w, a = tenant_where(tid, " and ".join(conds), list(args or [])) + s = sor or _db.get_sor() + return _db.q_count(s, tbl, w, a) + + +# ---------------------------------------------------------------- 条件构造 + +ALLOWED_OPS = ("=", "!=", ">", ">=", "<", "<=", "like", "in", "is", "is not") + + +def build_filter(tbl, conds): + """把 {字段: 值} 或 [(字段, op, 值)] 转成参数化 WHERE(字段名走白名单)。 + + 返回 (sql, args);sql 不含 tenant_id(由 crud_list 统一打头拼接)。 + """ + allowed = set(table_fields(tbl)) + parts, args = [], [] + items = conds.items() if isinstance(conds, dict) else conds + for item in items: + if isinstance(item, (tuple, list)) and len(item) == 3: + name, op, val = item + else: + name, val = item + op = "like" if isinstance(val, str) and ("%" in val or "_" in val) else "=" + name = str(name).strip() + if name not in allowed: + raise PblBlueprintError(ERR_PARAM_INVALID, "非法查询字段: %s" % name) + op = str(op).strip().lower() + if op not in ALLOWED_OPS: + raise PblBlueprintError(ERR_PARAM_INVALID, "非法操作符: %s" % op) + if op == "in": + vals = list(val or []) + if not vals: + continue + parts.append("%s in (%s)" % (name, ",".join(["?"] * len(vals)))) + args.extend(vals) + elif op in ("is", "is not"): + parts.append("%s %s null" % (name, op)) + else: + parts.append("%s %s ?" % (name, op)) + args.append(val) + return (" and ".join(parts), args) + + +# ---------------------------------------------------------------- 工厂 + +def make_crud(tbl): + """为指定表生成一组绑定 tenant 的契约函数(供 init.py 注册到 env)。""" + load_model(tbl) # 提前校验表定义存在 + append_only = tbl in APPEND_ONLY_TABLES + + def _create(tenant_id, data, op_user="", sor=None): + return crud_create(tbl, tenant_id, data, op_user, sor) + + def _get(tenant_id, row_id, fields="*", sor=None): + return crud_get(tbl, tenant_id, row_id, fields, sor) + + def _list(tenant_id, conds=None, fields="*", order="", page=1, page_size=20, sor=None): + w, a = build_filter(tbl, conds or {}) + return crud_list(tbl, tenant_id, w, a, fields, order, page, page_size, False, sor) + + def _count(tenant_id, conds=None, sor=None): + w, a = build_filter(tbl, conds or {}) + return crud_count(tbl, tenant_id, w, a, False, sor) + + def _update(tenant_id, row_id, data, op_user="", sor=None): + if append_only: + raise PblBlueprintError(ERR_FORBIDDEN, "%s 为 append-only 表,禁止更新" % tbl) + return crud_update(tbl, tenant_id, row_id, data, op_user, sor) + + def _delete(tenant_id, row_id, op_user="", sor=None, hard=False): + if append_only: + raise PblBlueprintError(ERR_FORBIDDEN, "%s 为 append-only 表,禁止删除" % tbl) + return crud_delete(tbl, tenant_id, row_id, op_user, sor, hard) + + return { + "create": _create, + "get": _get, + "list": _list, + "count": _count, + "update": _update, + "delete": _delete, + } diff --git a/pbl_blueprint/db.py b/pbl_blueprint/db.py index bfe6fda..7f8ff33 100644 --- a/pbl_blueprint/db.py +++ b/pbl_blueprint/db.py @@ -1,153 +1,122 @@ -"""pbl_blueprint 数据访问层。 +# -*- coding: utf-8 -*- +"""DB 适配层(M1a):统一取 sqlor 句柄,禁止硬编码库名。 -- 库名一律 ServerEnv().get_module_dbname('pbl_blueprint'),禁止硬编码 DBNAME。 -- 所有 SQL 只用 sqlor 标准 API:sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe。 -- tenant_id 强制打头:任何读写条件第一列必须是 tenant_id,缺失即抛 PblTenantRequired。 +库名一律来自应用注入的 ServerEnv().get_module_dbname('pbl_blueprint'); +本模块只出现模块名常量 MODULE_NAME,不出现任何具体库名字面量。 +sqlor 白名单:仅使用 C / U / D / R / I / sqlExe 六个标准 API。 """ -import os -import uuid +import json as _json -MODULE_NAME = 'pbl_blueprint' +MODULE_NAME = "pbl_blueprint" -TABLES = [ - 'pbl_blueprint', - 'pbl_blueprint_version', - 'pbl_blueprint_template', - 'pbl_blueprint_template_item', - 'pbl_blueprint_task', - 'pbl_blueprint_mission', - 'pbl_blueprint_role', - 'pbl_blueprint_learning_goal', - 'pbl_blueprint_evidence_spec', - 'pbl_blueprint_artifact_spec', - 'pbl_blueprint_reflection_spec', -] +_sor_cache = {} -class PblError(Exception): - """pbl 模块统一异常基类。""" - - code = 'PBL_ERROR' - - def __init__(self, message, code=None, **extra): - super(PblError, self).__init__(message) - self.message = message - if code: - self.code = code - self.extra = extra - - -class PblTenantRequired(PblError): - """租户上下文缺失(fail-closed)。""" - - code = 'PBL_TENANT_REQUIRED' - - -class PblNotFound(PblError): - """记录不存在。""" - - code = 'PBL_NOT_FOUND' - - -class PblValidationError(PblError): - """入参校验失败。""" - - code = 'PBL_VALIDATION_ERROR' - - -def new_id(): - """32 位无横线 UUID 主键。""" - return uuid.uuid4().hex - - -def get_env(env=None): - if env is not None: - return env +def _server_env(): + """取平台 ServerEnv(延迟导入,避免模块单独 import 时依赖平台)。""" try: - from sage import ServerEnv + from sage import ServerEnv # type: ignore return ServerEnv() except Exception: - return None - - -def get_dbname(env=None): - """取本模块库名,禁止硬编码。""" - e = get_env(env) - if e is None: - return None - getter = getattr(e, 'get_module_dbname', None) - if callable(getter): try: - return getter(MODULE_NAME) + from ahserver import ServerEnv # type: ignore + return ServerEnv() except Exception: return None - return None -def get_sor(env=None, dbname=None): - """取 sqlor 数据访问对象。""" - e = get_env(env) - if e is None: +def get_dbname(): + """取本模块库名(由应用 init() 注入的 get_module_dbname 决定)。""" + env = _server_env() + if env is None: + return "" + fn = getattr(env, "get_module_dbname", None) + if not callable(fn): + return "" + try: + return fn(MODULE_NAME) or "" + except Exception: + return "" + + +def get_sor(dbname=None): + """取 sqlor 句柄(按库名缓存)。 + + :param dbname: 可选,缺省用 get_dbname() + """ + db = dbname or get_dbname() + if not db: + raise RuntimeError("pbl_blueprint: 未取到库名,请确认应用 init() 已注入 get_module_dbname") + key = str(db) + if key in _sor_cache: + return _sor_cache[key] + from sqlor import sqlor # type: ignore + sor = sqlor(db) + _sor_cache[key] = sor + return sor + + +def clear_cache(): + _sor_cache.clear() + + +# ---------- sqlor 白名单封装(仅 C/U/D/R/I/sqlExe) ---------- + +def q_insert(sor, tbl, data): + """新增一行 -> sor.C""" + return sor.C(tbl, data) + + +def q_update(sor, tbl, data, where, args=None): + """按条件更新 -> sor.U""" + return sor.U(tbl, data, where, args or []) + + +def q_delete(sor, tbl, where, args=None): + """按条件删除 -> sor.D""" + return sor.D(tbl, where, args or []) + + +def q_select(sor, tbl, where="", args=None, fields="*", order="", limit=0, offset=0): + """条件查询 -> sor.R""" + return sor.R(tbl, where, args or [], fields, order, limit, offset) + + +def q_one(sor, tbl, where, args=None, fields="*"): + """取单行(无则 None)-> sor.R""" + rows = sor.R(tbl, where, args or [], fields, "", 1, 0) + if not rows: return None - db = dbname or get_dbname(e) - for attr in ('sqlor', 'sor'): - obj = getattr(e, attr, None) - if obj is not None: - return obj - getter = getattr(e, 'get_sqlor', None) - if callable(getter): - try: - return getter(db) if db else getter() - except Exception: - return None - return None + return rows[0] -def require_tenant(tenant_id): - """tenant_id 强制打头校验:空/非字符串一律拒绝。""" - if tenant_id is None: - raise PblTenantRequired('tenant_id is required (租户上下文缺失,拒绝访问)') - if not isinstance(tenant_id, str) or not tenant_id.strip(): - raise PblTenantRequired('tenant_id must be a non-empty string') - return tenant_id.strip() +def q_count(sor, tbl, where="", args=None): + """计数 -> sor.I""" + return sor.I(tbl, where, args or []) -def ensure_tables(env=None, dbname=None): - """幂等建表:执行 sql/pbl_blueprint.core.sql 与 sql/pbl_blueprint.subobjects.sql。""" - e = get_env(env) - db = dbname or get_dbname(e) - sor = get_sor(e, db) - if sor is None: - return False - sql_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'sql') - executed = [] - for fname in ('pbl_blueprint.core.sql', 'pbl_blueprint.subobjects.sql'): - path = os.path.join(sql_dir, fname) - if not os.path.isfile(path): - continue - with open(path, 'r', encoding='utf-8') as fp: - content = fp.read() - for stmt in _split_sql(content): - try: - sor.sqlExe(stmt) - executed.append(stmt[:40]) - except Exception: - continue - return executed +def q_sql(sor, sql, args=None): + """原生 SQL(只读/DDL 场景)-> sor.sqlExe""" + return sor.sqlExe(sql, args or []) -def _split_sql(content): - """按分号切分 DDL,剔除注释行与空语句。""" - lines = [] - for line in content.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith('--'): - continue - lines.append(line) - stmts = [] - for chunk in '\n'.join(lines).split(';'): - chunk = chunk.strip() - if chunk: - stmts.append(chunk) - return stmts +def dumps(obj): + """JSON 序列化(None 安全,中文不转义)。""" + if obj is None: + return "" + if isinstance(obj, str): + return obj + return _json.dumps(obj, ensure_ascii=False, default=str) + + +def loads(txt, default=None): + """JSON 反序列化(失败返回 default,不抛)。""" + if txt is None or txt == "": + return default + if isinstance(txt, (dict, list)): + return txt + try: + return _json.loads(txt) + except Exception: + return default diff --git a/pbl_blueprint/init.py b/pbl_blueprint/init.py index 6afe7b5..b4d89aa 100644 --- a/pbl_blueprint/init.py +++ b/pbl_blueprint/init.py @@ -1,162 +1,317 @@ -"""pbl_blueprint 模块装配入口。 +# -*- coding: utf-8 -*- +"""pbl_blueprint 模块挂载(M1a):建表 + 契约函数注册 + RBAC 路径注册。 -三处同步注册(module-development-spec): - 1) 函数定义:本文件 load_pbl_blueprint() - 2) 包导出: __init__.py -> from .init import load_pbl_blueprint - 3) env 注册:ServerEnv().register_module('pbl_blueprint', load_pbl_blueprint) +三处同步注册(缺一即 selfcheck 失败): + 1) service.py / crud.py 中定义函数 + 2) __init__.py CONTRACT_FUNCTIONS 导出 + 3) 本文件 REGISTER_FUNCTIONS 注册到 ServerEnv + +RBAC 路径显式枚举(无 %/* 通配符),逐条列出,便于权限审计。 +库名一律来自 env.get_module_dbname('pbl_blueprint'),本文件不出现任何库名字面量。 """ import os -from .db import ensure_tables, get_dbname -from .blueprint_crud import ( - create_blueprint, - update_blueprint, - delete_blueprint, - get_blueprint, - list_blueprints, - get_blueprint_tree, - fork_blueprint, - save_version, - list_versions, - diff_versions, - rollback_version, -) -from .subobjects import ( - SUBOBJECT_TYPES, - create_subobject, - update_subobject, - delete_subobject, - get_subobject, - list_subobjects, - batch_upsert_subobjects, -) -from .templates import ( - create_template, - update_template, - delete_template, - list_templates, - get_template, - instantiate_template, - build_offline_fallback_blueprint, +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", ) -MODULE_NAME = 'pbl_blueprint' +# ---- 2) CRUD 契约注册清单(表名 -> 注册前缀) ---- +REGISTER_CRUD = REGISTER_TABLES -# 本模块全部表(核心 4 + 子对象 7) -TABLES = [ - 'pbl_blueprint', - 'pbl_blueprint_version', - 'pbl_blueprint_template', - 'pbl_blueprint_template_item', - 'pbl_blueprint_task', - 'pbl_blueprint_mission', - 'pbl_blueprint_role', - 'pbl_blueprint_learning_goal', - 'pbl_blueprint_evidence_spec', - 'pbl_blueprint_artifact_spec', - 'pbl_blueprint_reflection_spec', -] +# ---- 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} -def load_pbl_blueprint(env=None): - """挂载 pbl_blueprint 模块到应用。 - - :param env: ServerEnv 实例(应用 init() 里逐个 load 时传入);为空则自建。 - :return: 模块契约字典(供应用/其他模块按契约调用) - """ - if env is None: - try: - from sage import ServerEnv - env = ServerEnv() - except Exception: - env = None - - dbname = get_dbname(env) - - # 建表(幂等,IF NOT EXISTS) - ensure_tables(env, dbname) - - # CRUD 定义注册(json/*.json) - _register_crud_json(env) - - contract = { - 'module': MODULE_NAME, - 'dbname': dbname, - 'tables': list(TABLES), - 'subobject_types': list(SUBOBJECT_TYPES), - # 蓝图 CRUD 与查询契约 - 'create_blueprint': create_blueprint, - 'update_blueprint': update_blueprint, - 'delete_blueprint': delete_blueprint, - 'get_blueprint': get_blueprint, - 'list_blueprints': list_blueprints, - 'get_blueprint_tree': get_blueprint_tree, - 'fork_blueprint': fork_blueprint, - # 版本契约 - 'save_version': save_version, - 'list_versions': list_versions, - 'diff_versions': diff_versions, - 'rollback_version': rollback_version, - # 7 类子对象泛化契约 - 'create_subobject': create_subobject, - 'update_subobject': update_subobject, - 'delete_subobject': delete_subobject, - 'get_subobject': get_subobject, - 'list_subobjects': list_subobjects, - 'batch_upsert_subobjects': batch_upsert_subobjects, - # 模板契约 - 'create_template': create_template, - 'update_template': update_template, - 'delete_template': delete_template, - 'get_template': get_template, - 'list_templates': list_templates, - 'instantiate_template': instantiate_template, - 'build_offline_fallback_blueprint': build_offline_fallback_blueprint, - } - +def _get_env(env=None): if env is not None: - for fn in ('register_module', 'set_module_contract', 'register_contract'): - reg = getattr(env, fn, None) - if callable(reg): - try: - reg(MODULE_NAME, contract) - except TypeError: - try: - reg(contract) - except Exception: - pass - except Exception: - pass - break - - return contract + return env + return _db._server_env() -def _register_crud_json(env): - """把 json/*.json 的 CRUD 定义注册到 env(若平台提供注册钩子)。""" - json_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'json') - if not os.path.isdir(json_dir): - return - defs = {} - for fname in sorted(os.listdir(json_dir)): - if not fname.endswith('.json'): - continue - path = os.path.join(json_dir, fname) - try: - import json as _json - with open(path, 'r', encoding='utf-8') as fp: - defs[fname[:-5]] = _json.load(fp) - except Exception: - continue - if env is None: - return defs - for fn in ('register_crud', 'load_crud_json', 'set_crud_defs'): - reg = getattr(env, fn, None) - if callable(reg): +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"): try: - reg(defs) + env.register_table(MODULE_NAME, tbl, model) except Exception: pass - break - return defs + if create_ddl and sor is not None: + try: + _ensure_table(sor, tbl, model) + except Exception: + pass # 建表失败不阻断挂载(部署阶段由 DDL 脚本保证) + n += 1 + return n + + +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 diff --git a/pbl_blueprint/json/pbl_blueprint.json b/pbl_blueprint/json/pbl_blueprint.json index 0cc5843..b67eaaf 100644 --- a/pbl_blueprint/json/pbl_blueprint.json +++ b/pbl_blueprint/json/pbl_blueprint.json @@ -1,58 +1,214 @@ { "tblname": "pbl_blueprint", "params": { - "editable": [ - "code", - "name", - "subject", - "grade", - "duration_hours", - "status", - "summary", - "config", - "source_blueprint_id", - "template_id", - "owner_id", - "remark" - ], - "browserfields": [ - "code", - "name", - "subject", - "grade", - "duration_hours", - "version_no", - "status", - "quality_level", - "owner_id", - "template_id", - "published_at", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "code", - "name", - "subject", - "status", - "owner_id" - ], - "orderby": "updated_at desc", - "defaultfilter": { - "deleted": 0 + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" }, - "caption": "PBL蓝图", - "readonly": [ - "id", - "tenant_id", - "version_no", - "quality_level", - "published_at", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "code": { + "type": "str", + "size": 64, + "notnull": true, + "editable": true, + "query": true, + "summary": "蓝图编码(租户内唯一,PBL-BP-序号)" + }, + "name": { + "type": "str", + "size": 128, + "notnull": true, + "editable": true, + "query": true, + "summary": "蓝图名称" + }, + "subject": { + "type": "str", + "size": 64, + "default": "", + "editable": true, + "query": true, + "summary": "学科" + }, + "grade": { + "type": "str", + "size": 32, + "default": "", + "editable": true, + "query": true, + "summary": "年级" + }, + "phase": { + "type": "str", + "size": 32, + "default": "", + "editable": true, + "query": true, + "summary": "学段" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "draft", + "editable": true, + "query": true, + "code": "status", + "summary": "蓝图状态" + }, + "quality_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "q0", + "editable": true, + "query": true, + "code": "quality_status", + "summary": "质量状态(5 级)" + }, + "source": { + "type": "str", + "size": 16, + "notnull": true, + "default": "manual", + "editable": true, + "query": false, + "code": "source", + "summary": "来源方式" + }, + "source_id": { + "type": "str", + "size": 32, + "default": "", + "editable": true, + "query": true, + "summary": "来源对象ID(模板ID或父蓝图ID)" + }, + "owner_id": { + "type": "str", + "size": 32, + "default": "", + "editable": true, + "query": true, + "summary": "负责人(教师)ID" + }, + "class_id": { + "type": "str", + "size": 32, + "default": "", + "editable": true, + "query": true, + "summary": "关联班级ID" + }, + "current_version": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "当前版本号(save_version 回写)" + }, + "duration_hours": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "预计课时数" + }, + "budget_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "预算金额(double(18,2))" + }, + "summary": { + "type": "str", + "size": 512, + "default": "", + "editable": true, + "query": false, + "summary": "蓝图简介" + }, + "ext_json": { + "type": "text", + "default": "", + "editable": true, + "query": false, + "summary": "扩展属性JSON" + }, + "publish_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "最近发布时间" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "更新人" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_list.dspy", + "api/pbl_blueprint_edit.dspy", + "api/pbl_blueprint_view.dspy" + ], + "browserfields": [ + "code", + "name", + "subject", + "status", + "quality_status", + "owner_id", + "update_time" + ] } diff --git a/pbl_blueprint/json/pbl_blueprint_artifact_spec.json b/pbl_blueprint/json/pbl_blueprint_artifact_spec.json deleted file mode 100644 index 22a61a0..0000000 --- a/pbl_blueprint/json/pbl_blueprint_artifact_spec.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tblname": "pbl_blueprint_artifact_spec", - "params": { - "editable": [ - "blueprint_id", - "task_id", - "mission_id", - "seq", - "code", - "name", - "artifact_type", - "description", - "spec", - "accept_criteria", - "required", - "weight", - "status" - ], - "browserfields": [ - "blueprint_id", - "task_id", - "mission_id", - "seq", - "code", - "name", - "artifact_type", - "required", - "weight", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "task_id", - "mission_id", - "artifact_type", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-产出物规格", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_audit.json b/pbl_blueprint/json/pbl_blueprint_audit.json new file mode 100644 index 0000000..b313a09 --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_audit.json @@ -0,0 +1,153 @@ +{ + "tblname": "pbl_blueprint_audit", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "蓝图ID" + }, + "target_type": { + "type": "str", + "size": 16, + "notnull": true, + "editable": true, + "query": true, + "code": "target_type", + "summary": "操作对象类型" + }, + "target_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "操作对象ID" + }, + "action": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "code": "action", + "summary": "操作动作" + }, + "version_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "关联版本号" + }, + "before_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "操作前数据JSON" + }, + "after_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "操作后数据JSON" + }, + "result": { + "type": "str", + "size": 16, + "notnull": true, + "default": "success", + "editable": true, + "query": true, + "code": "result", + "summary": "操作结果" + }, + "err_code": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": false, + "query": false, + "summary": "失败错误码" + }, + "cost_ms": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "耗时毫秒" + }, + "op_user": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "操作人" + }, + "op_source": { + "type": "str", + "size": 32, + "notnull": true, + "default": "api", + "editable": true, + "query": true, + "summary": "操作来源(web/api/agent/offline)" + }, + "client_ip": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "客户端IP" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": true, + "summary": "操作时间" + } + }, + "editable": [ + "api/pbl_blueprint_audit_list.dspy", + "api/pbl_blueprint_audit_edit.dspy", + "api/pbl_blueprint_audit_view.dspy" + ], + "browserfields": [ + "blueprint_id", + "target_type", + "action", + "result", + "op_user", + "op_source", + "create_time" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_edge.json b/pbl_blueprint/json/pbl_blueprint_edge.json new file mode 100644 index 0000000..be6f09a --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_edge.json @@ -0,0 +1,122 @@ +{ + "tblname": "pbl_blueprint_edge", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "所属蓝图ID" + }, + "from_node_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "起点节点ID" + }, + "to_node_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "终点节点ID" + }, + "edge_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "depends", + "editable": true, + "query": true, + "code": "edge_type", + "summary": "边类型" + }, + "weight": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "边权重(double(18,2))" + }, + "condition_json": { + "type": "text", + "default": "", + "editable": true, + "query": false, + "summary": "触发条件JSON" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "排序号" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_edge_list.dspy", + "api/pbl_blueprint_edge_edit.dspy", + "api/pbl_blueprint_edge_view.dspy" + ], + "browserfields": [ + "from_node_id", + "to_node_id", + "edge_type", + "weight", + "sort_no" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_evidence_spec.json b/pbl_blueprint/json/pbl_blueprint_evidence_spec.json deleted file mode 100644 index 76668df..0000000 --- a/pbl_blueprint/json/pbl_blueprint_evidence_spec.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "tblname": "pbl_blueprint_evidence_spec", - "params": { - "editable": [ - "blueprint_id", - "task_id", - "mission_id", - "learning_goal_id", - "seq", - "code", - "name", - "evidence_type", - "collect_mode", - "description", - "spec", - "rule", - "weight", - "status" - ], - "browserfields": [ - "blueprint_id", - "task_id", - "mission_id", - "learning_goal_id", - "seq", - "code", - "name", - "evidence_type", - "collect_mode", - "weight", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "task_id", - "mission_id", - "learning_goal_id", - "evidence_type", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-证据规格", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_fork.json b/pbl_blueprint/json/pbl_blueprint_fork.json new file mode 100644 index 0000000..60b1a27 --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_fork.json @@ -0,0 +1,137 @@ +{ + "tblname": "pbl_blueprint_fork", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "source_blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "源蓝图ID(模板实例化时为空串)" + }, + "source_version": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "fork 时的源版本号" + }, + "target_blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "派生出的新蓝图ID" + }, + "fork_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "copy", + "editable": true, + "query": true, + "code": "fork_type", + "summary": "fork 方式" + }, + "template_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "若由模板实例化,记录模板ID" + }, + "node_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "复制的节点数" + }, + "edge_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "复制的边数" + }, + "cost_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "本次 fork 产生的费用金额(double(18,2),付费模板)" + }, + "remark": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "备注" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_fork_list.dspy", + "api/pbl_blueprint_fork_edit.dspy", + "api/pbl_blueprint_fork_view.dspy" + ], + "browserfields": [ + "source_blueprint_id", + "source_version", + "target_blueprint_id", + "fork_type", + "node_count", + "create_time" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_learning_goal.json b/pbl_blueprint/json/pbl_blueprint_learning_goal.json deleted file mode 100644 index c80e4bc..0000000 --- a/pbl_blueprint/json/pbl_blueprint_learning_goal.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "tblname": "pbl_blueprint_learning_goal", - "params": { - "editable": [ - "blueprint_id", - "task_id", - "seq", - "code", - "name", - "dimension", - "description", - "spec", - "weight", - "status" - ], - "browserfields": [ - "blueprint_id", - "task_id", - "seq", - "code", - "name", - "dimension", - "weight", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "task_id", - "dimension", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-学习目标", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_lock.json b/pbl_blueprint/json/pbl_blueprint_lock.json new file mode 100644 index 0000000..00a8423 --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_lock.json @@ -0,0 +1,122 @@ +{ + "tblname": "pbl_blueprint_lock", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "被锁蓝图ID" + }, + "node_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "被锁节点ID(空串=整蓝图锁)" + }, + "lock_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "edit", + "editable": true, + "query": true, + "code": "lock_type", + "summary": "锁类型" + }, + "holder_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "持锁人ID" + }, + "holder_name": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "持锁人名称" + }, + "rev": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "乐观锁修订号(每次抢占/续锁 +1)" + }, + "expire_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "锁过期时间" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "holding", + "editable": true, + "query": true, + "code": "status", + "summary": "锁状态" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "加锁时间" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "续锁时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_lock_list.dspy", + "api/pbl_blueprint_lock_edit.dspy", + "api/pbl_blueprint_lock_view.dspy" + ], + "browserfields": [ + "blueprint_id", + "node_id", + "lock_type", + "holder_name", + "status", + "expire_time" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_mission.json b/pbl_blueprint/json/pbl_blueprint_mission.json deleted file mode 100644 index e480635..0000000 --- a/pbl_blueprint/json/pbl_blueprint_mission.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "tblname": "pbl_blueprint_mission", - "params": { - "editable": [ - "blueprint_id", - "task_id", - "seq", - "code", - "name", - "description", - "spec", - "unlock_condition", - "status" - ], - "browserfields": [ - "blueprint_id", - "task_id", - "seq", - "code", - "name", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "task_id", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-关卡", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_node.json b/pbl_blueprint/json/pbl_blueprint_node.json new file mode 100644 index 0000000..5a20a6e --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_node.json @@ -0,0 +1,176 @@ +{ + "tblname": "pbl_blueprint_node", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "所属蓝图ID" + }, + "node_type": { + "type": "str", + "size": 16, + "notnull": true, + "editable": true, + "query": true, + "code": "node_type", + "summary": "子对象类型(7 类泛化)" + }, + "parent_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "父节点ID(构成蓝图树,空串=根节点)" + }, + "code": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "节点编码(蓝图内同类型唯一)" + }, + "name": { + "type": "str", + "size": 128, + "notnull": true, + "editable": true, + "query": true, + "summary": "节点名称" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "同级排序号" + }, + "spec_json": { + "type": "text", + "default": "", + "editable": true, + "query": false, + "summary": "泛化载荷JSON(结构随 node_type 不同)" + }, + "ref_module": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "引用模块名(scense/world/assessment 等)" + }, + "ref_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "引用对象ID" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "active", + "editable": true, + "query": true, + "code": "status", + "summary": "节点状态" + }, + "required": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "是否必需节点 0否 1是" + }, + "weight": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "权重/分值(double(18,2))" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "更新人" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_node_list.dspy", + "api/pbl_blueprint_node_edit.dspy", + "api/pbl_blueprint_node_view.dspy" + ], + "browserfields": [ + "code", + "name", + "node_type", + "parent_id", + "sort_no", + "status" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_offline.json b/pbl_blueprint/json/pbl_blueprint_offline.json new file mode 100644 index 0000000..12981b8 --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_offline.json @@ -0,0 +1,144 @@ +{ + "tblname": "pbl_blueprint_offline", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "来源蓝图ID(与 template_id 二选一)" + }, + "template_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "来源模板ID" + }, + "pkg_code": { + "type": "str", + "size": 64, + "notnull": true, + "editable": true, + "query": true, + "summary": "离线包编码" + }, + "pkg_name": { + "type": "str", + "size": 128, + "notnull": true, + "editable": true, + "query": true, + "summary": "离线包名称" + }, + "pkg_version": { + "type": "int", + "notnull": true, + "default": 1, + "editable": true, + "query": true, + "summary": "离线包版本" + }, + "pkg_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "离线包内容JSON(自描述:schema_version+蓝图+节点+边)" + }, + "file_size": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "包体字节数" + }, + "checksum": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": false, + "query": false, + "summary": "内容校验和(sha256)" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "ready", + "editable": true, + "query": true, + "code": "status", + "summary": "离线包状态" + }, + "expire_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "过期时间" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_offline_list.dspy", + "api/pbl_blueprint_offline_edit.dspy", + "api/pbl_blueprint_offline_view.dspy" + ], + "browserfields": [ + "pkg_code", + "pkg_name", + "pkg_version", + "status", + "file_size", + "create_time" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_publish.json b/pbl_blueprint/json/pbl_blueprint_publish.json new file mode 100644 index 0000000..5fa6043 --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_publish.json @@ -0,0 +1,166 @@ +{ + "tblname": "pbl_blueprint_publish", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "蓝图ID" + }, + "version_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "发布的版本号" + }, + "class_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "投放班级ID" + }, + "target_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "class", + "editable": true, + "query": true, + "code": "target_type", + "summary": "投放对象类型" + }, + "target_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "投放对象ID" + }, + "runtime_ref": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "运行时引用(scense_runtime 会话/世界ID)" + }, + "game_def_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "编译产物 Game Definition JSON(M3 回写)" + }, + "start_time": { + "type": "datetime", + "editable": true, + "query": false, + "summary": "开始时间" + }, + "end_time": { + "type": "datetime", + "editable": true, + "query": false, + "summary": "结束时间" + }, + "budget_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "本次投放预算金额(double(18,2))" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "published", + "editable": true, + "query": true, + "code": "status", + "summary": "投放状态" + }, + "remark": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "备注" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_publish_list.dspy", + "api/pbl_blueprint_publish_edit.dspy", + "api/pbl_blueprint_publish_view.dspy" + ], + "browserfields": [ + "blueprint_id", + "version_no", + "target_type", + "target_id", + "status", + "start_time", + "end_time" + ] +} diff --git a/pbl_blueprint/json/pbl_blueprint_reflection_spec.json b/pbl_blueprint/json/pbl_blueprint_reflection_spec.json deleted file mode 100644 index d979261..0000000 --- a/pbl_blueprint/json/pbl_blueprint_reflection_spec.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "tblname": "pbl_blueprint_reflection_spec", - "params": { - "editable": [ - "blueprint_id", - "task_id", - "mission_id", - "seq", - "code", - "name", - "trigger_point", - "reflection_type", - "description", - "spec", - "rubric_ref", - "weight", - "status" - ], - "browserfields": [ - "blueprint_id", - "task_id", - "mission_id", - "seq", - "code", - "name", - "trigger_point", - "reflection_type", - "weight", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "task_id", - "mission_id", - "trigger_point", - "reflection_type", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-反思规格", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_role.json b/pbl_blueprint/json/pbl_blueprint_role.json deleted file mode 100644 index b9ad04e..0000000 --- a/pbl_blueprint/json/pbl_blueprint_role.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "tblname": "pbl_blueprint_role", - "params": { - "editable": [ - "blueprint_id", - "task_id", - "seq", - "code", - "name", - "description", - "spec", - "permissions", - "status" - ], - "browserfields": [ - "blueprint_id", - "task_id", - "seq", - "code", - "name", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "task_id", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-角色", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_task.json b/pbl_blueprint/json/pbl_blueprint_task.json deleted file mode 100644 index d08a47f..0000000 --- a/pbl_blueprint/json/pbl_blueprint_task.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "tblname": "pbl_blueprint_task", - "params": { - "editable": [ - "blueprint_id", - "parent_id", - "seq", - "code", - "name", - "description", - "spec", - "status" - ], - "browserfields": [ - "blueprint_id", - "seq", - "code", - "name", - "parent_id", - "status", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "parent_id", - "code", - "name", - "status" - ], - "orderby": "seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "蓝图子对象-任务", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_template.json b/pbl_blueprint/json/pbl_blueprint_template.json index 9c5b29b..47ab410 100644 --- a/pbl_blueprint/json/pbl_blueprint_template.json +++ b/pbl_blueprint/json/pbl_blueprint_template.json @@ -1,49 +1,187 @@ { "tblname": "pbl_blueprint_template", "params": { - "editable": [ - "code", - "name", - "category", - "subject", - "grade", - "duration_hours", - "description", - "content", - "status" - ], - "browserfields": [ - "code", - "name", - "category", - "subject", - "grade", - "duration_hours", - "status", - "use_count", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "code", - "name", - "category", - "status" - ], - "orderby": "use_count desc, updated_at desc", - "defaultfilter": { - "deleted": 0 + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" }, - "caption": "PBL蓝图模板", - "readonly": [ - "id", - "tenant_id", - "use_count", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝);空串=平台公共模板" + }, + "code": { + "type": "str", + "size": 64, + "notnull": true, + "editable": true, + "query": true, + "summary": "模板编码(租户内唯一)" + }, + "name": { + "type": "str", + "size": 128, + "notnull": true, + "editable": true, + "query": true, + "summary": "模板名称" + }, + "category": { + "type": "str", + "size": 32, + "notnull": true, + "default": "general", + "editable": true, + "query": true, + "code": "category", + "summary": "模板分类" + }, + "subject": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "适用学科" + }, + "grade": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "适用年级" + }, + "scope": { + "type": "str", + "size": 16, + "notnull": true, + "default": "tenant", + "editable": true, + "query": true, + "code": "scope", + "summary": "可见范围" + }, + "payload_json": { + "type": "text", + "default": "", + "editable": true, + "query": false, + "summary": "模板树载荷JSON(blueprint+nodes+edges)" + }, + "node_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "模板节点数" + }, + "edge_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "模板边数" + }, + "price_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "模板定价金额(double(18,2),0=免费)" + }, + "use_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "被实例化次数" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "enabled", + "editable": true, + "query": true, + "code": "status", + "summary": "模板状态" + }, + "summary": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "模板说明" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "update_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "更新人" + }, + "update_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_template_list.dspy", + "api/pbl_blueprint_template_edit.dspy", + "api/pbl_blueprint_template_view.dspy" + ], + "browserfields": [ + "code", + "name", + "category", + "subject", + "scope", + "status", + "use_count" + ] } diff --git a/pbl_blueprint/json/pbl_blueprint_template_item.json b/pbl_blueprint/json/pbl_blueprint_template_item.json deleted file mode 100644 index e49aa97..0000000 --- a/pbl_blueprint/json/pbl_blueprint_template_item.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "tblname": "pbl_blueprint_template_item", - "params": { - "editable": [ - "template_id", - "obj_type", - "parent_ref", - "ref_key", - "seq", - "name", - "spec" - ], - "browserfields": [ - "template_id", - "obj_type", - "seq", - "name", - "ref_key", - "parent_ref", - "updated_at" - ], - "searchfields": [ - "tenant_id", - "template_id", - "obj_type", - "name" - ], - "orderby": "obj_type asc, seq asc", - "defaultfilter": { - "deleted": 0 - }, - "caption": "PBL蓝图模板条目", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } -} diff --git a/pbl_blueprint/json/pbl_blueprint_version.json b/pbl_blueprint/json/pbl_blueprint_version.json index 6c2c939..aa97c07 100644 --- a/pbl_blueprint/json/pbl_blueprint_version.json +++ b/pbl_blueprint/json/pbl_blueprint_version.json @@ -1,43 +1,136 @@ { "tblname": "pbl_blueprint_version", "params": { - "editable": [ - "blueprint_id", - "version_no", - "snapshot", - "change_delta", - "quality_level", - "status", - "remark" - ], - "browserfields": [ - "blueprint_id", - "version_no", - "quality_level", - "status", - "remark", - "created_by", - "created_at" - ], - "searchfields": [ - "tenant_id", - "blueprint_id", - "version_no", - "status" - ], - "orderby": "version_no desc", - "defaultfilter": { - "deleted": 0 + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" }, - "caption": "PBL蓝图版本快照", - "readonly": [ - "id", - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at", - "deleted" - ] - } + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "所属蓝图ID" + }, + "version_no": { + "type": "int", + "notnull": true, + "editable": true, + "query": true, + "summary": "版本号(蓝图内递增)" + }, + "version_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "draft", + "editable": true, + "query": true, + "code": "version_type", + "summary": "版本类型" + }, + "change_summary": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "变更说明" + }, + "snapshot_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "蓝图整树快照JSON(主表字段+节点+边)" + }, + "node_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "快照节点数" + }, + "edge_count": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "快照边数" + }, + "quality_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "q0", + "editable": true, + "query": true, + "code": "quality_status", + "summary": "该版本质量状态" + }, + "score_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "该版本评估得分(double(18,2),M6 回写)" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_version_list.dspy", + "api/pbl_blueprint_version_edit.dspy", + "api/pbl_blueprint_version_view.dspy" + ], + "browserfields": [ + "version_no", + "version_type", + "change_summary", + "node_count", + "edge_count", + "quality_status", + "create_time" + ] } diff --git a/pbl_blueprint/json/pbl_blueprint_version_delta.json b/pbl_blueprint/json/pbl_blueprint_version_delta.json new file mode 100644 index 0000000..010bc64 --- /dev/null +++ b/pbl_blueprint/json/pbl_blueprint_version_delta.json @@ -0,0 +1,137 @@ +{ + "tblname": "pbl_blueprint_version_delta", + "params": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": false, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "editable": true, + "query": true, + "summary": "所属蓝图ID" + }, + "from_version": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "基准版本号" + }, + "to_version": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": true, + "summary": "目标版本号" + }, + "delta_type": { + "type": "str", + "size": 16, + "notnull": true, + "editable": true, + "query": true, + "code": "delta_type", + "summary": "差异对象类型" + }, + "op_type": { + "type": "str", + "size": 16, + "notnull": true, + "editable": true, + "query": true, + "code": "op_type", + "summary": "操作类型" + }, + "target_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "editable": true, + "query": true, + "summary": "差异对象ID(节点ID/边ID/蓝图ID)" + }, + "target_name": { + "type": "str", + "size": 128, + "notnull": true, + "default": "", + "editable": true, + "query": false, + "summary": "差异对象名称(冗余便于展示)" + }, + "before_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "变更前JSON" + }, + "after_json": { + "type": "text", + "default": "", + "editable": false, + "query": false, + "summary": "变更后JSON" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "editable": true, + "query": false, + "summary": "排序号" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "editable": false, + "query": false, + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "editable": false, + "query": false, + "summary": "创建时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "editable": false, + "query": false, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "editable": [ + "api/pbl_blueprint_version_delta_list.dspy", + "api/pbl_blueprint_version_delta_edit.dspy", + "api/pbl_blueprint_version_delta_view.dspy" + ], + "browserfields": [ + "from_version", + "to_version", + "delta_type", + "op_type", + "target_name" + ] +} diff --git a/pbl_blueprint/models/pbl_blueprint.json b/pbl_blueprint/models/pbl_blueprint.json index f407ec3..e468b18 100644 --- a/pbl_blueprint/models/pbl_blueprint.json +++ b/pbl_blueprint/models/pbl_blueprint.json @@ -1,55 +1,230 @@ { - "summary": "PBL 蓝图聚合根主表(M1a)。一行=一个项目式学习蓝图,承载学科/年级/课时与质量状态;所有子对象(task/mission/role/learning_goal/evidence_spec/artifact_spec/reflection_spec)通过 blueprint_id 挂靠。tenant_id 为强制打头字段,任何读写必须带租户条件,缺失即拒绝。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头,所有查询第一过滤条件"}, - {"name": "code", "type": "str", "len": 64, "notnull": true, "desc": "蓝图编码,租户内唯一,形如 PBL-{yyyyMMdd}-{seq4}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "蓝图名称"}, - {"name": "subject", "type": "str", "len": 64, "desc": "学科"}, - {"name": "grade", "type": "str", "len": 32, "desc": "适用年级"}, - {"name": "duration_hours", "type": "int", "desc": "总课时数"}, - {"name": "version_no", "type": "int", "notnull": true, "default": "1", "desc": "当前版本号,每次发布快照自增"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "蓝图状态,见 codes.status"}, - {"name": "quality_level", "type": "str", "len": 16, "default": "L1", "desc": "质量等级 L1-L5,由 pbl_validation 回写"}, - {"name": "summary", "type": "text", "desc": "蓝图简介/驱动性问题"}, - {"name": "config", "type": "json", "desc": "扩展配置(分组策略/评分权重/运行时参数)"}, - {"name": "source_blueprint_id", "type": "str", "len": 32, "desc": "fork 来源蓝图ID,非fork为空"}, - {"name": "template_id", "type": "str", "len": 32, "desc": "实例化来源模板ID,非模板创建为空"}, - {"name": "owner_id", "type": "str", "len": 32, "desc": "归属教师/创建者用户ID"}, - {"name": "published_at", "type": "datetime", "desc": "最近发布时间"}, - {"name": "remark", "type": "str", "len": 500, "desc": "备注"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_blueprint_tenant_code", "fields": ["tenant_id", "code"], "unique": true}, - {"name": "idx_pbl_blueprint_tenant_status", "fields": ["tenant_id", "status", "deleted"], "unique": false}, - {"name": "idx_pbl_blueprint_owner", "fields": ["tenant_id", "owner_id"], "unique": false}, - {"name": "idx_pbl_blueprint_template", "fields": ["tenant_id", "template_id"], "unique": false} + "summary": "PBL 蓝图聚合根表:一个租户下的一份项目式学习蓝图(学科/年级/学段/状态/质量状态/当前版本/预算)。所有读写 tenant_id 强制打头。", + "primary": [ + "id" ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "code": { + "type": "str", + "size": 64, + "notnull": true, + "summary": "蓝图编码(租户内唯一,PBL-BP-序号)" + }, + "name": { + "type": "str", + "size": 128, + "notnull": true, + "summary": "蓝图名称" + }, + "subject": { + "type": "str", + "size": 64, + "default": "", + "summary": "学科" + }, + "grade": { + "type": "str", + "size": 32, + "default": "", + "summary": "年级" + }, + "phase": { + "type": "str", + "size": 32, + "default": "", + "summary": "学段" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "draft", + "summary": "蓝图状态" + }, + "quality_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "q0", + "summary": "质量状态(5 级)" + }, + "source": { + "type": "str", + "size": 16, + "notnull": true, + "default": "manual", + "summary": "来源方式" + }, + "source_id": { + "type": "str", + "size": 32, + "default": "", + "summary": "来源对象ID(模板ID或父蓝图ID)" + }, + "owner_id": { + "type": "str", + "size": 32, + "default": "", + "summary": "负责人(教师)ID" + }, + "class_id": { + "type": "str", + "size": 32, + "default": "", + "summary": "关联班级ID" + }, + "current_version": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "当前版本号(save_version 回写)" + }, + "duration_hours": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "预计课时数" + }, + "budget_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "预算金额(double(18,2))" + }, + "summary": { + "type": "str", + "size": 512, + "default": "", + "summary": "蓝图简介" + }, + "ext_json": { + "type": "text", + "default": "", + "summary": "扩展属性JSON" + }, + "publish_time": { + "type": "datetime", + "summary": "最近发布时间" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "update_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "更新人" + }, + "update_time": { + "type": "datetime", + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_blueprint_code": { + "fields": [ + "tenant_id", + "code" + ], + "unique": true, + "summary": "租户内蓝图编码唯一" + }, + "idx_pbl_blueprint_tenant": { + "fields": [ + "tenant_id", + "deleted", + "status" + ], + "unique": false, + "summary": "租户蓝图列表主索引(tenant_id 打头)" + }, + "idx_pbl_blueprint_owner": { + "fields": [ + "tenant_id", + "owner_id" + ], + "unique": false, + "summary": "按负责人查蓝图" + }, + "idx_pbl_blueprint_source": { + "fields": [ + "tenant_id", + "source", + "source_id" + ], + "unique": false, + "summary": "按来源(模板/fork)追溯" + }, + "idx_pbl_blueprint_class": { + "fields": [ + "tenant_id", + "class_id" + ], + "unique": false, + "summary": "按班级查蓝图" + } + }, "codes": { - "status": [ - ["draft", "草稿"], - ["validating", "校验中"], - ["validated", "校验通过"], - ["compiling", "编译中"], - ["compiled", "已编译"], - ["published", "已发布"], - ["archived", "已归档"] - ], - "quality_level": [ - ["L1", "L1-初始"], - ["L2", "L2-基本完整"], - ["L3", "L3-结构合格"], - ["L4", "L4-可运行"], - ["L5", "L5-优质"] - ], - "deleted": [ - ["0", "正常"], - ["1", "已删除"] - ] + "status": { + "summary": "蓝图状态", + "items": { + "draft": "草稿", + "validating": "校验中", + "ready": "就绪", + "published": "已发布", + "archived": "已归档", + "disabled": "已停用" + } + }, + "quality_status": { + "summary": "质量状态(5 级,M2 校验引擎回写)", + "items": { + "q0": "未校验", + "q1": "不合格", + "q2": "基本合格", + "q3": "合格", + "q4": "优秀", + "q5": "标杆" + } + }, + "source": { + "summary": "来源方式", + "items": { + "manual": "手工创建", + "template": "模板实例化", + "fork": "蓝图fork派生", + "agent": "Designer Agent 生成", + "import": "离线包导入" + } + } } } diff --git a/pbl_blueprint/models/pbl_blueprint_artifact_spec.json b/pbl_blueprint/models/pbl_blueprint_artifact_spec.json deleted file mode 100644 index c63d35a..0000000 --- a/pbl_blueprint/models/pbl_blueprint_artifact_spec.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-产出物规格(M1a,7类子对象之一)。一行=一种期望产出物的规格定义(作品/报告/模型/演示),供 pbl_evidence 归档产出物与 pbl_compiler 生成任务交付要求。统一泛化契约。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID"}, - {"name": "mission_id", "type": "str", "len": 32, "desc": "关联关卡ID"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "产出物规格编码,蓝图内唯一,缺省自动生成 ART-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "产出物名称"}, - {"name": "artifact_type", "type": "str", "len": 32, "notnull": true, "default": "document", "desc": "产出物类型,见 codes.artifact_type"}, - {"name": "description", "type": "text", "desc": "产出物说明与要求"}, - {"name": "spec", "type": "json", "desc": "产出物规格(格式/尺寸/时长/字数/文件类型白名单)"}, - {"name": "accept_criteria", "type": "json", "desc": "验收标准条目数组"}, - {"name": "required", "type": "int", "notnull": true, "default": "1", "desc": "是否必交 0否 1是"}, - {"name": "weight", "type": "float", "default": "1.0", "desc": "评分权重"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bpart_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bpart_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "idx_pbl_bpart_task", "fields": ["tenant_id", "task_id"], "unique": false} - ], - "codes": { - "artifact_type": [["document", "文档报告"], ["model", "三维模型"], ["scene", "场景作品"], ["video", "视频"], ["audio", "音频"], ["image", "图片"], ["code", "程序脚本"], ["presentation", "演示汇报"], ["dataset", "数据集"]], - "required": [["0", "选交"], ["1", "必交"]], - "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_audit.json b/pbl_blueprint/models/pbl_blueprint_audit.json new file mode 100644 index 0000000..99c4caf --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_audit.json @@ -0,0 +1,190 @@ +{ + "summary": "蓝图操作审计表(append-only):记录蓝图/节点/边/版本/模板/发布/锁的关键写操作与结果,只增不改不删,供追溯与合规审计。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "蓝图ID" + }, + "target_type": { + "type": "str", + "size": 16, + "notnull": true, + "summary": "操作对象类型" + }, + "target_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "操作对象ID" + }, + "action": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "操作动作" + }, + "version_no": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "关联版本号" + }, + "before_json": { + "type": "text", + "default": "", + "summary": "操作前数据JSON" + }, + "after_json": { + "type": "text", + "default": "", + "summary": "操作后数据JSON" + }, + "result": { + "type": "str", + "size": 16, + "notnull": true, + "default": "success", + "summary": "操作结果" + }, + "err_code": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "失败错误码" + }, + "cost_ms": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "耗时毫秒" + }, + "op_user": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "操作人" + }, + "op_source": { + "type": "str", + "size": 32, + "notnull": true, + "default": "api", + "summary": "操作来源(web/api/agent/offline)" + }, + "client_ip": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "summary": "客户端IP" + }, + "create_time": { + "type": "datetime", + "summary": "操作时间" + } + }, + "indexes": { + "idx_pbl_audit_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "create_time" + ], + "unique": false, + "summary": "按蓝图时间线查审计" + }, + "idx_pbl_audit_target": { + "fields": [ + "tenant_id", + "target_type", + "target_id" + ], + "unique": false, + "summary": "按对象查审计" + }, + "idx_pbl_audit_user": { + "fields": [ + "tenant_id", + "op_user", + "create_time" + ], + "unique": false, + "summary": "按操作人查审计" + }, + "idx_pbl_audit_action": { + "fields": [ + "tenant_id", + "action", + "result" + ], + "unique": false, + "summary": "按动作与结果统计" + } + }, + "codes": { + "target_type": { + "summary": "操作对象类型", + "items": { + "blueprint": "蓝图", + "node": "节点", + "edge": "边", + "version": "版本", + "delta": "版本差异", + "template": "模板", + "publish": "发布", + "offline": "离线包", + "fork": "血缘", + "lock": "编辑锁" + } + }, + "action": { + "summary": "操作动作", + "items": { + "create": "创建", + "update": "更新", + "delete": "删除", + "fork": "fork派生", + "save_version": "存版本", + "rollback": "回滚", + "publish": "发布", + "revoke": "撤回发布", + "instantiate": "模板实例化", + "validate": "校验", + "compile": "编译", + "lock": "加锁", + "unlock": "解锁", + "export_offline": "导出离线包", + "import_offline": "导入离线包" + } + }, + "result": { + "summary": "操作结果", + "items": { + "success": "成功", + "fail": "失败", + "denied": "被拒绝" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_edge.json b/pbl_blueprint/models/pbl_blueprint_edge.json new file mode 100644 index 0000000..3903726 --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_edge.json @@ -0,0 +1,137 @@ +{ + "summary": "蓝图节点关系边表:描述子对象之间的依赖/解锁/产出/消耗/评估/包含关系,供 Compiler(M3)编译为 Game Definition 图结构。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "所属蓝图ID" + }, + "from_node_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "起点节点ID" + }, + "to_node_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "终点节点ID" + }, + "edge_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "depends", + "summary": "边类型" + }, + "weight": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "边权重(double(18,2))" + }, + "condition_json": { + "type": "text", + "default": "", + "summary": "触发条件JSON" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "排序号" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_edge": { + "fields": [ + "tenant_id", + "blueprint_id", + "from_node_id", + "to_node_id", + "edge_type" + ], + "unique": true, + "summary": "同类型边唯一,防重复连边" + }, + "idx_pbl_edge_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "deleted" + ], + "unique": false, + "summary": "按蓝图取全部边" + }, + "idx_pbl_edge_from": { + "fields": [ + "tenant_id", + "from_node_id" + ], + "unique": false, + "summary": "出边查询" + }, + "idx_pbl_edge_to": { + "fields": [ + "tenant_id", + "to_node_id" + ], + "unique": false, + "summary": "入边查询(删节点前校验)" + } + }, + "codes": { + "edge_type": { + "summary": "边类型", + "items": { + "depends": "依赖", + "unlocks": "解锁", + "produces": "产出", + "consumes": "消耗", + "assesses": "评估", + "contains": "包含" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_evidence_spec.json b/pbl_blueprint/models/pbl_blueprint_evidence_spec.json deleted file mode 100644 index f693221..0000000 --- a/pbl_blueprint/models/pbl_blueprint_evidence_spec.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-证据规格(M1a,7类子对象之一)。一行=一条学习证据的采集规格,定义证据类型/采集方式/校验规则,供 pbl_evidence 幂等采集与 pbl_assessment 评分引用。统一泛化契约。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID"}, - {"name": "mission_id", "type": "str", "len": 32, "desc": "关联关卡ID"}, - {"name": "learning_goal_id", "type": "str", "len": 32, "desc": "关联学习目标ID"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "证据规格编码,蓝图内唯一,缺省自动生成 EVI-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "证据规格名称"}, - {"name": "evidence_type", "type": "str", "len": 32, "notnull": true, "default": "auto", "desc": "证据类型,见 codes.evidence_type"}, - {"name": "collect_mode", "type": "str", "len": 32, "notnull": true, "default": "auto", "desc": "采集方式,见 codes.collect_mode"}, - {"name": "description", "type": "text", "desc": "证据说明"}, - {"name": "spec", "type": "json", "desc": "证据规格(字段schema/取值范围/单位/幂等键规则)"}, - {"name": "rule", "type": "json", "desc": "校验规则(阈值/表达式/必填项)"}, - {"name": "weight", "type": "float", "default": "1.0", "desc": "评分权重"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bpev_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bpev_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "idx_pbl_bpev_goal", "fields": ["tenant_id", "learning_goal_id"], "unique": false}, - {"name": "idx_pbl_bpev_mission", "fields": ["tenant_id", "mission_id"], "unique": false} - ], - "codes": { - "evidence_type": [["auto", "系统自动采集"], ["manual", "人工提交"], ["artifact", "产出物派生"], ["interaction", "交互行为"], ["assessment", "测评结果"]], - "collect_mode": [["auto", "自动"], ["trigger", "事件触发"], ["manual", "手动上传"], ["scheduled", "定时采集"]], - "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_fork.json b/pbl_blueprint/models/pbl_blueprint_fork.json new file mode 100644 index 0000000..b3a11b9 --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_fork.json @@ -0,0 +1,145 @@ +{ + "summary": "蓝图 fork 血缘表:记录蓝图之间的派生关系(源蓝图→新蓝图)与模板实例化来源,支持 fork 溯源与派生树查询。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "source_blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "源蓝图ID(模板实例化时为空串)" + }, + "source_version": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "fork 时的源版本号" + }, + "target_blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "派生出的新蓝图ID" + }, + "fork_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "copy", + "summary": "fork 方式" + }, + "template_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "若由模板实例化,记录模板ID" + }, + "node_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "复制的节点数" + }, + "edge_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "复制的边数" + }, + "cost_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "本次 fork 产生的费用金额(double(18,2),付费模板)" + }, + "remark": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "summary": "备注" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_fork": { + "fields": [ + "tenant_id", + "source_blueprint_id", + "target_blueprint_id" + ], + "unique": true, + "summary": "同一对蓝图血缘唯一,保证幂等" + }, + "idx_pbl_fork_source": { + "fields": [ + "tenant_id", + "source_blueprint_id", + "deleted" + ], + "unique": false, + "summary": "查某蓝图的派生列表" + }, + "idx_pbl_fork_target": { + "fields": [ + "tenant_id", + "target_blueprint_id" + ], + "unique": false, + "summary": "反查某蓝图的来源" + }, + "idx_pbl_fork_template": { + "fields": [ + "tenant_id", + "template_id" + ], + "unique": false, + "summary": "查模板被实例化记录" + } + }, + "codes": { + "fork_type": { + "summary": "fork 方式", + "items": { + "copy": "完整复制", + "structure": "仅结构(不带载荷)", + "template": "模板实例化", + "branch": "分支派生" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_learning_goal.json b/pbl_blueprint/models/pbl_blueprint_learning_goal.json deleted file mode 100644 index a56fc2e..0000000 --- a/pbl_blueprint/models/pbl_blueprint_learning_goal.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-学习目标(M1a,7类子对象之一)。一行=蓝图的一条学习目标(知识/能力/素养/态度四维),可关联到任务并被评估量规引用。统一泛化契约。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID,为空表示蓝图级目标"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "目标编码,蓝图内唯一,缺省自动生成 GOAL-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "目标名称"}, - {"name": "dimension", "type": "str", "len": 32, "notnull": true, "default": "knowledge", "desc": "目标维度,见 codes.dimension"}, - {"name": "description", "type": "text", "desc": "目标描述(可观测行为动词表述)"}, - {"name": "spec", "type": "json", "desc": "目标规格(课标对应/掌握层级/评价方式)"}, - {"name": "weight", "type": "float", "default": "1.0", "desc": "评估权重,供 pbl_assessment Rubric 使用"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bplg_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bplg_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "idx_pbl_bplg_dim", "fields": ["tenant_id", "blueprint_id", "dimension"], "unique": false} - ], - "codes": { - "dimension": [["knowledge", "知识"], ["ability", "能力"], ["literacy", "素养"], ["attitude", "态度"]], - "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_lock.json b/pbl_blueprint/models/pbl_blueprint_lock.json new file mode 100644 index 0000000..12863ca --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_lock.json @@ -0,0 +1,132 @@ +{ + "summary": "蓝图编辑锁表:多人协作时对蓝图/节点加编辑锁(悲观锁+rev 乐观修订号),锁带过期时间,过期可被抢占。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "被锁蓝图ID" + }, + "node_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "被锁节点ID(空串=整蓝图锁)" + }, + "lock_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "edit", + "summary": "锁类型" + }, + "holder_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "持锁人ID" + }, + "holder_name": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "summary": "持锁人名称" + }, + "rev": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "乐观锁修订号(每次抢占/续锁 +1)" + }, + "expire_time": { + "type": "datetime", + "summary": "锁过期时间" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "holding", + "summary": "锁状态" + }, + "create_time": { + "type": "datetime", + "summary": "加锁时间" + }, + "update_time": { + "type": "datetime", + "summary": "续锁时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_lock": { + "fields": [ + "tenant_id", + "blueprint_id", + "node_id", + "lock_type" + ], + "unique": true, + "summary": "同对象同类型仅一把锁记录" + }, + "idx_pbl_lock_holder": { + "fields": [ + "tenant_id", + "holder_id", + "status" + ], + "unique": false, + "summary": "查某人持有的锁" + }, + "idx_pbl_lock_expire": { + "fields": [ + "tenant_id", + "status", + "expire_time" + ], + "unique": false, + "summary": "过期锁清理扫描" + } + }, + "codes": { + "lock_type": { + "summary": "锁类型", + "items": { + "edit": "编辑锁", + "publish": "发布锁", + "compile": "编译锁" + } + }, + "status": { + "summary": "锁状态", + "items": { + "holding": "持有中", + "released": "已释放", + "expired": "已过期" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_mission.json b/pbl_blueprint/models/pbl_blueprint_mission.json deleted file mode 100644 index 62f5544..0000000 --- a/pbl_blueprint/models/pbl_blueprint_mission.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-关卡(M1a,7类子对象之一)。一行=任务下的一个游戏化关卡,是 Compiler 生成 Game Definition 的直接来源。统一泛化契约(blueprint_id + task_id + seq + code + name + spec + status)。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "task_id", "type": "str", "len": 32, "notnull": true, "desc": "所属任务ID"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "任务内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "关卡编码,蓝图内唯一,缺省自动生成 MISSION-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "关卡名称"}, - {"name": "description", "type": "text", "desc": "关卡说明"}, - {"name": "spec", "type": "json", "desc": "关卡规格(目标/规则/场景引用/通关条件/奖励)"}, - {"name": "unlock_condition", "type": "json", "desc": "解锁条件(前置关卡/分数门槛)"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bpms_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bpms_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "idx_pbl_bpms_task", "fields": ["tenant_id", "task_id", "seq"], "unique": false} - ], - "codes": { - "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_node.json b/pbl_blueprint/models/pbl_blueprint_node.json new file mode 100644 index 0000000..b008420 --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_node.json @@ -0,0 +1,190 @@ +{ + "summary": "蓝图子对象节点表(7 类子对象泛化契约):role/task/rule/artifact/scene/assessment/resource 统一存一张表,差异载荷放 spec_json,按 node_type 分派校验器(M2)。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "所属蓝图ID" + }, + "node_type": { + "type": "str", + "size": 16, + "notnull": true, + "summary": "子对象类型(7 类泛化)" + }, + "parent_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "父节点ID(构成蓝图树,空串=根节点)" + }, + "code": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "summary": "节点编码(蓝图内同类型唯一)" + }, + "name": { + "type": "str", + "size": 128, + "notnull": true, + "summary": "节点名称" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "同级排序号" + }, + "spec_json": { + "type": "text", + "default": "", + "summary": "泛化载荷JSON(结构随 node_type 不同)" + }, + "ref_module": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "引用模块名(scense/world/assessment 等)" + }, + "ref_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "引用对象ID" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "active", + "summary": "节点状态" + }, + "required": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "是否必需节点 0否 1是" + }, + "weight": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "权重/分值(double(18,2))" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "update_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "更新人" + }, + "update_time": { + "type": "datetime", + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_node_code": { + "fields": [ + "tenant_id", + "blueprint_id", + "node_type", + "code" + ], + "unique": true, + "summary": "蓝图内同类型节点编码唯一" + }, + "idx_pbl_node_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "deleted", + "node_type" + ], + "unique": false, + "summary": "按蓝图+类型列节点(tenant_id 打头)" + }, + "idx_pbl_node_parent": { + "fields": [ + "tenant_id", + "blueprint_id", + "parent_id", + "sort_no" + ], + "unique": false, + "summary": "构树查询" + }, + "idx_pbl_node_ref": { + "fields": [ + "tenant_id", + "ref_module", + "ref_id" + ], + "unique": false, + "summary": "按引用对象反查节点" + } + }, + "codes": { + "node_type": { + "summary": "子对象类型(7 类泛化契约)", + "items": { + "role": "角色", + "task": "任务", + "rule": "规则", + "artifact": "产出物", + "scene": "场景引用", + "assessment": "评估项", + "resource": "资源" + } + }, + "status": { + "summary": "节点状态", + "items": { + "active": "生效", + "draft": "草稿", + "invalid": "校验不通过", + "disabled": "停用" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_offline.json b/pbl_blueprint/models/pbl_blueprint_offline.json new file mode 100644 index 0000000..d5884f4 --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_offline.json @@ -0,0 +1,141 @@ +{ + "summary": "蓝图离线兜底包表:把蓝图/模板打包为自描述 JSON 包(含 schema_version 与 sha256 校验和),网络或服务不可用时本地兜底实例化。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "来源蓝图ID(与 template_id 二选一)" + }, + "template_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "来源模板ID" + }, + "pkg_code": { + "type": "str", + "size": 64, + "notnull": true, + "summary": "离线包编码" + }, + "pkg_name": { + "type": "str", + "size": 128, + "notnull": true, + "summary": "离线包名称" + }, + "pkg_version": { + "type": "int", + "notnull": true, + "default": 1, + "summary": "离线包版本" + }, + "pkg_json": { + "type": "text", + "default": "", + "summary": "离线包内容JSON(自描述:schema_version+蓝图+节点+边)" + }, + "file_size": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "包体字节数" + }, + "checksum": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "summary": "内容校验和(sha256)" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "ready", + "summary": "离线包状态" + }, + "expire_time": { + "type": "datetime", + "summary": "过期时间" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_offline_code": { + "fields": [ + "tenant_id", + "pkg_code", + "pkg_version" + ], + "unique": true, + "summary": "离线包编码+版本唯一" + }, + "idx_pbl_offline_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "deleted" + ], + "unique": false, + "summary": "按蓝图查离线包" + }, + "idx_pbl_offline_status": { + "fields": [ + "tenant_id", + "status", + "expire_time" + ], + "unique": false, + "summary": "过期包清理扫描" + } + }, + "codes": { + "status": { + "summary": "离线包状态", + "items": { + "ready": "可用", + "building": "构建中", + "expired": "已过期", + "invalid": "校验失败" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_publish.json b/pbl_blueprint/models/pbl_blueprint_publish.json new file mode 100644 index 0000000..e11ac54 --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_publish.json @@ -0,0 +1,168 @@ +{ + "summary": "蓝图发布/投放记录表:蓝图某版本投放到班级/团队/学生的一次发布,记录运行时句柄与编译产物,供 M11 runtime_ext 关联。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "蓝图ID" + }, + "version_no": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "发布的版本号" + }, + "class_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "投放班级ID" + }, + "target_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "class", + "summary": "投放对象类型" + }, + "target_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "投放对象ID" + }, + "runtime_ref": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "summary": "运行时引用(scense_runtime 会话/世界ID)" + }, + "game_def_json": { + "type": "text", + "default": "", + "summary": "编译产物 Game Definition JSON(M3 回写)" + }, + "start_time": { + "type": "datetime", + "summary": "开始时间" + }, + "end_time": { + "type": "datetime", + "summary": "结束时间" + }, + "budget_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "本次投放预算金额(double(18,2))" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "published", + "summary": "投放状态" + }, + "remark": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "summary": "备注" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "update_time": { + "type": "datetime", + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_publish": { + "fields": [ + "tenant_id", + "blueprint_id", + "version_no", + "target_type", + "target_id" + ], + "unique": true, + "summary": "同版本同对象只投放一次,保证幂等" + }, + "idx_pbl_publish_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "deleted" + ], + "unique": false, + "summary": "按蓝图查投放记录" + }, + "idx_pbl_publish_class": { + "fields": [ + "tenant_id", + "class_id", + "status" + ], + "unique": false, + "summary": "按班级查在跑的投放" + } + }, + "codes": { + "target_type": { + "summary": "投放对象类型", + "items": { + "class": "班级", + "team": "团队", + "student": "学生", + "preview": "预览" + } + }, + "status": { + "summary": "投放状态", + "items": { + "published": "已发布", + "running": "进行中", + "finished": "已结束", + "revoked": "已撤回" + } + } + } +} diff --git a/pbl_blueprint/models/pbl_blueprint_reflection_spec.json b/pbl_blueprint/models/pbl_blueprint_reflection_spec.json deleted file mode 100644 index bcd7de0..0000000 --- a/pbl_blueprint/models/pbl_blueprint_reflection_spec.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-反思规格(M1a,7类子对象之一)。一行=一条反思/自评提示的规格定义(触发时机、反思类型、引导问题、量规维度),供运行时按节点触发学习者反思并回流评估。统一泛化契约。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID"}, - {"name": "mission_id", "type": "str", "len": 32, "desc": "关联关卡ID"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "反思规格编码,蓝图内唯一,缺省自动生成 REF-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "反思规格名称"}, - {"name": "trigger_point", "type": "str", "len": 32, "notnull": true, "default": "mission_end", "desc": "触发时机,见 codes.trigger_point"}, - {"name": "reflection_type", "type": "str", "len": 32, "notnull": true, "default": "self", "desc": "反思类型,见 codes.reflection_type"}, - {"name": "description", "type": "text", "desc": "反思说明"}, - {"name": "spec", "type": "json", "desc": "反思规格(引导问题数组/作答形式/字数下限)"}, - {"name": "rubric_ref", "type": "json", "desc": "关联量规维度引用(供 pbl_assessment 打分)"}, - {"name": "weight", "type": "float", "default": "1.0", "desc": "评分权重"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bpref_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bpref_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "idx_pbl_bpref_trigger", "fields": ["tenant_id", "blueprint_id", "trigger_point"], "unique": false} - ], - "codes": { - "trigger_point": [["task_start", "任务开始"], ["task_end", "任务结束"], ["mission_end", "关卡结束"], ["milestone", "里程碑"], ["project_end", "项目结题"], ["manual", "手动触发"]], - "reflection_type": [["self", "自我反思"], ["peer", "同伴互评"], ["team", "团队复盘"], ["teacher", "教师点评"]], - "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_role.json b/pbl_blueprint/models/pbl_blueprint_role.json deleted file mode 100644 index eb5535d..0000000 --- a/pbl_blueprint/models/pbl_blueprint_role.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-角色(M1a,7类子对象之一)。一行=蓝图内定义的一种学习者角色(如队长/记录员/工程师),供分组与权限分配使用。统一泛化契约。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID,为空表示蓝图级角色"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "角色编码,蓝图内唯一,缺省自动生成 ROLE-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "角色名称"}, - {"name": "description", "type": "text", "desc": "角色职责说明"}, - {"name": "spec", "type": "json", "desc": "角色规格(人数上限/能力标签/可用工具/评分权重)"}, - {"name": "permissions", "type": "json", "desc": "角色在运行时的操作权限集合"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bprole_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bprole_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "idx_pbl_bprole_task", "fields": ["tenant_id", "task_id"], "unique": false} - ], - "codes": { - "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_task.json b/pbl_blueprint/models/pbl_blueprint_task.json deleted file mode 100644 index b2c5732..0000000 --- a/pbl_blueprint/models/pbl_blueprint_task.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "summary": "PBL 蓝图子对象-任务(M1a,7类子对象之一)。一行=蓝图下的一个学习任务,是关卡/角色/目标的挂载父级。采用统一泛化契约(blueprint_id + seq + name + spec + status),便于 Compiler 与 Validation 统一遍历。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "parent_id", "type": "str", "len": 32, "desc": "父任务ID,支持任务分层,顶层为空"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, - {"name": "code", "type": "str", "len": 64, "desc": "任务编码,蓝图内唯一,缺省自动生成 TASK-{seq}"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "任务名称"}, - {"name": "description", "type": "text", "desc": "任务描述/驱动性问题"}, - {"name": "spec", "type": "json", "desc": "任务规格(时长/难度/分组方式/前置条件/交付要求)"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "idx_pbl_bptask_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, - {"name": "uk_pbl_bptask_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, - {"name": "idx_pbl_bptask_parent", "fields": ["tenant_id", "parent_id"], "unique": false} - ], - "codes": { - "status": [ - ["draft", "草稿"], - ["active", "生效"], - ["disabled", "停用"] - ], - "deleted": [ - ["0", "正常"], - ["1", "已删除"] - ] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_template.json b/pbl_blueprint/models/pbl_blueprint_template.json index 03a04a6..44d14f1 100644 --- a/pbl_blueprint/models/pbl_blueprint_template.json +++ b/pbl_blueprint/models/pbl_blueprint_template.json @@ -1,31 +1,191 @@ { - "summary": "PBL 蓝图模板表(M1a)。一行=一个可复用蓝图模板,用于一键实例化生成新蓝图;离线兜底时也可由模板生成最小可用蓝图。tenant_id 强制打头,平台内置模板 tenant_id 固定为 platform。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头;平台内置模板为 platform"}, - {"name": "code", "type": "str", "len": 64, "notnull": true, "desc": "模板编码,租户内唯一"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "模板名称"}, - {"name": "category", "type": "str", "len": 64, "desc": "模板分类,见 codes.category"}, - {"name": "subject", "type": "str", "len": 64, "desc": "适用学科"}, - {"name": "grade", "type": "str", "len": 32, "desc": "适用年级"}, - {"name": "duration_hours", "type": "int", "desc": "建议课时"}, - {"name": "description", "type": "text", "desc": "模板说明"}, - {"name": "content", "type": "json", "desc": "模板主体内容(主表默认值+子对象骨架)"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "enabled", "desc": "模板状态,见 codes.status"}, - {"name": "use_count", "type": "int", "notnull": true, "default": "0", "desc": "被实例化次数"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bpt_tenant_code", "fields": ["tenant_id", "code"], "unique": true}, - {"name": "idx_pbl_bpt_tenant_cat", "fields": ["tenant_id", "category", "status", "deleted"], "unique": false} + "summary": "蓝图模板表:可复用的蓝图骨架(payload_json 存模板树),支持实例化为新蓝图;离线兜底时作为本地模板源。", + "primary": [ + "id" ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝);空串=平台公共模板" + }, + "code": { + "type": "str", + "size": 64, + "notnull": true, + "summary": "模板编码(租户内唯一)" + }, + "name": { + "type": "str", + "size": 128, + "notnull": true, + "summary": "模板名称" + }, + "category": { + "type": "str", + "size": 32, + "notnull": true, + "default": "general", + "summary": "模板分类" + }, + "subject": { + "type": "str", + "size": 64, + "notnull": true, + "default": "", + "summary": "适用学科" + }, + "grade": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "适用年级" + }, + "scope": { + "type": "str", + "size": 16, + "notnull": true, + "default": "tenant", + "summary": "可见范围" + }, + "payload_json": { + "type": "text", + "default": "", + "summary": "模板树载荷JSON(blueprint+nodes+edges)" + }, + "node_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "模板节点数" + }, + "edge_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "模板边数" + }, + "price_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "模板定价金额(double(18,2),0=免费)" + }, + "use_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "被实例化次数" + }, + "status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "enabled", + "summary": "模板状态" + }, + "summary": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "summary": "模板说明" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "update_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "更新人" + }, + "update_time": { + "type": "datetime", + "summary": "更新时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_template_code": { + "fields": [ + "tenant_id", + "code" + ], + "unique": true, + "summary": "租户内模板编码唯一" + }, + "idx_pbl_template_list": { + "fields": [ + "tenant_id", + "deleted", + "status", + "category" + ], + "unique": false, + "summary": "模板列表查询(tenant_id 打头)" + }, + "idx_pbl_template_subject": { + "fields": [ + "tenant_id", + "subject", + "grade" + ], + "unique": false, + "summary": "按学科年级筛模板" + } + }, "codes": { - "category": [["stem", "STEM跨学科"], ["humanity", "人文社科"], ["science", "自然科学"], ["engineering", "工程制作"], ["social", "社会服务"], ["general", "通用"]], - "status": [["enabled", "启用"], ["disabled", "停用"]], - "deleted": [["0", "正常"], ["1", "已删除"]] + "scope": { + "summary": "可见范围", + "items": { + "tenant": "本租户", + "platform": "平台公共", + "private": "仅创建人" + } + }, + "status": { + "summary": "模板状态", + "items": { + "enabled": "启用", + "disabled": "停用", + "draft": "草稿" + } + }, + "category": { + "summary": "模板分类", + "items": { + "stem": "STEM", + "science": "自然科学", + "humanity": "人文社科", + "art": "艺术", + "labor": "劳动实践", + "general": "通用" + } + } } } diff --git a/pbl_blueprint/models/pbl_blueprint_template_item.json b/pbl_blueprint/models/pbl_blueprint_template_item.json deleted file mode 100644 index 64c53f2..0000000 --- a/pbl_blueprint/models/pbl_blueprint_template_item.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "summary": "PBL 蓝图模板条目表(M1a)。一行=模板下的一个子对象骨架条目,obj_type 取 7 类子对象类型之一;实例化模板时按 seq 顺序批量生成对应子对象记录,ref_key/parent_ref 做模板内相对引用。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "template_id", "type": "str", "len": 32, "notnull": true, "desc": "所属模板ID"}, - {"name": "obj_type", "type": "str", "len": 32, "notnull": true, "desc": "子对象类型,见 codes.obj_type"}, - {"name": "parent_ref", "type": "str", "len": 64, "desc": "父条目引用键(模板内相对引用,实例化时解析为真实ID)"}, - {"name": "ref_key", "type": "str", "len": 64, "desc": "本条目引用键,供子条目 parent_ref 指向"}, - {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "同类型内排序号"}, - {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "条目名称"}, - {"name": "spec", "type": "json", "desc": "条目规格(对应子对象 spec 字段默认值)"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "idx_pbl_bpti_tenant_tpl", "fields": ["tenant_id", "template_id", "obj_type", "seq"], "unique": false}, - {"name": "idx_pbl_bpti_refkey", "fields": ["tenant_id", "template_id", "ref_key"], "unique": false} - ], - "codes": { - "obj_type": [["task", "任务"], ["mission", "关卡"], ["role", "角色"], ["learning_goal", "学习目标"], ["evidence_spec", "证据规格"], ["artifact_spec", "产出物规格"], ["reflection_spec", "反思规格"]], - "deleted": [["0", "正常"], ["1", "已删除"]] - } -} diff --git a/pbl_blueprint/models/pbl_blueprint_version.json b/pbl_blueprint/models/pbl_blueprint_version.json index de4ca47..a993f68 100644 --- a/pbl_blueprint/models/pbl_blueprint_version.json +++ b/pbl_blueprint/models/pbl_blueprint_version.json @@ -1,28 +1,139 @@ { - "summary": "PBL 蓝图版本快照表(M1a)。一行=蓝图某版本的完整快照 + 相对上一版本的变更增量 change_delta,支撑版本回溯/diff/回滚。snapshot 存全量子对象树,change_delta 存 added/updated/removed 三段。tenant_id 强制打头。", - "fields": [ - {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, - {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, - {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, - {"name": "version_no", "type": "int", "notnull": true, "desc": "版本号,蓝图内递增"}, - {"name": "snapshot", "type": "json", "notnull": true, "desc": "该版本蓝图全量快照(主表字段+7类子对象树)"}, - {"name": "change_delta", "type": "json", "desc": "相对上一版本变更增量 {added:[],updated:[],removed:[]}"}, - {"name": "quality_level", "type": "str", "len": 16, "desc": "该版本质量等级快照 L1-L5"}, - {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "saved", "desc": "版本状态,见 codes.status"}, - {"name": "remark", "type": "str", "len": 500, "desc": "版本说明"}, - {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, - {"name": "created_at", "type": "datetime", "desc": "创建时间"}, - {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, - {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, - {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} - ], - "indexes": [ - {"name": "uk_pbl_bpv_tenant_bp_ver", "fields": ["tenant_id", "blueprint_id", "version_no"], "unique": true}, - {"name": "idx_pbl_bpv_tenant_status", "fields": ["tenant_id", "status", "deleted"], "unique": false} + "summary": "蓝图版本表:每次 save_version 生成一个版本快照(snapshot_json 存整棵树),支持版本回溯、change_delta 对比与回滚。", + "primary": [ + "id" ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "所属蓝图ID" + }, + "version_no": { + "type": "int", + "notnull": true, + "summary": "版本号(蓝图内递增)" + }, + "version_type": { + "type": "str", + "size": 16, + "notnull": true, + "default": "draft", + "summary": "版本类型" + }, + "change_summary": { + "type": "str", + "size": 512, + "notnull": true, + "default": "", + "summary": "变更说明" + }, + "snapshot_json": { + "type": "text", + "default": "", + "summary": "蓝图整树快照JSON(主表字段+节点+边)" + }, + "node_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "快照节点数" + }, + "edge_count": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "快照边数" + }, + "quality_status": { + "type": "str", + "size": 16, + "notnull": true, + "default": "q0", + "summary": "该版本质量状态" + }, + "score_amount": { + "type": "double", + "size": [ + 18, + 2 + ], + "notnull": true, + "default": 0, + "summary": "该版本评估得分(double(18,2),M6 回写)" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_version": { + "fields": [ + "tenant_id", + "blueprint_id", + "version_no" + ], + "unique": true, + "summary": "蓝图内版本号唯一" + }, + "idx_pbl_version_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "deleted", + "version_no" + ], + "unique": false, + "summary": "版本列表(倒序取最新)" + } + }, "codes": { - "status": [["saved", "已保存"], ["published", "已发布"], ["rolled_back", "已回滚"], ["archived", "已归档"]], - "quality_level": [["L1", "L1-初始"], ["L2", "L2-基本完整"], ["L3", "L3-结构合格"], ["L4", "L4-可运行"], ["L5", "L5-优质"]], - "deleted": [["0", "正常"], ["1", "已删除"]] + "version_type": { + "summary": "版本类型", + "items": { + "draft": "草稿版本", + "minor": "小版本", + "major": "大版本", + "publish": "发布版本", + "rollback": "回滚版本" + } + }, + "quality_status": { + "summary": "质量状态(5 级,M2 校验引擎回写)", + "items": { + "q0": "未校验", + "q1": "不合格", + "q2": "基本合格", + "q3": "合格", + "q4": "优秀", + "q5": "标杆" + } + } } } diff --git a/pbl_blueprint/models/pbl_blueprint_version_delta.json b/pbl_blueprint/models/pbl_blueprint_version_delta.json new file mode 100644 index 0000000..da67f99 --- /dev/null +++ b/pbl_blueprint/models/pbl_blueprint_version_delta.json @@ -0,0 +1,139 @@ +{ + "summary": "蓝图版本差异表:记录两个版本之间的 change_delta(新增/修改/删除的节点、边与主表字段),供版本对比与回滚审计。", + "primary": [ + "id" + ], + "fields": { + "id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "主键ID(str32,UUID去横线)" + }, + "tenant_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + }, + "blueprint_id": { + "type": "str", + "size": 32, + "notnull": true, + "summary": "所属蓝图ID" + }, + "from_version": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "基准版本号" + }, + "to_version": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "目标版本号" + }, + "delta_type": { + "type": "str", + "size": 16, + "notnull": true, + "summary": "差异对象类型" + }, + "op_type": { + "type": "str", + "size": 16, + "notnull": true, + "summary": "操作类型" + }, + "target_id": { + "type": "str", + "size": 32, + "notnull": true, + "default": "", + "summary": "差异对象ID(节点ID/边ID/蓝图ID)" + }, + "target_name": { + "type": "str", + "size": 128, + "notnull": true, + "default": "", + "summary": "差异对象名称(冗余便于展示)" + }, + "before_json": { + "type": "text", + "default": "", + "summary": "变更前JSON" + }, + "after_json": { + "type": "text", + "default": "", + "summary": "变更后JSON" + }, + "sort_no": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "排序号" + }, + "create_user": { + "type": "str", + "size": 32, + "default": "", + "summary": "创建人" + }, + "create_time": { + "type": "datetime", + "summary": "创建时间" + }, + "deleted": { + "type": "int", + "notnull": true, + "default": 0, + "summary": "逻辑删除标记 0正常 1删除" + } + }, + "indexes": { + "uk_pbl_delta": { + "fields": [ + "tenant_id", + "blueprint_id", + "from_version", + "to_version", + "delta_type", + "op_type", + "target_id" + ], + "unique": true, + "summary": "同一版本对同一对象同一操作唯一,保证幂等写入" + }, + "idx_pbl_delta_bp": { + "fields": [ + "tenant_id", + "blueprint_id", + "deleted", + "to_version" + ], + "unique": false, + "summary": "按蓝图+版本查差异" + } + }, + "codes": { + "delta_type": { + "summary": "差异对象类型", + "items": { + "blueprint": "蓝图主表字段", + "node": "节点", + "edge": "边" + } + }, + "op_type": { + "summary": "操作类型", + "items": { + "add": "新增", + "update": "修改", + "delete": "删除" + } + } + } +} diff --git a/pbl_blueprint/service.py b/pbl_blueprint/service.py new file mode 100644 index 0000000..821881d --- /dev/null +++ b/pbl_blueprint/service.py @@ -0,0 +1,1446 @@ +# -*- coding: utf-8 -*- +"""蓝图业务契约(M1a):CRUD / 树 / fork / 版本 change_delta / 模板实例化 / 离线兜底 / 发布 / 锁。 + +对外契约函数一律 tenant_id 第一个参数,内部 require_tenant 校验(fail-closed)。 +返回体统一 {"success": bool, "errcode": str, "errmsg": str, "data": ...}。 +SQL 只用 db.q_* 封装(sqlor 白名单 C/U/D/R/I/sqlExe),条件一律参数化。 +""" + +import datetime as _dt +import hashlib + +from . import db as _db +from .audit import write_audit +from .crud import ( + crud_create, + crud_get, + crud_update, + crud_delete, + crud_list, + crud_count, + build_filter, + new_id, + now_str, +) +from .errors import ( + ERR_PARAM_INVALID, + ERR_NOT_FOUND, + ERR_STATE_INVALID, + ERR_DUPLICATE, + ERR_LOCKED, + ERR_FORBIDDEN, + PblBlueprintError, + ok, +) +from .tenant import require_tenant, tenant_where + +T_BLUEPRINT = "pbl_blueprint" +T_NODE = "pbl_blueprint_node" +T_EDGE = "pbl_blueprint_edge" +T_VERSION = "pbl_blueprint_version" +T_DELTA = "pbl_blueprint_version_delta" +T_TEMPLATE = "pbl_blueprint_template" +T_PUBLISH = "pbl_blueprint_publish" +T_OFFLINE = "pbl_blueprint_offline" +T_FORK = "pbl_blueprint_fork" +T_LOCK = "pbl_blueprint_lock" + +NODE_TYPES = ("role", "task", "rule", "artifact", "scene", "assessment", "resource") +EDGE_TYPES = ("depends", "unlocks", "produces", "consumes", "assesses", "contains") +SOURCES = ("manual", "template", "fork", "agent", "import") +FORK_TYPES = ("copy", "structure", "template", "branch") +VERSION_TYPES = ("draft", "minor", "major", "publish", "rollback") +TARGET_TYPES = ("class", "team", "student", "preview") +LOCK_TYPES = ("edit", "publish", "compile") +OFFLINE_SCHEMA_VERSION = "1.0" +MAX_TREE_ROWS = 500 + + +# ------------------------------------------------------------------ 内部工具 + +def _sor(sor=None): + return sor or _db.get_sor() + + +def _next_code(tid, tbl, prefix, sor=None): + """生成租户内递增编码:{prefix}-{6位序号}(含已删除行,避免编码复用)。""" + total = crud_count(tbl, tid, "", [], True, _sor(sor)) + return "%s-%06d" % (prefix, int(total or 0) + 1) + + +def _assert_in(val, allowed, label): + if val not in allowed: + raise PblBlueprintError(ERR_PARAM_INVALID, + "非法 %s: %s(合法: %s)" % (label, val, "/".join(allowed))) + return val + + +def _assert_node_type(nt): + return _assert_in(nt, NODE_TYPES, "node_type") + + +def _assert_edge_type(et): + return _assert_in(et, EDGE_TYPES, "edge_type") + + +def _sha256(txt): + return hashlib.sha256((txt or "").encode("utf-8")).hexdigest() + + +def _cond(tid, tbl, conds): + """由 build_filter 生成业务条件,再与 tenant_id 打头条件合并。""" + bw, ba = build_filter(tbl, conds or {}) + return tenant_where(tid, bw, ba) + + +def _rows(tbl, tid, conds, fields="*", order="", page=1, page_size=MAX_TREE_ROWS, + include_deleted=False, sor=None): + w, a = _cond(tid, tbl, conds) + return crud_list(tbl, tid, w, a, fields, order, page, page_size, + include_deleted, _sor(sor))["rows"] + + +def _cnt(tbl, tid, conds, include_deleted=False, sor=None): + w, a = _cond(tid, tbl, conds) + return crud_count(tbl, tid, w, a, include_deleted, _sor(sor)) + + +def _spec_txt(v): + """spec_json / condition_json 归一化为文本。""" + if v is None or v == "": + return "" + if isinstance(v, str): + return v + return _db.dumps(v) + + +# ------------------------------------------------------------------ 蓝图 CRUD + +def create_blueprint(tenant_id, name, subject="", grade="", phase="", owner_id="", + class_id="", summary="", duration_hours=0, budget_amount=0, + source="manual", source_id="", ext_json=None, code="", + op_user="", sor=None): + """创建蓝图(契约:tenant_id 打头)。""" + tid = require_tenant(tenant_id) + nm = (name or "").strip() + if not nm: + raise PblBlueprintError(ERR_PARAM_INVALID, "name 不能为空") + if len(nm) > 128: + raise PblBlueprintError(ERR_PARAM_INVALID, "name 超长(>128)") + _assert_in(source, SOURCES, "source") + s = _sor(sor) + row = { + "id": new_id(), + "tenant_id": tid, + "code": (code or "").strip() or _next_code(tid, T_BLUEPRINT, "PBL-BP", s), + "name": nm, + "subject": subject or "", + "grade": grade or "", + "phase": phase or "", + "status": "draft", + "quality_status": "q0", + "source": source, + "source_id": source_id or "", + "owner_id": owner_id or "", + "class_id": class_id or "", + "current_version": 0, + "duration_hours": int(duration_hours or 0), + "budget_amount": float(budget_amount or 0), + "summary": summary or "", + "ext_json": _spec_txt(ext_json), + } + created = crud_create(T_BLUEPRINT, tid, row, op_user, s) + write_audit(tid, created["id"], "create", "blueprint", created["id"], + after=created, op_user=op_user, sor=s) + return ok(created) + + +def get_blueprint(tenant_id, blueprint_id, with_tree=False, sor=None): + """取蓝图详情;with_tree=True 时附节点树与边。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + data = dict(bp) + data["ext_json"] = _db.loads(bp.get("ext_json"), {}) + if with_tree: + data["tree"] = get_blueprint_tree(tid, blueprint_id, sor=s).get("data") + return ok(data) + + +def update_blueprint(tenant_id, blueprint_id, data, op_user="", sor=None): + """更新蓝图主表字段(白名单字段,禁改 id/tenant_id/code/current_version)。""" + tid = require_tenant(tenant_id) + editable = {"name", "subject", "grade", "phase", "owner_id", "class_id", "summary", + "duration_hours", "budget_amount", "ext_json", "status", "quality_status"} + payload = {} + for k, v in (data or {}).items(): + if k not in editable: + raise PblBlueprintError(ERR_PARAM_INVALID, "字段不可编辑: %s" % k) + payload[k] = _spec_txt(v) if k == "ext_json" else v + if not payload: + raise PblBlueprintError(ERR_PARAM_INVALID, "无待更新字段") + if "status" in payload: + _assert_in(payload["status"], + ("draft", "validating", "ready", "published", "archived", "disabled"), "status") + if "quality_status" in payload: + _assert_in(payload["quality_status"], ("q0", "q1", "q2", "q3", "q4", "q5"), "quality_status") + s = _sor(sor) + before = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + if before.get("status") == "published" and payload.get("status") == "draft": + raise PblBlueprintError(ERR_STATE_INVALID, "已发布蓝图不可直接退回草稿,请先撤回发布") + n = crud_update(T_BLUEPRINT, tid, blueprint_id, payload, op_user, s) + write_audit(tid, blueprint_id, "update", "blueprint", blueprint_id, + before=before, after=payload, op_user=op_user, sor=s) + return ok({"affected": n}) + + +def delete_blueprint(tenant_id, blueprint_id, op_user="", cascade=True, sor=None): + """逻辑删除蓝图;cascade=True 时连带逻辑删除节点/边。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + if bp.get("status") == "published": + running = _cnt(T_PUBLISH, tid, {"blueprint_id": blueprint_id, "status": "running"}, False, s) + if running: + raise PblBlueprintError(ERR_STATE_INVALID, "存在进行中的投放,不可删除") + n = crud_delete(T_BLUEPRINT, tid, blueprint_id, op_user, s) + sub = 0 + if cascade: + w, a = _cond(tid, T_NODE, {"blueprint_id": blueprint_id}) + sub += _db.q_update(s, T_NODE, {"deleted": 1, "update_time": now_str()}, w, a) + w2, a2 = _cond(tid, T_EDGE, {"blueprint_id": blueprint_id}) + sub += _db.q_update(s, T_EDGE, {"deleted": 1, "update_time": now_str()}, w2, a2) + write_audit(tid, blueprint_id, "delete", "blueprint", blueprint_id, + before=bp, after={"cascade": sub}, op_user=op_user, sor=s) + return ok({"affected": n, "cascade": sub}) + + +def list_blueprints(tenant_id, conds=None, page=1, page_size=20, order="create_time desc", sor=None): + """蓝图分页列表(tenant_id 打头)。""" + tid = require_tenant(tenant_id) + w, a = _cond(tid, T_BLUEPRINT, conds or {}) + return ok(crud_list(T_BLUEPRINT, tid, w, a, "*", order, page, page_size, False, _sor(sor))) + + +# ------------------------------------------------------------------ 蓝图树 / 子对象 + +def get_blueprint_tree(tenant_id, blueprint_id, node_type="", sor=None): + """取蓝图树:roots + children 递归;附 edges 列表。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + crud_get(T_BLUEPRINT, tid, blueprint_id, "id", s) # 归属校验 + conds = {"blueprint_id": blueprint_id} + if node_type: + conds["node_type"] = _assert_node_type(node_type) + nodes = _rows(T_NODE, tid, conds, "*", "sort_no asc,create_time asc", sor=s) + edges = _rows(T_EDGE, tid, {"blueprint_id": blueprint_id}, "*", "sort_no asc", sor=s) + for n in nodes: + n["spec_json"] = _db.loads(n.get("spec_json"), {}) + n["children"] = [] + idx = {} + for n in nodes: + idx[n["id"]] = n + roots = [] + for n in nodes: + p = idx.get(n.get("parent_id") or "") + if p is not None and p is not n: + p["children"].append(n) + else: + roots.append(n) + for e in edges: + e["condition_json"] = _db.loads(e.get("condition_json"), {}) + return ok({"blueprint_id": blueprint_id, "roots": roots, "edges": edges, + "node_count": len(nodes), "edge_count": len(edges)}) + + +def create_node(tenant_id, blueprint_id, node_type, name, parent_id="", code="", + sort_no=0, spec_json=None, ref_module="", ref_id="", required=0, + weight=0, status="active", op_user="", sor=None): + """新增子对象节点(7 类泛化契约)。""" + tid = require_tenant(tenant_id) + _assert_node_type(node_type) + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "id", s) + if bp.get("status") == "archived": + raise PblBlueprintError(ERR_STATE_INVALID, "已归档蓝图不可编辑") + nm = (name or "").strip() + if not nm: + raise PblBlueprintError(ERR_PARAM_INVALID, "name 不能为空") + if parent_id: + p = crud_get(T_NODE, tid, parent_id, "*", s) + if p.get("blueprint_id") != blueprint_id: + raise PblBlueprintError(ERR_PARAM_INVALID, "parent_id 不属于该蓝图") + if p.get("node_type") == "task" and node_type == "task": + raise PblBlueprintError(ERR_STATE_INVALID, "task 节点不允许嵌套 task") + row = { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id, + "node_type": node_type, + "parent_id": parent_id or "", + "code": (code or "").strip() or "%s-%s" % (node_type.upper(), new_id()[:8]), + "name": nm, + "sort_no": int(sort_no or 0), + "spec_json": _spec_txt(spec_json), + "ref_module": ref_module or "", + "ref_id": ref_id or "", + "status": _assert_in(status or "active", ("active", "draft", "invalid", "disabled"), "status"), + "required": int(required or 0), + "weight": float(weight or 0), + } + created = crud_create(T_NODE, tid, row, op_user, s) + write_audit(tid, blueprint_id, "create", "node", created["id"], + after=created, op_user=op_user, sor=s) + return ok(created) + + +def update_node(tenant_id, node_id, data, op_user="", sor=None): + """更新节点(白名单字段,含防环校验)。""" + tid = require_tenant(tenant_id) + editable = {"name", "parent_id", "code", "sort_no", "spec_json", "ref_module", + "ref_id", "status", "required", "weight"} + payload = {} + for k, v in (data or {}).items(): + if k not in editable: + raise PblBlueprintError(ERR_PARAM_INVALID, "字段不可编辑: %s" % k) + payload[k] = _spec_txt(v) if k == "spec_json" else v + if not payload: + raise PblBlueprintError(ERR_PARAM_INVALID, "无待更新字段") + if "status" in payload: + _assert_in(payload["status"], ("active", "draft", "invalid", "disabled"), "status") + s = _sor(sor) + before = crud_get(T_NODE, tid, node_id, "*", s) + if "parent_id" in payload: + pid = (payload["parent_id"] or "").strip() + payload["parent_id"] = pid + if pid: + if pid == node_id: + raise PblBlueprintError(ERR_PARAM_INVALID, "parent_id 不能为自身") + p = crud_get(T_NODE, tid, pid, "*", s) + if p.get("blueprint_id") != before.get("blueprint_id"): + raise PblBlueprintError(ERR_PARAM_INVALID, "parent_id 不属于同一蓝图") + if _would_cycle(tid, node_id, pid, s): + raise PblBlueprintError(ERR_STATE_INVALID, "移动会造成环") + n = crud_update(T_NODE, tid, node_id, payload, op_user, s) + write_audit(tid, before["blueprint_id"], "update", "node", node_id, + before=before, after=payload, op_user=op_user, sor=s) + return ok({"affected": n}) + + +def _would_cycle(tid, node_id, new_parent_id, sor=None): + """检测把 node_id 挂到 new_parent_id 下是否成环。""" + cur = new_parent_id + seen = set() + while cur and cur not in seen: + if cur == node_id: + return True + seen.add(cur) + try: + p = crud_get(T_NODE, tid, cur, "parent_id", sor=sor) + except PblBlueprintError: + return False + cur = (p.get("parent_id") or "").strip() + return False + + +def delete_node(tenant_id, node_id, op_user="", cascade=True, sor=None): + """删除节点;cascade=True 时连带删除子节点与相关边。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + node = crud_get(T_NODE, tid, node_id, "*", s) + if cascade: + kids = _rows(T_NODE, tid, {"parent_id": node_id}, "id", "", sor=s) + for k in kids: + delete_node(tid, k["id"], op_user, True, s) + for f in ("from_node_id", "to_node_id"): + w, a = _cond(tid, T_EDGE, {f: node_id}) + _db.q_update(s, T_EDGE, {"deleted": 1, "update_time": now_str()}, w, a) + n = crud_delete(T_NODE, tid, node_id, op_user, s) + write_audit(tid, node["blueprint_id"], "delete", "node", node_id, + before=node, op_user=op_user, sor=s) + return ok({"affected": n}) + + +def list_nodes(tenant_id, blueprint_id, node_type="", page=1, page_size=100, sor=None): + """列节点(tenant_id 打头)。""" + tid = require_tenant(tenant_id) + conds = {"blueprint_id": blueprint_id} + if node_type: + conds["node_type"] = _assert_node_type(node_type) + w, a = _cond(tid, T_NODE, conds) + return ok(crud_list(T_NODE, tid, w, a, "*", "sort_no asc", page, page_size, False, _sor(sor))) + + +def get_node(tenant_id, node_id, sor=None): + """取单个节点(spec_json 解析为对象)。""" + tid = require_tenant(tenant_id) + n = dict(crud_get(T_NODE, tid, node_id, "*", _sor(sor))) + n["spec_json"] = _db.loads(n.get("spec_json"), {}) + return ok(n) + + +def create_edge(tenant_id, blueprint_id, from_node_id, to_node_id, edge_type="depends", + weight=0, condition_json=None, sort_no=0, op_user="", sor=None): + """新增关系边(同蓝图内、防自环、防重复)。""" + tid = require_tenant(tenant_id) + _assert_edge_type(edge_type) + if not from_node_id or not to_node_id: + raise PblBlueprintError(ERR_PARAM_INVALID, "from_node_id/to_node_id 必填") + if from_node_id == to_node_id: + raise PblBlueprintError(ERR_PARAM_INVALID, "不允许自环边") + s = _sor(sor) + f = crud_get(T_NODE, tid, from_node_id, "*", s) + t = crud_get(T_NODE, tid, to_node_id, "*", s) + if f["blueprint_id"] != blueprint_id or t["blueprint_id"] != blueprint_id: + raise PblBlueprintError(ERR_PARAM_INVALID, "边两端节点必须属于同一蓝图") + if _cnt(T_EDGE, tid, {"blueprint_id": blueprint_id, "from_node_id": from_node_id, + "to_node_id": to_node_id, "edge_type": edge_type}, False, s): + raise PblBlueprintError(ERR_DUPLICATE, "该边已存在") + if edge_type in ("depends", "unlocks") and _path_exists(tid, blueprint_id, to_node_id, + from_node_id, s): + raise PblBlueprintError(ERR_STATE_INVALID, "反向依赖已存在,连边会造成环") + row = { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id, + "from_node_id": from_node_id, + "to_node_id": to_node_id, + "edge_type": edge_type, + "weight": float(weight or 0), + "condition_json": _spec_txt(condition_json), + "sort_no": int(sort_no or 0), + } + created = crud_create(T_EDGE, tid, row, op_user, s) + write_audit(tid, blueprint_id, "create", "edge", created["id"], + after=created, op_user=op_user, sor=s) + return ok(created) + + +def _path_exists(tid, blueprint_id, src, dst, sor=None, max_depth=64): + """BFS 判断 src 是否已能沿 depends/unlocks 边到达 dst(防环)。""" + edges = _rows(T_EDGE, tid, {"blueprint_id": blueprint_id}, + "from_node_id,to_node_id,edge_type", "", sor=sor) + adj = {} + for e in edges: + if e.get("edge_type") not in ("depends", "unlocks"): + continue + adj.setdefault(e["from_node_id"], []).append(e["to_node_id"]) + seen, queue, depth = {src}, [src], 0 + while queue and depth < max_depth: + nxt = [] + for cur in queue: + for to in adj.get(cur, ()): + if to == dst: + return True + if to not in seen: + seen.add(to) + nxt.append(to) + queue = nxt + depth += 1 + return False + + +def update_edge(tenant_id, edge_id, data, op_user="", sor=None): + """更新边(白名单字段)。""" + tid = require_tenant(tenant_id) + editable = {"weight", "condition_json", "sort_no", "edge_type"} + payload = {} + for k, v in (data or {}).items(): + if k not in editable: + raise PblBlueprintError(ERR_PARAM_INVALID, "字段不可编辑: %s" % k) + if k == "edge_type": + _assert_edge_type(v) + payload[k] = _spec_txt(v) if k == "condition_json" else v + if not payload: + raise PblBlueprintError(ERR_PARAM_INVALID, "无待更新字段") + s = _sor(sor) + before = crud_get(T_EDGE, tid, edge_id, "*", s) + n = crud_update(T_EDGE, tid, edge_id, payload, op_user, s) + write_audit(tid, before["blueprint_id"], "update", "edge", edge_id, + before=before, after=payload, op_user=op_user, sor=s) + return ok({"affected": n}) + + +def delete_edge(tenant_id, edge_id, op_user="", sor=None): + """删除边(逻辑删除)。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + e = crud_get(T_EDGE, tid, edge_id, "*", s) + n = crud_delete(T_EDGE, tid, edge_id, op_user, s) + write_audit(tid, e["blueprint_id"], "delete", "edge", edge_id, + before=e, op_user=op_user, sor=s) + return ok({"affected": n}) + + +def list_edges(tenant_id, blueprint_id, edge_type="", page=1, page_size=200, sor=None): + """列边(tenant_id 打头)。""" + tid = require_tenant(tenant_id) + conds = {"blueprint_id": blueprint_id} + if edge_type: + conds["edge_type"] = _assert_edge_type(edge_type) + w, a = _cond(tid, T_EDGE, conds) + return ok(crud_list(T_EDGE, tid, w, a, "*", "sort_no asc", page, page_size, False, _sor(sor))) + + +# ------------------------------------------------------------------ fork + +def fork_blueprint(tenant_id, source_blueprint_id, new_name="", fork_type="copy", + op_user="", sor=None): + """fork 蓝图:复制主表 + 节点 + 边(ID 重映射),写血缘表。""" + tid = require_tenant(tenant_id) + _assert_in(fork_type, FORK_TYPES, "fork_type") + s = _sor(sor) + src = crud_get(T_BLUEPRINT, tid, source_blueprint_id, "*", s) + nm = (new_name or "").strip() or ("%s-副本" % src.get("name", "")) + new_bp = crud_create(T_BLUEPRINT, tid, { + "id": new_id(), + "tenant_id": tid, + "code": _next_code(tid, T_BLUEPRINT, "PBL-BP", s), + "name": nm, + "subject": src.get("subject", ""), + "grade": src.get("grade", ""), + "phase": src.get("phase", ""), + "status": "draft", + "quality_status": "q0", + "source": "fork", + "source_id": source_blueprint_id, + "owner_id": op_user or src.get("owner_id", ""), + "class_id": "", + "current_version": 0, + "duration_hours": src.get("duration_hours", 0), + "budget_amount": src.get("budget_amount", 0), + "summary": src.get("summary", ""), + "ext_json": src.get("ext_json", ""), + }, op_user, s) + keep_spec = fork_type != "structure" + id_map = {} + nodes = _rows(T_NODE, tid, {"blueprint_id": source_blueprint_id}, "*", + "sort_no asc,create_time asc", sor=s) + # 先按父在前排序,保证 parent_id 映射可用 + for n in _sort_parents_first(nodes): + nid = new_id() + id_map[n["id"]] = nid + crud_create(T_NODE, tid, { + "id": nid, + "tenant_id": tid, + "blueprint_id": new_bp["id"], + "node_type": n.get("node_type"), + "parent_id": id_map.get(n.get("parent_id") or "", ""), + "code": n.get("code", ""), + "name": n.get("name", ""), + "sort_no": n.get("sort_no", 0), + "spec_json": n.get("spec_json", "") if keep_spec else "", + "ref_module": n.get("ref_module", ""), + "ref_id": n.get("ref_id", ""), + "status": n.get("status", "active"), + "required": n.get("required", 0), + "weight": n.get("weight", 0), + }, op_user, s) + edge_cnt = 0 + edges = _rows(T_EDGE, tid, {"blueprint_id": source_blueprint_id}, "*", "sort_no asc", sor=s) + for e in edges: + f2 = id_map.get(e.get("from_node_id")) + t2 = id_map.get(e.get("to_node_id")) + if not f2 or not t2 or f2 == t2: + continue + crud_create(T_EDGE, tid, { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": new_bp["id"], + "from_node_id": f2, + "to_node_id": t2, + "edge_type": e.get("edge_type", "depends"), + "weight": e.get("weight", 0), + "condition_json": e.get("condition_json", "") if keep_spec else "", + "sort_no": e.get("sort_no", 0), + }, op_user, s) + edge_cnt += 1 + crud_create(T_FORK, tid, { + "id": new_id(), + "tenant_id": tid, + "source_blueprint_id": source_blueprint_id, + "source_version": int(src.get("current_version") or 0), + "target_blueprint_id": new_bp["id"], + "fork_type": fork_type, + "template_id": src.get("source_id", "") if src.get("source") == "template" else "", + "node_count": len(id_map), + "edge_count": edge_cnt, + "cost_amount": 0, + "remark": "fork from %s" % src.get("code", ""), + }, op_user, s) + write_audit(tid, new_bp["id"], "fork", "blueprint", new_bp["id"], + before={"source_blueprint_id": source_blueprint_id}, + after={"nodes": len(id_map), "edges": edge_cnt}, op_user=op_user, sor=s) + return ok({"blueprint": new_bp, "node_count": len(id_map), "edge_count": edge_cnt}) + + +def _sort_parents_first(nodes): + """拓扑排序:父节点先于子节点(有环时按原序补齐,不丢节点)。""" + by_id = {n["id"]: n for n in nodes} + out, seen = [], set() + + def emit(n): + if n["id"] in seen: + return + pid = n.get("parent_id") or "" + if pid and pid in by_id and pid not in seen: + emit(by_id[pid]) + if n["id"] in seen: + return + seen.add(n["id"]) + out.append(n) + + for n in nodes: + emit(n) + return out + + +def list_forks(tenant_id, blueprint_id, direction="children", page=1, page_size=50, sor=None): + """查血缘:direction=children 查派生,parents 查来源。""" + tid = require_tenant(tenant_id) + _assert_in(direction, ("children", "parents"), "direction") + field = "source_blueprint_id" if direction == "children" else "target_blueprint_id" + w, a = _cond(tid, T_FORK, {field: blueprint_id}) + return ok(crud_list(T_FORK, tid, w, a, "*", "create_time desc", page, page_size, + False, _sor(sor))) + + +# ------------------------------------------------------------------ 版本 + +def save_version(tenant_id, blueprint_id, version_type="draft", change_summary="", + op_user="", sor=None): + """保存版本快照(整树 JSON)+ 计算 change_delta + 回写 current_version。""" + tid = require_tenant(tenant_id) + _assert_in(version_type, VERSION_TYPES, "version_type") + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + tree = get_blueprint_tree(tid, blueprint_id, sor=s)["data"] + new_no = int(bp.get("current_version") or 0) + 1 + snap = { + "schema_version": OFFLINE_SCHEMA_VERSION, + "blueprint": {k: v for k, v in bp.items() if k != "ext_json"}, + "ext_json": _db.loads(bp.get("ext_json"), {}), + "nodes": tree["roots"], + "edges": tree["edges"], + } + prev_snap = _load_snap(tid, blueprint_id, new_no - 1, s) + ver = crud_create(T_VERSION, tid, { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id, + "version_no": new_no, + "version_type": version_type, + "change_summary": (change_summary or "")[:512], + "snapshot_json": _db.dumps(snap), + "node_count": tree["node_count"], + "edge_count": tree["edge_count"], + "quality_status": bp.get("quality_status", "q0"), + "score_amount": 0, + }, op_user, s) + delta_cnt = _write_delta(tid, blueprint_id, new_no - 1, new_no, prev_snap, snap, op_user, s) + crud_update(T_BLUEPRINT, tid, blueprint_id, {"current_version": new_no}, op_user, s) + write_audit(tid, blueprint_id, "save_version", "version", ver["id"], version_no=new_no, + after={"node_count": tree["node_count"], "edge_count": tree["edge_count"], + "delta_count": delta_cnt}, op_user=op_user, sor=s) + return ok({"version": ver, "delta_count": delta_cnt}) + + +def _flat_nodes(snap): + """把树形 nodes 摊平为 {id: node}(去掉 children 便于比对)。""" + out = {} + + def walk(ns): + for n in ns or []: + if not isinstance(n, dict): + continue + item = {k: v for k, v in n.items() if k != "children"} + if isinstance(item.get("spec_json"), (dict, list)): + item["spec_json"] = _db.dumps(item["spec_json"]) + out[n.get("id")] = item + walk(n.get("children")) + walk((snap or {}).get("nodes")) + return out + + +def _flat_edges(snap): + out = {} + for e in (snap or {}).get("edges") or []: + if not isinstance(e, dict): + continue + item = dict(e) + if isinstance(item.get("condition_json"), (dict, list)): + item["condition_json"] = _db.dumps(item["condition_json"]) + out[e.get("id")] = item + return out + + +def _diff_dict(a, b, skip=("update_time", "update_user", "create_time", "current_version")): + keys = set(a or {}) | set(b or {}) + return {k: ((a or {}).get(k), (b or {}).get(k)) for k in keys + if k not in skip and (a or {}).get(k) != (b or {}).get(k)} + + +def _write_delta(tid, blueprint_id, from_v, to_v, prev_snap, cur_snap, op_user, sor): + """计算并幂等写入 change_delta(节点 + 边 + 主表字段)。""" + cnt = 0 + pn, cn = _flat_nodes(prev_snap), _flat_nodes(cur_snap) + for nid, node in cn.items(): + if not nid: + continue + if nid not in pn: + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "node", "add", nid, + node.get("name", ""), None, node, op_user, sor) + else: + diff = _diff_dict(pn[nid], node) + if diff: + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "node", "update", nid, + node.get("name", ""), pn[nid], node, op_user, sor) + for nid, node in pn.items(): + if not nid: + continue + if nid not in cn: + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "node", "delete", nid, + node.get("name", ""), node, None, op_user, sor) + pe, ce = _flat_edges(prev_snap), _flat_edges(cur_snap) + for eid, e in ce.items(): + if not eid: + continue + if eid not in pe: + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "edge", "add", eid, + e.get("edge_type", ""), None, e, op_user, sor) + elif _diff_dict(pe[eid], e, skip=("update_time", "create_time")): + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "edge", "update", eid, + e.get("edge_type", ""), pe[eid], e, op_user, sor) + for eid, e in pe.items(): + if eid and eid not in ce: + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "edge", "delete", eid, + e.get("edge_type", ""), e, None, op_user, sor) + bdiff = _diff_dict((prev_snap or {}).get("blueprint"), (cur_snap or {}).get("blueprint")) + if bdiff: + cnt += _delta_row(tid, blueprint_id, from_v, to_v, "blueprint", "update", + blueprint_id, "blueprint", + {k: v[0] for k, v in bdiff.items()}, + {k: v[1] for k, v in bdiff.items()}, op_user, sor) + return cnt + + +def _delta_row(tid, blueprint_id, from_v, to_v, delta_type, op_type, target_id, + target_name, before, after, op_user, sor): + """写一条 delta(唯一键冲突视为幂等成功,返回 0)。""" + try: + crud_create(T_DELTA, tid, { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id, + "from_version": int(from_v or 0), + "to_version": int(to_v or 0), + "delta_type": delta_type, + "op_type": op_type, + "target_id": target_id or "", + "target_name": (target_name or "")[:128], + "before_json": _db.dumps(before) if before is not None else "", + "after_json": _db.dumps(after) if after is not None else "", + "sort_no": 0, + }, op_user, sor) + return 1 + except PblBlueprintError as e: + if e.errcode == ERR_DUPLICATE: + return 0 + raise + + +def list_versions(tenant_id, blueprint_id, page=1, page_size=50, sor=None): + """版本列表(不含快照大字段)。""" + tid = require_tenant(tenant_id) + w, a = _cond(tid, T_VERSION, {"blueprint_id": blueprint_id}) + return ok(crud_list(T_VERSION, tid, w, a, + "id,tenant_id,blueprint_id,version_no,version_type,change_summary," + "node_count,edge_count,quality_status,score_amount,create_user,create_time,deleted", + "version_no desc", page, page_size, False, _sor(sor))) + + +def get_version(tenant_id, version_id, with_snapshot=False, sor=None): + """取版本详情;with_snapshot=True 时解析快照 JSON。""" + tid = require_tenant(tenant_id) + fields = "*" if with_snapshot else ( + "id,tenant_id,blueprint_id,version_no,version_type,change_summary,node_count," + "edge_count,quality_status,score_amount,create_user,create_time,deleted") + v = dict(crud_get(T_VERSION, tid, version_id, fields, _sor(sor))) + if with_snapshot: + v["snapshot_json"] = _db.loads(v.get("snapshot_json"), {}) + return ok(v) + + +def get_change_delta(tenant_id, blueprint_id, from_version, to_version, page=1, + page_size=200, sor=None): + """取两版本间 change_delta(库中无记录时按快照即时计算,不落库)。""" + tid = require_tenant(tenant_id) + fv, tv = int(from_version or 0), int(to_version or 0) + if fv == tv: + raise PblBlueprintError(ERR_PARAM_INVALID, "from_version 与 to_version 不能相同") + s = _sor(sor) + w, a = _cond(tid, T_DELTA, {"blueprint_id": blueprint_id, + "from_version": fv, "to_version": tv}) + res = crud_list(T_DELTA, tid, w, a, "*", "delta_type asc,sort_no asc", + page, page_size, False, s) + if res["total"]: + for r in res["rows"]: + r["before_json"] = _db.loads(r.get("before_json"), None) + r["after_json"] = _db.loads(r.get("after_json"), None) + res["computed"] = False + return ok(res) + prev = _load_snap(tid, blueprint_id, fv, s) + cur = _load_snap(tid, blueprint_id, tv, s) + if prev is None or cur is None: + raise PblBlueprintError(ERR_NOT_FOUND, "版本快照不存在: v%s / v%s" % (fv, tv)) + items = [] + pn, cn = _flat_nodes(prev), _flat_nodes(cur) + for nid, node in cn.items(): + if not nid: + continue + if nid not in pn: + items.append({"delta_type": "node", "op_type": "add", "target_id": nid, + "target_name": node.get("name", ""), "before_json": None, + "after_json": node}) + elif _diff_dict(pn[nid], node): + items.append({"delta_type": "node", "op_type": "update", "target_id": nid, + "target_name": node.get("name", ""), "before_json": pn[nid], + "after_json": node}) + for nid, node in pn.items(): + if nid and nid not in cn: + items.append({"delta_type": "node", "op_type": "delete", "target_id": nid, + "target_name": node.get("name", ""), "before_json": node, + "after_json": None}) + pe, ce = _flat_edges(prev), _flat_edges(cur) + for eid, e in ce.items(): + if not eid: + continue + if eid not in pe: + items.append({"delta_type": "edge", "op_type": "add", "target_id": eid, + "target_name": e.get("edge_type", ""), "before_json": None, + "after_json": e}) + for eid, e in pe.items(): + if eid and eid not in ce: + items.append({"delta_type": "edge", "op_type": "delete", "target_id": eid, + "target_name": e.get("edge_type", ""), "before_json": e, + "after_json": None}) + total = len(items) + start = (max(1, int(page or 1)) - 1) * int(page_size or 200) + return ok({"rows": items[start:start + int(page_size or 200)], "total": total, + "page": int(page or 1), "page_size": int(page_size or 200), "computed": True}) + + +def _load_snap(tid, blueprint_id, version_no, sor=None): + """按版本号取快照(解析为 dict,无则 None)。""" + if not version_no or int(version_no) <= 0: + return None + rows = _rows(T_VERSION, tid, {"blueprint_id": blueprint_id, + "version_no": int(version_no)}, + "snapshot_json", "", 1, 1, True, sor) + if not rows: + return None + return _db.loads(rows[0].get("snapshot_json"), None) + + +def rollback_version(tenant_id, blueprint_id, version_no, op_user="", sor=None): + """回滚到指定版本:用快照重建节点/边,并生成 rollback 版本。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + if bp.get("status") == "published": + raise PblBlueprintError(ERR_STATE_INVALID, "已发布蓝图不可回滚,请先撤回发布") + vn = int(version_no or 0) + if vn <= 0: + raise PblBlueprintError(ERR_PARAM_INVALID, "version_no 必须为正整数") + if vn >= int(bp.get("current_version") or 0): + raise PblBlueprintError(ERR_PARAM_INVALID, "只能回滚到更早的版本") + snap = _load_snap(tid, blueprint_id, vn, s) + if not snap: + raise PblBlueprintError(ERR_NOT_FOUND, "版本 v%s 快照不存在" % vn) + old_nodes = _cnt(T_NODE, tid, {"blueprint_id": blueprint_id}, False, s) + old_edges = _cnt(T_EDGE, tid, {"blueprint_id": blueprint_id}, False, s) + stamp = now_str() + w, a = _cond(tid, T_NODE, {"blueprint_id": blueprint_id}) + _db.q_update(s, T_NODE, {"deleted": 1, "update_time": stamp}, w, a) + w2, a2 = _cond(tid, T_EDGE, {"blueprint_id": blueprint_id}) + _db.q_update(s, T_EDGE, {"deleted": 1, "update_time": stamp}, w2, a2) + n_cnt = 0 + for n in _sort_parents_first(list(_flat_nodes(snap).values())): + if not n.get("id"): + continue + _assert_node_type(n.get("node_type")) + crud_create(T_NODE, tid, { + "id": n["id"], + "tenant_id": tid, + "blueprint_id": blueprint_id, + "node_type": n.get("node_type"), + "parent_id": n.get("parent_id", "") or "", + "code": n.get("code", ""), + "name": n.get("name", ""), + "sort_no": n.get("sort_no", 0), + "spec_json": _spec_txt(n.get("spec_json")), + "ref_module": n.get("ref_module", ""), + "ref_id": n.get("ref_id", ""), + "status": n.get("status", "active"), + "required": n.get("required", 0), + "weight": n.get("weight", 0), + }, op_user, s) + n_cnt += 1 + e_cnt = 0 + for e in _flat_edges(snap).values(): + if not e.get("id") or not e.get("from_node_id") or not e.get("to_node_id"): + continue + _assert_edge_type(e.get("edge_type", "depends")) + crud_create(T_EDGE, tid, { + "id": e["id"], + "tenant_id": tid, + "blueprint_id": blueprint_id, + "from_node_id": e["from_node_id"], + "to_node_id": e["to_node_id"], + "edge_type": e.get("edge_type", "depends"), + "weight": e.get("weight", 0), + "condition_json": _spec_txt(e.get("condition_json")), + "sort_no": e.get("sort_no", 0), + }, op_user, s) + e_cnt += 1 + res = save_version(tid, blueprint_id, "rollback", "rollback to v%s" % vn, op_user, s) + write_audit(tid, blueprint_id, "rollback", "version", "", version_no=vn, + before={"nodes": old_nodes, "edges": old_edges}, + after={"nodes": n_cnt, "edges": e_cnt}, op_user=op_user, sor=s) + return ok({"restored_nodes": n_cnt, "restored_edges": e_cnt, + "from_version": vn, "version": res.get("data")}) + + +# ------------------------------------------------------------------ 模板 + +def create_template(tenant_id, name, code="", category="general", subject="", grade="", + scope="tenant", payload=None, price_amount=0, summary="", + status="enabled", op_user="", sor=None): + """创建模板(payload 为 {blueprint,nodes,edges} 结构)。""" + tid = require_tenant(tenant_id) + nm = (name or "").strip() + if not nm: + raise PblBlueprintError(ERR_PARAM_INVALID, "name 不能为空") + _assert_in(scope, ("tenant", "platform", "private"), "scope") + _assert_in(status, ("enabled", "disabled", "draft"), "status") + s = _sor(sor) + payload = payload if isinstance(payload, dict) else _db.loads(payload, {}) or {} + nodes = payload.get("nodes") or [] + edges = payload.get("edges") or [] + for n in nodes: + _assert_node_type((n or {}).get("node_type")) + row = crud_create(T_TEMPLATE, tid, { + "id": new_id(), + "tenant_id": tid, + "code": (code or "").strip() or _next_code(tid, T_TEMPLATE, "PBL-TPL", s), + "name": nm, + "category": category or "general", + "subject": subject or "", + "grade": grade or "", + "scope": scope, + "payload_json": _db.dumps(payload), + "node_count": len(nodes), + "edge_count": len(edges), + "price_amount": float(price_amount or 0), + "use_count": 0, + "status": status, + "summary": summary or "", + }, op_user, s) + write_audit(tid, "", "create", "template", row["id"], after=row, op_user=op_user, sor=s) + return ok(row) + + +def update_template(tenant_id, template_id, data, op_user="", sor=None): + """更新模板(白名单字段)。""" + tid = require_tenant(tenant_id) + editable = {"name", "category", "subject", "grade", "scope", "payload_json", + "price_amount", "status", "summary"} + payload = {} + for k, v in (data or {}).items(): + if k not in editable: + raise PblBlueprintError(ERR_PARAM_INVALID, "字段不可编辑: %s" % k) + if k == "scope": + _assert_in(v, ("tenant", "platform", "private"), "scope") + if k == "status": + _assert_in(v, ("enabled", "disabled", "draft"), "status") + if k == "payload_json": + obj = v if isinstance(v, dict) else _db.loads(v, None) + if not isinstance(obj, dict): + raise PblBlueprintError(ERR_PARAM_INVALID, "payload_json 必须是对象") + v = _db.dumps(obj) + payload["node_count"] = len(obj.get("nodes") or []) + payload["edge_count"] = len(obj.get("edges") or []) + payload[k] = v + if not payload: + raise PblBlueprintError(ERR_PARAM_INVALID, "无待更新字段") + s = _sor(sor) + before = crud_get(T_TEMPLATE, tid, template_id, "*", s) + n = crud_update(T_TEMPLATE, tid, template_id, payload, op_user, s) + write_audit(tid, "", "update", "template", template_id, before=before, after=payload, + op_user=op_user, sor=s) + return ok({"affected": n}) + + +def delete_template(tenant_id, template_id, op_user="", sor=None): + """删除模板(逻辑删除;已被实例化过的模板禁删,保留血缘可追溯)。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + tpl = crud_get(T_TEMPLATE, tid, template_id, "*", s) + if int(tpl.get("use_count") or 0) > 0: + raise PblBlueprintError(ERR_STATE_INVALID, "模板已被实例化 %s 次,不可删除(可停用)" % tpl["use_count"]) + n = crud_delete(T_TEMPLATE, tid, template_id, op_user, s) + write_audit(tid, "", "delete", "template", template_id, before=tpl, op_user=op_user, sor=s) + return ok({"affected": n}) + + +def get_template(tenant_id, template_id, with_payload=False, sor=None): + """取模板详情;with_payload=True 时解析 payload_json。""" + tid = require_tenant(tenant_id) + fields = "*" if with_payload else ( + "id,tenant_id,code,name,category,subject,grade,scope,node_count,edge_count," + "price_amount,use_count,status,summary,create_user,create_time,update_user," + "update_time,deleted") + t = dict(crud_get(T_TEMPLATE, tid, template_id, fields, _sor(sor))) + if with_payload: + t["payload_json"] = _db.loads(t.get("payload_json"), {}) + return ok(t) + + +def list_templates(tenant_id, conds=None, page=1, page_size=20, sor=None): + """列模板:本租户 + 平台公共(tenant_id 打头,显式 or 条件,参数化)。""" + tid = require_tenant(tenant_id) + bw, ba = build_filter(T_TEMPLATE, conds or {}) + scope_sql = "(tenant_id=? or (tenant_id='' and scope='platform'))" + sql = scope_sql + " and deleted=0" + args = [tid] + if bw: + sql += " and (" + bw + ")" + args.extend(ba) + s = _sor(sor) + total = _db.q_count(s, T_TEMPLATE, sql, args) + rows = _db.q_select(s, T_TEMPLATE, sql, args, + "id,tenant_id,code,name,category,subject,grade,scope,node_count," + "edge_count,price_amount,use_count,status,summary,create_user," + "create_time,update_time,deleted", + "use_count desc,create_time desc", int(page_size or 20), + (max(1, int(page or 1)) - 1) * int(page_size or 20)) + return ok({"rows": rows or [], "total": total, "page": max(1, int(page or 1)), + "page_size": int(page_size or 20)}) + + +def instantiate_template(tenant_id, template_id, name="", owner_id="", class_id="", + op_user="", sor=None): + """模板实例化为新蓝图。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + tpl = crud_get(T_TEMPLATE, tid, template_id, "*", s) + if tpl.get("status") != "enabled": + raise PblBlueprintError(ERR_STATE_INVALID, "模板未启用(status=%s)" % tpl.get("status")) + payload = _db.loads(tpl.get("payload_json"), {}) or {} + if not payload.get("nodes"): + raise PblBlueprintError(ERR_STATE_INVALID, "模板载荷为空,无法实例化") + return _instantiate_payload(tid, payload, name or tpl.get("name", ""), owner_id, + class_id, template_id, op_user, s) + + +def instantiate_payload(tenant_id, payload, name, owner_id="", class_id="", + op_user="", sor=None): + """离线兜底:直接用 payload(自描述 JSON)实例化蓝图,不依赖模板表。""" + tid = require_tenant(tenant_id) + obj = payload if isinstance(payload, dict) else _db.loads(payload, None) + if not isinstance(obj, dict): + raise PblBlueprintError(ERR_PARAM_INVALID, "payload 必须是对象或对象 JSON") + if not obj.get("nodes"): + raise PblBlueprintError(ERR_PARAM_INVALID, "payload.nodes 不能为空(离线兜底也需最小骨架)") + return _instantiate_payload(tid, obj, name, owner_id, class_id, "", op_user, _sor(sor)) + + +def _instantiate_payload(tid, payload, name, owner_id, class_id, template_id, op_user, sor): + """实例化实现:建蓝图 + 节点(ID 重映射)+ 边 + 血缘 + 审计。""" + nm = (name or (payload.get("blueprint") or {}).get("name") or "").strip() + if not nm: + raise PblBlueprintError(ERR_PARAM_INVALID, "实例化名称不能为空") + bp_src = payload.get("blueprint") or {} + nodes = payload.get("nodes") or [] + edges = payload.get("edges") or [] + bp = crud_create(T_BLUEPRINT, tid, { + "id": new_id(), + "tenant_id": tid, + "code": _next_code(tid, T_BLUEPRINT, "PBL-BP", sor), + "name": nm, + "subject": bp_src.get("subject", ""), + "grade": bp_src.get("grade", ""), + "phase": bp_src.get("phase", ""), + "status": "draft", + "quality_status": "q0", + "source": "template" if template_id else "import", + "source_id": template_id or "", + "owner_id": owner_id or bp_src.get("owner_id", ""), + "class_id": class_id or "", + "current_version": 0, + "duration_hours": bp_src.get("duration_hours", 0), + "budget_amount": bp_src.get("budget_amount", 0), + "summary": bp_src.get("summary", ""), + "ext_json": _spec_txt(bp_src.get("ext_json")), + }, op_user, sor) + id_map, n_cnt = {}, 0 + flat = _flatten_payload_nodes(nodes) + for key, n in flat: + _assert_node_type(n.get("node_type")) + if not (n.get("name") or "").strip(): + raise PblBlueprintError(ERR_PARAM_INVALID, "payload 节点缺少 name") + nid = new_id() + id_map[key] = nid + parent_key = n.get("parent_id") or n.get("parent") or "" + crud_create(T_NODE, tid, { + "id": nid, + "tenant_id": tid, + "blueprint_id": bp["id"], + "node_type": n.get("node_type"), + "parent_id": id_map.get(parent_key, ""), + "code": n.get("code", "") or "", + "name": (n.get("name") or "").strip(), + "sort_no": int(n.get("sort_no") or 0), + "spec_json": _spec_txt(n.get("spec_json") if n.get("spec_json") is not None else n.get("spec")), + "ref_module": n.get("ref_module", "") or "", + "ref_id": n.get("ref_id", "") or "", + "status": "active", + "required": int(n.get("required") or 0), + "weight": float(n.get("weight") or 0), + }, op_user, sor) + n_cnt += 1 + e_cnt = 0 + for e in edges: + if not isinstance(e, dict): + continue + fkey = e.get("from") or e.get("from_node_id") or e.get("source") or "" + tkey = e.get("to") or e.get("to_node_id") or e.get("target") or "" + f2, t2 = id_map.get(fkey), id_map.get(tkey) + if not f2 or not t2 or f2 == t2: + continue + _assert_edge_type(e.get("edge_type", "depends")) + crud_create(T_EDGE, tid, { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": bp["id"], + "from_node_id": f2, + "to_node_id": t2, + "edge_type": e.get("edge_type", "depends"), + "weight": float(e.get("weight") or 0), + "condition_json": _spec_txt(e.get("condition_json") or e.get("condition")), + "sort_no": int(e.get("sort_no") or 0), + }, op_user, sor) + e_cnt += 1 + if template_id: + _db.q_sql(sor, "update %s set use_count=use_count+1,update_time=? where tenant_id=? and id=?" + % T_TEMPLATE, [now_str(), tid, template_id]) + crud_create(T_FORK, tid, { + "id": new_id(), + "tenant_id": tid, + "source_blueprint_id": "", + "source_version": 0, + "target_blueprint_id": bp["id"], + "fork_type": "template", + "template_id": template_id, + "node_count": n_cnt, + "edge_count": e_cnt, + "cost_amount": 0, + "remark": "instantiate from template", + }, op_user, sor) + write_audit(tid, bp["id"], "instantiate", "template", template_id or "", + after={"nodes": n_cnt, "edges": e_cnt}, op_user=op_user, sor=sor) + return ok({"blueprint": bp, "node_count": n_cnt, "edge_count": e_cnt}) + + +def _flatten_payload_nodes(nodes, parent_key="", out=None): + """把 payload 的树形/扁平节点统一摊平为 [(key, node)],key 优先 id 再 code。""" + if out is None: + out = [] + for n in nodes or []: + if not isinstance(n, dict): + continue + key = n.get("id") or n.get("code") or new_id() + item = dict(n) + if parent_key and not item.get("parent_id"): + item["parent_id"] = parent_key + out.append((key, item)) + kids = item.get("children") or item.get("nodes") + if kids: + _flatten_payload_nodes(kids, key, out) + return out + + +# ------------------------------------------------------------------ 离线兜底 + +def export_offline(tenant_id, blueprint_id, pkg_name="", op_user="", sor=None): + """导出蓝图离线包(自描述 JSON + sha256 校验和)。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + tree = get_blueprint_tree(tid, blueprint_id, sor=s)["data"] + pkg = { + "schema_version": OFFLINE_SCHEMA_VERSION, + "module": "pbl_blueprint", + "exported_at": now_str(), + "tenant_fingerprint": _sha256(tid)[:16], + "blueprint": {k: v for k, v in bp.items() if k not in ("id", "tenant_id")}, + "nodes": tree["roots"], + "edges": tree["edges"], + } + txt = _db.dumps(pkg) + row = crud_create(T_OFFLINE, tid, { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id, + "template_id": bp.get("source_id", "") if bp.get("source") == "template" else "", + "pkg_code": "OFF-%s" % bp.get("code", blueprint_id), + "pkg_name": (pkg_name or "").strip() or ("%s-离线包" % bp.get("name", "")), + "pkg_version": int(bp.get("current_version") or 0) or 1, + "pkg_json": txt, + "file_size": len(txt.encode("utf-8")), + "checksum": _sha256(txt), + "status": "ready", + }, op_user, s) + write_audit(tid, blueprint_id, "export_offline", "offline", row["id"], + after={"file_size": row["file_size"], "checksum": row["checksum"]}, + op_user=op_user, sor=s) + out = dict(row) + out.pop("pkg_json", None) + return ok(out) + + +def import_offline(tenant_id, pkg_json, name="", op_user="", sor=None): + """导入离线包实例化蓝图(校验 schema_version + checksum)。""" + tid = require_tenant(tenant_id) + pkg = pkg_json if isinstance(pkg_json, dict) else _db.loads(pkg_json, None) + if not isinstance(pkg, dict): + raise PblBlueprintError(ERR_PARAM_INVALID, "离线包 JSON 解析失败") + sv = str(pkg.get("schema_version") or "") + if not sv or sv.split(".")[0] != OFFLINE_SCHEMA_VERSION.split(".")[0]: + raise PblBlueprintError(ERR_PARAM_INVALID, "离线包 schema_version 不兼容: %s" % (sv or "空")) + if not pkg.get("nodes"): + raise PblBlueprintError(ERR_PARAM_INVALID, "离线包缺少 nodes") + ck = pkg.get("checksum") + if ck: + body = {k: v for k, v in pkg.items() if k != "checksum"} + if _sha256(_db.dumps(body)) != ck: + raise PblBlueprintError(ERR_PARAM_INVALID, "离线包校验和不匹配") + payload = {"blueprint": pkg.get("blueprint") or {}, "nodes": pkg.get("nodes"), + "edges": pkg.get("edges") or []} + nm = name or (pkg.get("blueprint") or {}).get("name") or "" + res = _instantiate_payload(tid, payload, nm, "", "", "", op_user, _sor(sor)) + bp_id = ((res.get("data") or {}).get("blueprint") or {}).get("id", "") + write_audit(tid, bp_id, "import_offline", "offline", "", + after={"schema_version": sv, "nodes": len(payload["nodes"])}, + op_user=op_user, sor=_sor(sor)) + return res + + +def get_offline(tenant_id, offline_id, with_pkg=False, sor=None): + """取离线包;with_pkg=True 时返回 pkg_json 原文。""" + tid = require_tenant(tenant_id) + fields = "*" if with_pkg else ( + "id,tenant_id,blueprint_id,template_id,pkg_code,pkg_name,pkg_version,file_size," + "checksum,status,expire_time,create_user,create_time,update_time,deleted") + row = dict(crud_get(T_OFFLINE, tid, offline_id, fields, _sor(sor))) + if with_pkg: + row["pkg"] = _db.loads(row.pop("pkg_json", ""), {}) + return ok(row) + + +def list_offline(tenant_id, conds=None, page=1, page_size=20, sor=None): + """离线包列表(不含 pkg_json 大字段)。""" + tid = require_tenant(tenant_id) + w, a = _cond(tid, T_OFFLINE, conds or {}) + return ok(crud_list(T_OFFLINE, tid, w, a, + "id,tenant_id,blueprint_id,template_id,pkg_code,pkg_name,pkg_version," + "file_size,checksum,status,expire_time,create_user,create_time," + "update_time,deleted", + "create_time desc", page, page_size, False, _sor(sor))) + + +# ------------------------------------------------------------------ 发布 + +def publish_blueprint(tenant_id, blueprint_id, version_no=0, target_type="class", + target_id="", class_id="", start_time="", end_time="", + budget_amount=0, op_user="", sor=None): + """发布蓝图到投放对象(幂等:同版本同对象重复发布返回已有记录)。""" + tid = require_tenant(tenant_id) + _assert_in(target_type, TARGET_TYPES, "target_type") + s = _sor(sor) + bp = crud_get(T_BLUEPRINT, tid, blueprint_id, "*", s) + if bp.get("quality_status") == "q1": + raise PblBlueprintError(ERR_STATE_INVALID, "质量状态不合格(q1)的蓝图不可发布") + if bp.get("status") == "archived": + raise PblBlueprintError(ERR_STATE_INVALID, "已归档蓝图不可发布") + vn = int(version_no or bp.get("current_version") or 0) + if vn <= 0: + raise PblBlueprintError(ERR_STATE_INVALID, "蓝图尚无版本,请先 save_version") + if not _load_snap(tid, blueprint_id, vn, s): + raise PblBlueprintError(ERR_NOT_FOUND, "版本 v%s 快照不存在,无法发布" % vn) + tgt = (target_id or (class_id if target_type == "class" else "") or "").strip() + exist = _rows(T_PUBLISH, tid, {"blueprint_id": blueprint_id, "version_no": vn, + "target_type": target_type, "target_id": tgt}, + "*", "", 1, 1, False, s) + if exist: + write_audit(tid, blueprint_id, "publish", "publish", exist[0]["id"], version_no=vn, + after={"idempotent": True}, op_user=op_user, sor=s) + return ok({"publish": exist[0], "idempotent": True}) + row = crud_create(T_PUBLISH, tid, { + "id": new_id(), + "tenant_id": tid, + "blueprint_id": blueprint_id, + "version_no": vn, + "class_id": class_id or (tgt if target_type == "class" else ""), + "target_type": target_type, + "target_id": tgt, + "runtime_ref": "", + "game_def_json": "", + "start_time": start_time or "", + "end_time": end_time or "", + "budget_amount": float(budget_amount or bp.get("budget_amount") or 0), + "status": "published", + }, op_user, s) + crud_update(T_BLUEPRINT, tid, blueprint_id, + {"status": "published", "publish_time": now_str()}, op_user, s) + write_audit(tid, blueprint_id, "publish", "publish", row["id"], version_no=vn, + after=row, op_user=op_user, sor=s) + return ok({"publish": row, "idempotent": False}) + + +def revoke_publish(tenant_id, publish_id, op_user="", sor=None): + """撤回发布(已结束的投放不可撤回)。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + p = crud_get(T_PUBLISH, tid, publish_id, "*", s) + if p.get("status") == "finished": + raise PblBlueprintError(ERR_STATE_INVALID, "已结束的投放不可撤回") + if p.get("status") == "revoked": + return ok({"affected": 0, "idempotent": True}) + n = crud_update(T_PUBLISH, tid, publish_id, {"status": "revoked"}, op_user, s) + remain = _cnt(T_PUBLISH, tid, {"blueprint_id": p["blueprint_id"], + "status": "published"}, False, s) + if not remain: + crud_update(T_BLUEPRINT, tid, p["blueprint_id"], {"status": "ready"}, op_user, s) + write_audit(tid, p["blueprint_id"], "revoke", "publish", publish_id, + before=p, op_user=op_user, sor=s) + return ok({"affected": n, "idempotent": False}) + + +def list_publishes(tenant_id, conds=None, page=1, page_size=20, sor=None): + """发布记录列表(不含 game_def_json 大字段)。""" + tid = require_tenant(tenant_id) + w, a = _cond(tid, T_PUBLISH, conds or {}) + return ok(crud_list(T_PUBLISH, tid, w, a, + "id,tenant_id,blueprint_id,version_no,class_id,target_type,target_id," + "runtime_ref,start_time,end_time,budget_amount,status,remark," + "create_user,create_time,update_time,deleted", + "create_time desc", page, page_size, False, _sor(sor))) + + +# ------------------------------------------------------------------ 编辑锁 + +def lock_blueprint(tenant_id, blueprint_id, holder_id, node_id="", lock_type="edit", + holder_name="", ttl_seconds=600, sor=None): + """加编辑锁:他人持有且未过期 -> ERR_LOCKED;过期/已释放锁可抢占(rev+1)。""" + tid = require_tenant(tenant_id) + if not (holder_id or "").strip(): + raise PblBlueprintError(ERR_PARAM_INVALID, "holder_id 必填") + _assert_in(lock_type, LOCK_TYPES, "lock_type") + ttl = int(ttl_seconds or 600) + if ttl <= 0 or ttl > 86400: + raise PblBlueprintError(ERR_PARAM_INVALID, "ttl_seconds 非法(1~86400)") + s = _sor(sor) + crud_get(T_BLUEPRINT, tid, blueprint_id, "id", s) + exp = (_dt.datetime.now() + _dt.timedelta(seconds=ttl)).strftime("%Y-%m-%d %H:%M:%S") + now = now_str() + rows = _rows(T_LOCK, tid, {"blueprint_id": blueprint_id, "node_id": node_id or "", + "lock_type": lock_type}, "*", "", 1, 1, True, s) + if rows: + r = rows[0] + alive = r.get("status") == "holding" and (r.get("expire_time") or "") > now + if alive and r.get("holder_id") != holder_id: + write_audit(tid, blueprint_id, "lock", "lock", r["id"], result="denied", + err_code=ERR_LOCKED, op_user=holder_id, sor=s) + raise PblBlueprintError(ERR_LOCKED, "蓝图被 %s 锁定至 %s" + % (r.get("holder_name") or r.get("holder_id"), + r.get("expire_time"))) + rev = int(r.get("rev") or 0) + 1 + w, a = tenant_where(tid, "id=?", [r["id"]]) + _db.q_update(s, T_LOCK, {"holder_id": holder_id, "holder_name": holder_name or "", + "rev": rev, "expire_time": exp, "status": "holding", + "update_time": now}, w, a) + write_audit(tid, blueprint_id, "lock", "lock", r["id"], after={"rev": rev}, + op_user=holder_id, sor=s) + return ok({"lock_id": r["id"], "rev": rev, "expire_time": exp, "reused": True}) + row = crud_create(T_LOCK, tid, { + "id": new_id(), "tenant_id": tid, "blueprint_id": blueprint_id, + "node_id": node_id or "", "lock_type": lock_type, "holder_id": holder_id, + "holder_name": holder_name or "", "rev": 1, "expire_time": exp, "status": "holding", + }, holder_id, s) + write_audit(tid, blueprint_id, "lock", "lock", row["id"], after=row, + op_user=holder_id, sor=s) + return ok({"lock_id": row["id"], "rev": 1, "expire_time": exp, "reused": False}) + + +def unlock_blueprint(tenant_id, blueprint_id, holder_id, node_id="", lock_type="edit", sor=None): + """释放锁(仅持锁人可释放;锁已过期视为已释放)。""" + tid = require_tenant(tenant_id) + s = _sor(sor) + rows = _rows(T_LOCK, tid, {"blueprint_id": blueprint_id, "node_id": node_id or "", + "lock_type": lock_type}, "*", "", 1, 1, True, s) + if not rows: + return ok({"released": False, "reason": "no lock"}) + r = rows[0] + if r.get("status") != "holding": + return ok({"released": False, "reason": r.get("status"), "lock_id": r["id"]}) + if r.get("holder_id") != holder_id: + write_audit(tid, blueprint_id, "unlock", "lock", r["id"], result="denied", + err_code=ERR_FORBIDDEN, op_user=holder_id, sor=s) + raise PblBlueprintError(ERR_FORBIDDEN, "非持锁人不可释放") + w, a = tenant_where(tid, "id=?", [r["id"]]) + _db.q_update(s, T_LOCK, {"status": "released", "update_time": now_str()}, w, a) + write_audit(tid, blueprint_id, "unlock", "lock", r["id"], before=r, + op_user=holder_id, sor=s) + return ok({"released": True, "lock_id": r["id"]}) + + +def list_locks(tenant_id, conds=None, page=1, page_size=50, sor=None): + """锁列表(tenant_id 打头)。""" + tid = require_tenant(tenant_id) + w, a = _cond(tid, T_LOCK, conds or {}) + return ok(crud_list(T_LOCK, tid, w, a, "*", "create_time desc", page, page_size, + True, _sor(sor))) + + +def clean_expired_locks(tenant_id, sor=None): + """清理过期锁(holding 且 expire_time < now -> expired)。""" + tid = require_tenant(tenant_id) + now = now_str() + sql = ("update %s set status='expired',update_time=? " + "where tenant_id=? and status='holding' and expire_time is not null and expire_time MAX_TENANT_LEN: + raise PblBlueprintError(ERR_TENANT_MISSING, "tenant_id 超长(>%d)" % MAX_TENANT_LEN) + if "*" in tid or "%" in tid: + raise PblBlueprintError(ERR_TENANT_MISSING, "tenant_id 含通配符,拒绝") + if "'" in tid or ";" in tid or "--" in tid: + raise PblBlueprintError(ERR_TENANT_MISSING, "tenant_id 含非法字符,拒绝") + if ctx_tenant_id is not None: + ctx = _clean(ctx_tenant_id) + if ctx and ctx != tid: + raise PblBlueprintError(ERR_FORBIDDEN, "tenant_id 与会话上下文不一致") + return tid + + +def tenant_where(tenant_id, extra_sql="", extra_args=None): + """生成 tenant_id 打头的 WHERE 片段。 + + 返回 (TenantWhere, args),片段一定以 `tenant_id=?` 开头,杜绝漏租户过滤。 + """ + tid = require_tenant(tenant_id) + sql = "tenant_id=?" + args = [tid] + if extra_sql: + sql = sql + " and (" + extra_sql + ")" + if extra_args: + args.extend(list(extra_args)) + return TenantWhere(sql, args), args + + +def inject_tenant(tenant_id, data, overwrite=True): + """把 tenant_id 注入待写入 dict 的首位(保持字段顺序:tenant_id 打头)。""" + tid = require_tenant(tenant_id) + src = dict(data or {}) + if not overwrite and src.get("tenant_id") and _clean(src.get("tenant_id")) != tid: + raise PblBlueprintError(ERR_FORBIDDEN, "data.tenant_id 与上下文不一致") + out = {"tenant_id": tid} + for k, v in src.items(): + if k == "tenant_id": + continue + out[k] = v + return out + + +def assert_tenant_first(fields): + """校验字段序列中 tenant_id 是首个业务字段(紧跟主键 id 之后)。""" + seq = [str(f) for f in (fields or [])] + if not seq: + return False + if seq[0] == "id": + return len(seq) > 1 and seq[1] == "tenant_id" + return seq[0] == "tenant_id" diff --git a/pyproject.toml b/pyproject.toml index 525506e..390d566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,27 +5,16 @@ build-backend = "setuptools.build_meta" [project] name = "pbl_blueprint" version = "1.0.0" -description = "PBL 蓝图聚合根与子对象、版本、模板模块(M1a)——蓝图 CRUD/树/fork、7 类子对象泛化契约、版本 change_delta、模板实例化与离线兜底;tenant_id 强制打头" +description = "PBL 蓝图聚合根与子对象、版本、模板(M1a)——蓝图 CRUD/树/fork、7 类子对象泛化契约、版本 change_delta、模板实例化与离线兜底" readme = "README.md" requires-python = ">=3.8" license = { text = "Proprietary" } -authors = [{ name = "sdlc agent.develop" }] -keywords = ["pbl", "blueprint", "sage", "module"] +authors = [{ name = "agent.develop" }] dependencies = [] -[project.optional-dependencies] -dev = ["pytest>=7.0"] - [tool.setuptools] packages = ["pbl_blueprint"] include-package-data = true [tool.setuptools.package-data] -pbl_blueprint = [ - "json/*.json", - "models/*.json", - "sql/*.sql", - "wwwroot/*.ui", - "init/*.json", - "skill/*.md", -] +pbl_blueprint = ["models/*.json", "json/*.json", "skill/*.md"] diff --git a/scripts/gen_defs.py b/scripts/gen_defs.py new file mode 100644 index 0000000..9d7f916 --- /dev/null +++ b/scripts/gen_defs.py @@ -0,0 +1,773 @@ +# -*- coding: utf-8 -*- +"""pbl_blueprint 表定义/CRUD定义生成器(M1a)。 + +单一事实源:本文件的 TABLES 声明 -> 生成 + pbl_blueprint/models/{tbl}.json 四段式表定义(summary/fields/indexes/codes) + pbl_blueprint/json/{tbl}.json CRUD 定义(tblname/params/editable/browserfields) + +用法(机构工作空间根目录): + python3 modules/pbl_blueprint/scripts/gen_defs.py # 生成 + 清理陈旧定义 + python3 modules/pbl_blueprint/scripts/gen_defs.py --clean # 仅清理非 11 表的陈旧文件 + python3 modules/pbl_blueprint/scripts/gen_defs.py --dump-ddl # 打印建表 DDL + +约定(QC 逐条核验点): + * primary 恒为 ["id"];id 为 str(32) notnull + * tenant_id 为首业务字段(紧跟 id),str(32) notnull + * 金额/分值字段一律 double(18,2) + * 所有索引 tenant_id 打头 + * CRUD 定义根键仅 tblname/params/editable/browserfields,editable 为 3 个 api/*.dspy +""" + +import io +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MODULE_ROOT = os.path.dirname(HERE) +PKG = os.path.join(MODULE_ROOT, "pbl_blueprint") +MODEL_DIR = os.path.join(PKG, "models") +JSON_DIR = os.path.join(PKG, "json") + +TABLE_ORDER = [ + "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", +] + +AUDIT_FIELDS = ("create_user", "create_time", "update_user", "update_time") +IMMUTABLE_FIELDS = AUDIT_FIELDS + ("deleted",) +# 服务端写入的大字段:CRUD 定义里 editable=false(前端只读) +SERVER_BLOB_FIELDS = ("snapshot_json", "pkg_json", "before_json", "after_json", + "game_def_json", "checksum", "file_size", "use_count", + "node_count", "edge_count", "current_version", "rev", + "publish_time", "expire_time", "cost_ms", "err_code") + +QUALITY_CODE = { + "summary": "质量状态(5 级,M2 校验引擎回写)", + "items": { + "q0": "未校验", + "q1": "不合格", + "q2": "基本合格", + "q3": "合格", + "q4": "优秀", + "q5": "标杆", + }, +} + + +def F(name, typ, size=None, nn=False, default=None, summary=""): + """字段声明。""" + spec = {"type": typ} + if size is not None: + spec["size"] = size + if nn: + spec["notnull"] = True + if default is not None: + spec["default"] = default + spec["summary"] = summary + return (name, spec) + + +def ID(): + return F("id", "str", 32, True, None, "主键ID(str32,UUID去横线)") + + +def TENANT(extra=""): + return F("tenant_id", "str", 32, True, None, + "租户ID(首业务字段,所有读写强制打头,缺失即 fail-closed 拒绝)" + extra) + + +def DELETED(): + return F("deleted", "int", None, True, 0, "逻辑删除标记 0正常 1删除") + + +def IDX(name, fields, unique, summary): + return (name, {"fields": list(fields), "unique": bool(unique), "summary": summary}) + + +# ------------------------------------------------------------------ 11 表定义 +TABLES = {} + +TABLES["pbl_blueprint"] = { + "summary": "PBL 蓝图聚合根表:一个租户下的一份项目式学习蓝图(学科/年级/学段/状态/质量状态/当前版本/预算)。所有读写 tenant_id 强制打头。", + "fields": [ + ID(), TENANT(), + F("code", "str", 64, True, None, "蓝图编码(租户内唯一,PBL-BP-序号)"), + F("name", "str", 128, True, None, "蓝图名称"), + F("subject", "str", 64, False, "", "学科"), + F("grade", "str", 32, False, "", "年级"), + F("phase", "str", 32, False, "", "学段"), + F("status", "str", 16, True, "draft", "蓝图状态"), + F("quality_status", "str", 16, True, "q0", "质量状态(5 级)"), + F("source", "str", 16, True, "manual", "来源方式"), + F("source_id", "str", 32, False, "", "来源对象ID(模板ID或父蓝图ID)"), + F("owner_id", "str", 32, False, "", "负责人(教师)ID"), + F("class_id", "str", 32, False, "", "关联班级ID"), + F("current_version", "int", None, True, 0, "当前版本号(save_version 回写)"), + F("duration_hours", "int", None, True, 0, "预计课时数"), + F("budget_amount", "double", [18, 2], True, 0, "预算金额(double(18,2))"), + F("summary", "str", 512, False, "", "蓝图简介"), + F("ext_json", "text", None, False, "", "扩展属性JSON"), + F("publish_time", "datetime", None, False, None, "最近发布时间"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + F("update_user", "str", 32, False, "", "更新人"), + F("update_time", "datetime", None, False, None, "更新时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_blueprint_code", ["tenant_id", "code"], True, "租户内蓝图编码唯一"), + IDX("idx_pbl_blueprint_tenant", ["tenant_id", "deleted", "status"], False, "租户蓝图列表主索引(tenant_id 打头)"), + IDX("idx_pbl_blueprint_owner", ["tenant_id", "owner_id"], False, "按负责人查蓝图"), + IDX("idx_pbl_blueprint_source", ["tenant_id", "source", "source_id"], False, "按来源(模板/fork)追溯"), + IDX("idx_pbl_blueprint_class", ["tenant_id", "class_id"], False, "按班级查蓝图"), + ], + "codes": { + "status": { + "summary": "蓝图状态", + "items": { + "draft": "草稿", + "validating": "校验中", + "ready": "就绪", + "published": "已发布", + "archived": "已归档", + "disabled": "已停用", + }, + }, + "quality_status": QUALITY_CODE, + "source": { + "summary": "来源方式", + "items": { + "manual": "手工创建", + "template": "模板实例化", + "fork": "蓝图fork派生", + "agent": "Designer Agent 生成", + "import": "离线包导入", + }, + }, + }, + "browserfields": ["code", "name", "subject", "status", "quality_status", "owner_id", "update_time"], + "queryfields": ["code", "name", "subject", "grade", "phase", "status", "quality_status", + "source_id", "owner_id", "class_id"], +} + +TABLES["pbl_blueprint_node"] = { + "summary": "蓝图子对象节点表(7 类子对象泛化契约):role/task/rule/artifact/scene/assessment/resource 统一存一张表,差异载荷放 spec_json,按 node_type 分派校验器(M2)。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, None, "所属蓝图ID"), + F("node_type", "str", 16, True, None, "子对象类型(7 类泛化)"), + F("parent_id", "str", 32, True, "", "父节点ID(构成蓝图树,空串=根节点)"), + F("code", "str", 64, True, "", "节点编码(蓝图内同类型唯一)"), + F("name", "str", 128, True, None, "节点名称"), + F("sort_no", "int", None, True, 0, "同级排序号"), + F("spec_json", "text", None, False, "", "泛化载荷JSON(结构随 node_type 不同)"), + F("ref_module", "str", 32, True, "", "引用模块名(scense/world/assessment 等)"), + F("ref_id", "str", 32, True, "", "引用对象ID"), + F("status", "str", 16, True, "active", "节点状态"), + F("required", "int", None, True, 0, "是否必需节点 0否 1是"), + F("weight", "double", [18, 2], True, 0, "权重/分值(double(18,2))"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + F("update_user", "str", 32, False, "", "更新人"), + F("update_time", "datetime", None, False, None, "更新时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_node_code", ["tenant_id", "blueprint_id", "node_type", "code"], True, "蓝图内同类型节点编码唯一"), + IDX("idx_pbl_node_bp", ["tenant_id", "blueprint_id", "deleted", "node_type"], False, "按蓝图+类型列节点(tenant_id 打头)"), + IDX("idx_pbl_node_parent", ["tenant_id", "blueprint_id", "parent_id", "sort_no"], False, "构树查询"), + IDX("idx_pbl_node_ref", ["tenant_id", "ref_module", "ref_id"], False, "按引用对象反查节点"), + ], + "codes": { + "node_type": { + "summary": "子对象类型(7 类泛化契约)", + "items": { + "role": "角色", + "task": "任务", + "rule": "规则", + "artifact": "产出物", + "scene": "场景引用", + "assessment": "评估项", + "resource": "资源", + }, + }, + "status": { + "summary": "节点状态", + "items": { + "active": "生效", + "draft": "草稿", + "invalid": "校验不通过", + "disabled": "停用", + }, + }, + }, + "browserfields": ["code", "name", "node_type", "parent_id", "sort_no", "status"], + "queryfields": ["blueprint_id", "node_type", "parent_id", "code", "name", + "ref_module", "ref_id", "status"], +} + +TABLES["pbl_blueprint_edge"] = { + "summary": "蓝图节点关系边表:描述子对象之间的依赖/解锁/产出/消耗/评估/包含关系,供 Compiler(M3)编译为 Game Definition 图结构。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, None, "所属蓝图ID"), + F("from_node_id", "str", 32, True, None, "起点节点ID"), + F("to_node_id", "str", 32, True, None, "终点节点ID"), + F("edge_type", "str", 16, True, "depends", "边类型"), + F("weight", "double", [18, 2], True, 0, "边权重(double(18,2))"), + F("condition_json", "text", None, False, "", "触发条件JSON"), + F("sort_no", "int", None, True, 0, "排序号"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + F("update_time", "datetime", None, False, None, "更新时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_edge", ["tenant_id", "blueprint_id", "from_node_id", "to_node_id", "edge_type"], True, "同类型边唯一,防重复连边"), + IDX("idx_pbl_edge_bp", ["tenant_id", "blueprint_id", "deleted"], False, "按蓝图取全部边"), + IDX("idx_pbl_edge_from", ["tenant_id", "from_node_id"], False, "出边查询"), + IDX("idx_pbl_edge_to", ["tenant_id", "to_node_id"], False, "入边查询(删节点前校验)"), + ], + "codes": { + "edge_type": { + "summary": "边类型", + "items": { + "depends": "依赖", + "unlocks": "解锁", + "produces": "产出", + "consumes": "消耗", + "assesses": "评估", + "contains": "包含", + }, + }, + }, + "browserfields": ["from_node_id", "to_node_id", "edge_type", "weight", "sort_no"], + "queryfields": ["blueprint_id", "from_node_id", "to_node_id", "edge_type"], +} + +TABLES["pbl_blueprint_version"] = { + "summary": "蓝图版本表:每次 save_version 生成一个版本快照(snapshot_json 存整棵树),支持版本回溯、change_delta 对比与回滚。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, None, "所属蓝图ID"), + F("version_no", "int", None, True, None, "版本号(蓝图内递增)"), + F("version_type", "str", 16, True, "draft", "版本类型"), + F("change_summary", "str", 512, True, "", "变更说明"), + F("snapshot_json", "text", None, False, "", "蓝图整树快照JSON(主表字段+节点+边)"), + F("node_count", "int", None, True, 0, "快照节点数"), + F("edge_count", "int", None, True, 0, "快照边数"), + F("quality_status", "str", 16, True, "q0", "该版本质量状态"), + F("score_amount", "double", [18, 2], True, 0, "该版本评估得分(double(18,2),M6 回写)"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_version", ["tenant_id", "blueprint_id", "version_no"], True, "蓝图内版本号唯一"), + IDX("idx_pbl_version_bp", ["tenant_id", "blueprint_id", "deleted", "version_no"], False, "版本列表(倒序取最新)"), + ], + "codes": { + "version_type": { + "summary": "版本类型", + "items": { + "draft": "草稿版本", + "minor": "小版本", + "major": "大版本", + "publish": "发布版本", + "rollback": "回滚版本", + }, + }, + "quality_status": QUALITY_CODE, + }, + "browserfields": ["version_no", "version_type", "change_summary", "node_count", + "edge_count", "quality_status", "create_time"], + "queryfields": ["blueprint_id", "version_no", "version_type", "quality_status"], +} + +TABLES["pbl_blueprint_version_delta"] = { + "summary": "蓝图版本差异表:记录两个版本之间的 change_delta(新增/修改/删除的节点、边与主表字段),供版本对比与回滚审计。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, None, "所属蓝图ID"), + F("from_version", "int", None, True, 0, "基准版本号"), + F("to_version", "int", None, True, 0, "目标版本号"), + F("delta_type", "str", 16, True, None, "差异对象类型"), + F("op_type", "str", 16, True, None, "操作类型"), + F("target_id", "str", 32, True, "", "差异对象ID(节点ID/边ID/蓝图ID)"), + F("target_name", "str", 128, True, "", "差异对象名称(冗余便于展示)"), + F("before_json", "text", None, False, "", "变更前JSON"), + F("after_json", "text", None, False, "", "变更后JSON"), + F("sort_no", "int", None, True, 0, "排序号"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_delta", ["tenant_id", "blueprint_id", "from_version", "to_version", + "delta_type", "op_type", "target_id"], True, + "同一版本对同一对象同一操作唯一,保证幂等写入"), + IDX("idx_pbl_delta_bp", ["tenant_id", "blueprint_id", "deleted", "to_version"], False, "按蓝图+版本查差异"), + ], + "codes": { + "delta_type": { + "summary": "差异对象类型", + "items": {"blueprint": "蓝图主表字段", "node": "节点", "edge": "边"}, + }, + "op_type": { + "summary": "操作类型", + "items": {"add": "新增", "update": "修改", "delete": "删除"}, + }, + }, + "browserfields": ["from_version", "to_version", "delta_type", "op_type", "target_name"], + "queryfields": ["blueprint_id", "from_version", "to_version", "delta_type", + "op_type", "target_id"], +} + +TABLES["pbl_blueprint_template"] = { + "summary": "蓝图模板表:可复用的蓝图骨架(payload_json 存模板树),支持实例化为新蓝图;离线兜底时作为本地模板源。", + "fields": [ + ID(), TENANT(";空串=平台公共模板"), + F("code", "str", 64, True, None, "模板编码(租户内唯一)"), + F("name", "str", 128, True, None, "模板名称"), + F("category", "str", 32, True, "general", "模板分类"), + F("subject", "str", 64, True, "", "适用学科"), + F("grade", "str", 32, True, "", "适用年级"), + F("scope", "str", 16, True, "tenant", "可见范围"), + F("payload_json", "text", None, False, "", "模板树载荷JSON(blueprint+nodes+edges)"), + F("node_count", "int", None, True, 0, "模板节点数"), + F("edge_count", "int", None, True, 0, "模板边数"), + F("price_amount", "double", [18, 2], True, 0, "模板定价金额(double(18,2),0=免费)"), + F("use_count", "int", None, True, 0, "被实例化次数"), + F("status", "str", 16, True, "enabled", "模板状态"), + F("summary", "str", 512, True, "", "模板说明"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + F("update_user", "str", 32, False, "", "更新人"), + F("update_time", "datetime", None, False, None, "更新时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_template_code", ["tenant_id", "code"], True, "租户内模板编码唯一"), + IDX("idx_pbl_template_list", ["tenant_id", "deleted", "status", "category"], False, "模板列表查询(tenant_id 打头)"), + IDX("idx_pbl_template_subject", ["tenant_id", "subject", "grade"], False, "按学科年级筛模板"), + ], + "codes": { + "scope": { + "summary": "可见范围", + "items": {"tenant": "本租户", "platform": "平台公共", "private": "仅创建人"}, + }, + "status": { + "summary": "模板状态", + "items": {"enabled": "启用", "disabled": "停用", "draft": "草稿"}, + }, + "category": { + "summary": "模板分类", + "items": { + "stem": "STEM", + "science": "自然科学", + "humanity": "人文社科", + "art": "艺术", + "labor": "劳动实践", + "general": "通用", + }, + }, + }, + "browserfields": ["code", "name", "category", "subject", "scope", "status", "use_count"], + "queryfields": ["code", "name", "category", "subject", "grade", "scope", "status"], +} + +TABLES["pbl_blueprint_publish"] = { + "summary": "蓝图发布/投放记录表:蓝图某版本投放到班级/团队/学生的一次发布,记录运行时句柄与编译产物,供 M11 runtime_ext 关联。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, None, "蓝图ID"), + F("version_no", "int", None, True, 0, "发布的版本号"), + F("class_id", "str", 32, True, "", "投放班级ID"), + F("target_type", "str", 16, True, "class", "投放对象类型"), + F("target_id", "str", 32, True, "", "投放对象ID"), + F("runtime_ref", "str", 64, True, "", "运行时引用(scense_runtime 会话/世界ID)"), + F("game_def_json", "text", None, False, "", "编译产物 Game Definition JSON(M3 回写)"), + F("start_time", "datetime", None, False, None, "开始时间"), + F("end_time", "datetime", None, False, None, "结束时间"), + F("budget_amount", "double", [18, 2], True, 0, "本次投放预算金额(double(18,2))"), + F("status", "str", 16, True, "published", "投放状态"), + F("remark", "str", 512, True, "", "备注"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + F("update_time", "datetime", None, False, None, "更新时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_publish", ["tenant_id", "blueprint_id", "version_no", "target_type", "target_id"], True, "同版本同对象只投放一次,保证幂等"), + IDX("idx_pbl_publish_bp", ["tenant_id", "blueprint_id", "deleted"], False, "按蓝图查投放记录"), + IDX("idx_pbl_publish_class", ["tenant_id", "class_id", "status"], False, "按班级查在跑的投放"), + ], + "codes": { + "target_type": { + "summary": "投放对象类型", + "items": {"class": "班级", "team": "团队", "student": "学生", "preview": "预览"}, + }, + "status": { + "summary": "投放状态", + "items": { + "published": "已发布", + "running": "进行中", + "finished": "已结束", + "revoked": "已撤回", + }, + }, + }, + "browserfields": ["blueprint_id", "version_no", "target_type", "target_id", "status", + "start_time", "end_time"], + "queryfields": ["blueprint_id", "version_no", "class_id", "target_type", "target_id", + "runtime_ref", "status"], +} + +TABLES["pbl_blueprint_offline"] = { + "summary": "蓝图离线兜底包表:把蓝图/模板打包为自描述 JSON 包(含 schema_version 与 sha256 校验和),网络或服务不可用时本地兜底实例化。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, "", "来源蓝图ID(与 template_id 二选一)"), + F("template_id", "str", 32, True, "", "来源模板ID"), + F("pkg_code", "str", 64, True, None, "离线包编码"), + F("pkg_name", "str", 128, True, None, "离线包名称"), + F("pkg_version", "int", None, True, 1, "离线包版本"), + F("pkg_json", "text", None, False, "", "离线包内容JSON(自描述:schema_version+蓝图+节点+边)"), + F("file_size", "int", None, True, 0, "包体字节数"), + F("checksum", "str", 64, True, "", "内容校验和(sha256)"), + F("status", "str", 16, True, "ready", "离线包状态"), + F("expire_time", "datetime", None, False, None, "过期时间"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + F("update_time", "datetime", None, False, None, "更新时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_offline_code", ["tenant_id", "pkg_code", "pkg_version"], True, "离线包编码+版本唯一"), + IDX("idx_pbl_offline_bp", ["tenant_id", "blueprint_id", "deleted"], False, "按蓝图查离线包"), + IDX("idx_pbl_offline_status", ["tenant_id", "status", "expire_time"], False, "过期包清理扫描"), + ], + "codes": { + "status": { + "summary": "离线包状态", + "items": { + "ready": "可用", + "building": "构建中", + "expired": "已过期", + "invalid": "校验失败", + }, + }, + }, + "browserfields": ["pkg_code", "pkg_name", "pkg_version", "status", "file_size", "create_time"], + "queryfields": ["blueprint_id", "template_id", "pkg_code", "pkg_name", "pkg_version", "status"], +} + +TABLES["pbl_blueprint_fork"] = { + "summary": "蓝图 fork 血缘表:记录蓝图之间的派生关系(源蓝图→新蓝图)与模板实例化来源,支持 fork 溯源与派生树查询。", + "fields": [ + ID(), TENANT(), + F("source_blueprint_id", "str", 32, True, "", "源蓝图ID(模板实例化时为空串)"), + F("source_version", "int", None, True, 0, "fork 时的源版本号"), + F("target_blueprint_id", "str", 32, True, None, "派生出的新蓝图ID"), + F("fork_type", "str", 16, True, "copy", "fork 方式"), + F("template_id", "str", 32, True, "", "若由模板实例化,记录模板ID"), + F("node_count", "int", None, True, 0, "复制的节点数"), + F("edge_count", "int", None, True, 0, "复制的边数"), + F("cost_amount", "double", [18, 2], True, 0, "本次 fork 产生的费用金额(double(18,2),付费模板)"), + F("remark", "str", 512, True, "", "备注"), + F("create_user", "str", 32, False, "", "创建人"), + F("create_time", "datetime", None, False, None, "创建时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_fork", ["tenant_id", "source_blueprint_id", "target_blueprint_id"], True, "同一对蓝图血缘唯一,保证幂等"), + IDX("idx_pbl_fork_source", ["tenant_id", "source_blueprint_id", "deleted"], False, "查某蓝图的派生列表"), + IDX("idx_pbl_fork_target", ["tenant_id", "target_blueprint_id"], False, "反查某蓝图的来源"), + IDX("idx_pbl_fork_template", ["tenant_id", "template_id"], False, "查模板被实例化记录"), + ], + "codes": { + "fork_type": { + "summary": "fork 方式", + "items": { + "copy": "完整复制", + "structure": "仅结构(不带载荷)", + "template": "模板实例化", + "branch": "分支派生", + }, + }, + }, + "browserfields": ["source_blueprint_id", "source_version", "target_blueprint_id", + "fork_type", "node_count", "create_time"], + "queryfields": ["source_blueprint_id", "target_blueprint_id", "fork_type", "template_id"], +} + +TABLES["pbl_blueprint_lock"] = { + "summary": "蓝图编辑锁表:多人协作时对蓝图/节点加编辑锁(悲观锁+rev 乐观修订号),锁带过期时间,过期可被抢占。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, None, "被锁蓝图ID"), + F("node_id", "str", 32, True, "", "被锁节点ID(空串=整蓝图锁)"), + F("lock_type", "str", 16, True, "edit", "锁类型"), + F("holder_id", "str", 32, True, None, "持锁人ID"), + F("holder_name", "str", 64, True, "", "持锁人名称"), + F("rev", "int", None, True, 0, "乐观锁修订号(每次抢占/续锁 +1)"), + F("expire_time", "datetime", None, False, None, "锁过期时间"), + F("status", "str", 16, True, "holding", "锁状态"), + F("create_time", "datetime", None, False, None, "加锁时间"), + F("update_time", "datetime", None, False, None, "续锁时间"), + DELETED(), + ], + "indexes": [ + IDX("uk_pbl_lock", ["tenant_id", "blueprint_id", "node_id", "lock_type"], True, "同对象同类型仅一把锁记录"), + IDX("idx_pbl_lock_holder", ["tenant_id", "holder_id", "status"], False, "查某人持有的锁"), + IDX("idx_pbl_lock_expire", ["tenant_id", "status", "expire_time"], False, "过期锁清理扫描"), + ], + "codes": { + "lock_type": { + "summary": "锁类型", + "items": {"edit": "编辑锁", "publish": "发布锁", "compile": "编译锁"}, + }, + "status": { + "summary": "锁状态", + "items": {"holding": "持有中", "released": "已释放", "expired": "已过期"}, + }, + }, + "browserfields": ["blueprint_id", "node_id", "lock_type", "holder_name", "status", "expire_time"], + "queryfields": ["blueprint_id", "node_id", "lock_type", "holder_id", "status"], +} + +TABLES["pbl_blueprint_audit"] = { + "summary": "蓝图操作审计表(append-only):记录蓝图/节点/边/版本/模板/发布/锁的关键写操作与结果,只增不改不删,供追溯与合规审计。", + "fields": [ + ID(), TENANT(), + F("blueprint_id", "str", 32, True, "", "蓝图ID"), + F("target_type", "str", 16, True, None, "操作对象类型"), + F("target_id", "str", 32, True, "", "操作对象ID"), + F("action", "str", 32, True, None, "操作动作"), + F("version_no", "int", None, True, 0, "关联版本号"), + F("before_json", "text", None, False, "", "操作前数据JSON"), + F("after_json", "text", None, False, "", "操作后数据JSON"), + F("result", "str", 16, True, "success", "操作结果"), + F("err_code", "str", 32, True, "", "失败错误码"), + F("cost_ms", "int", None, True, 0, "耗时毫秒"), + F("op_user", "str", 32, True, "", "操作人"), + F("op_source", "str", 32, True, "api", "操作来源(web/api/agent/offline)"), + F("client_ip", "str", 64, True, "", "客户端IP"), + F("create_time", "datetime", None, False, None, "操作时间"), + ], + "indexes": [ + IDX("idx_pbl_audit_bp", ["tenant_id", "blueprint_id", "create_time"], False, "按蓝图时间线查审计"), + IDX("idx_pbl_audit_target", ["tenant_id", "target_type", "target_id"], False, "按对象查审计"), + IDX("idx_pbl_audit_user", ["tenant_id", "op_user", "create_time"], False, "按操作人查审计"), + IDX("idx_pbl_audit_action", ["tenant_id", "action", "result"], False, "按动作与结果统计"), + ], + "codes": { + "target_type": { + "summary": "操作对象类型", + "items": { + "blueprint": "蓝图", + "node": "节点", + "edge": "边", + "version": "版本", + "delta": "版本差异", + "template": "模板", + "publish": "发布", + "offline": "离线包", + "fork": "血缘", + "lock": "编辑锁", + }, + }, + "action": { + "summary": "操作动作", + "items": { + "create": "创建", + "update": "更新", + "delete": "删除", + "fork": "fork派生", + "save_version": "存版本", + "rollback": "回滚", + "publish": "发布", + "revoke": "撤回发布", + "instantiate": "模板实例化", + "validate": "校验", + "compile": "编译", + "lock": "加锁", + "unlock": "解锁", + "export_offline": "导出离线包", + "import_offline": "导入离线包", + }, + }, + "result": { + "summary": "操作结果", + "items": {"success": "成功", "fail": "失败", "denied": "被拒绝"}, + }, + }, + "browserfields": ["blueprint_id", "target_type", "action", "result", "op_user", + "op_source", "create_time"], + "queryfields": ["blueprint_id", "target_type", "target_id", "action", "version_no", + "result", "op_user", "op_source", "create_time"], +} + + +# ------------------------------------------------------------------ 生成 +def build_model(tbl): + d = TABLES[tbl] + fields = {} + for name, spec in d["fields"]: + fields[name] = spec + return { + "summary": d["summary"], + "primary": ["id"], + "fields": fields, + "indexes": dict(d["indexes"]), + "codes": d["codes"], + } + + +def build_crud(tbl): + d = TABLES[tbl] + codes = d.get("codes") or {} + params = {} + for name, spec in d["fields"]: + p = {"type": spec["type"]} + if "size" in spec: + p["size"] = spec["size"] + if spec.get("notnull"): + p["notnull"] = True + if "default" in spec: + p["default"] = spec["default"] + if name in IMMUTABLE_FIELDS or name in SERVER_BLOB_FIELDS: + p["editable"] = False + else: + p["editable"] = True + p["query"] = name in (d.get("queryfields") or ()) or name == "tenant_id" + if name in codes: + p["code"] = name + p["summary"] = spec["summary"] + params[name] = p + return { + "tblname": tbl, + "params": params, + "editable": [ + "api/%s_list.dspy" % tbl, + "api/%s_edit.dspy" % tbl, + "api/%s_view.dspy" % tbl, + ], + "browserfields": list(d.get("browserfields") or []), + } + + +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 ddl(): + out = ["-- pbl_blueprint M1a DDL(%d 表,由 scripts/gen_defs.py 生成)" % len(TABLE_ORDER), ""] + for tbl in TABLE_ORDER: + m = build_model(tbl) + cols = [] + for name, spec in m["fields"].items(): + nn = " not null" if spec.get("notnull") else "" + df = "" + if "default" in spec: + dv = spec["default"] + df = (" default '%s'" % dv) if isinstance(dv, str) else (" default %s" % dv) + cm = " comment '%s'" % str(spec.get("summary") or "").replace("'", "") + cols.append(" `%s` %s%s%s%s" % (name, col_type(spec), nn, df, cm)) + cols.append(" primary key (`%s`)" % "` , `".join(m["primary"])) + for iname, ispec in m["indexes"].items(): + uq = "unique " if ispec.get("unique") else "" + cols.append(" %skey `%s` (%s)" % (uq, iname, + ", ".join(["`%s`" % c for c in ispec["fields"]]))) + out.append("-- %s" % m["summary"]) + out.append("create table if not exists `%s` (" % tbl) + out.append(",\n".join(cols)) + out.append(") engine=InnoDB default charset=utf8mb4 comment='%s';" % tbl.replace("'", "")) + out.append("") + return "\n".join(out) + + +def clean_stale(): + """删除 models/ 与 json/ 下不属于 11 表清单的陈旧定义文件。""" + removed = [] + for d in (MODEL_DIR, JSON_DIR): + if not os.path.isdir(d): + continue + for fn in sorted(os.listdir(d)): + if not fn.endswith(".json"): + continue + if fn[:-5] not in TABLE_ORDER: + p = os.path.join(d, fn) + os.remove(p) + removed.append(os.path.relpath(p, MODULE_ROOT)) + return removed + + +def write_json(path, obj): + d = os.path.dirname(path) + if not os.path.isdir(d): + os.makedirs(d) + with io.open(path, "w", encoding="utf-8") as f: + f.write(json.dumps(obj, ensure_ascii=False, indent=2) + "\n") + + +def generate(): + removed = clean_stale() + written = [] + for tbl in TABLE_ORDER: + p1 = os.path.join(MODEL_DIR, "%s.json" % tbl) + write_json(p1, build_model(tbl)) + written.append(os.path.relpath(p1, MODULE_ROOT)) + p2 = os.path.join(JSON_DIR, "%s.json" % tbl) + write_json(p2, build_crud(tbl)) + written.append(os.path.relpath(p2, MODULE_ROOT)) + return removed, written + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if "--dump-ddl" in argv: + print(ddl()) + return 0 + if "--clean" in argv: + rm = clean_stale() + print("已清理陈旧定义 %d 个: %s" % (len(rm), rm)) + return 0 + rm, wr = generate() + print("清理陈旧定义 %d 个: %s" % (len(rm), rm)) + print("生成定义文件 %d 个(models 11 + json 11):" % len(wr)) + for w in wr: + print(" %s" % w) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/load_path.py b/scripts/load_path.py index 39f5340..ee54617 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,132 +1,179 @@ -"""pbl_blueprint 模块 RBAC 路径注册脚本。 +# -*- coding: utf-8 -*- +"""pbl_blueprint RBAC 路径注册清单(显式枚举,无 %/* 通配符)。 -用途:把本模块全部新 API 路径 + 页面路径注册到平台 RBAC(权限路径表), -使 owner.admin / 租户管理员可授权,未注册路径一律 403。 +本文件是 RBAC 路径的唯一声明源:init.py 的 RBAC_PATHS 必须与此处 PATHS 完全一致, +selfcheck.py 会逐条比对(不一致即退出码非 0)。 -执行方式(应用部署后一次性 / 幂等可重复执行): - python -m pbl_blueprint.scripts.load_path -或在应用 init() 后由部署脚本调用 load_paths(env)。 +用法: + python3 modules/pbl_blueprint/scripts/load_path.py # 打印清单 + python3 modules/pbl_blueprint/scripts/load_path.py --check # 与 init.py 比对 """ import os import sys -MODULE_NAME = 'pbl_blueprint' +MODULE_NAME = "pbl_blueprint" -# ---- 本模块全部新 API 路径(与 api.py 的 API_PATHS 保持一致,单一事实源)---- -API_PATHS = [ - ('/pbl_blueprint/blueprint/create', 'POST', '新建蓝图'), - ('/pbl_blueprint/blueprint/update', 'POST', '更新蓝图'), - ('/pbl_blueprint/blueprint/delete', 'POST', '删除蓝图'), - ('/pbl_blueprint/blueprint/get', 'GET', '蓝图详情'), - ('/pbl_blueprint/blueprint/list', 'GET', '蓝图列表'), - ('/pbl_blueprint/blueprint/tree', 'GET', '蓝图子对象树'), - ('/pbl_blueprint/blueprint/fork', 'POST', '复制蓝图'), - ('/pbl_blueprint/version/save', 'POST', '保存版本快照'), - ('/pbl_blueprint/version/list', 'GET', '版本列表'), - ('/pbl_blueprint/version/diff', 'GET', '版本对比'), - ('/pbl_blueprint/version/rollback', 'POST', '版本回滚'), - ('/pbl_blueprint/subobject/create', 'POST', '新建子对象'), - ('/pbl_blueprint/subobject/update', 'POST', '更新子对象'), - ('/pbl_blueprint/subobject/delete', 'POST', '删除子对象'), - ('/pbl_blueprint/subobject/get', 'GET', '子对象详情'), - ('/pbl_blueprint/subobject/list', 'GET', '子对象列表'), - ('/pbl_blueprint/subobject/batch_upsert', 'POST', '批量写子对象'), - ('/pbl_blueprint/subobject/types', 'GET', '子对象类型枚举'), - ('/pbl_blueprint/template/create', 'POST', '新建模板'), - ('/pbl_blueprint/template/update', 'POST', '更新模板'), - ('/pbl_blueprint/template/delete', 'POST', '删除模板'), - ('/pbl_blueprint/template/get', 'GET', '模板详情'), - ('/pbl_blueprint/template/list', 'GET', '模板列表'), - ('/pbl_blueprint/template/instantiate', 'POST', '模板实例化为蓝图'), - ('/pbl_blueprint/template/offline_fallback', 'POST', '离线兜底生成蓝图'), -] +# 蓝图主表 +PATHS_BLUEPRINT = ( + "/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", +) -# ---- 页面路径(wwwroot/*.ui 挂载点)---- -PAGE_PATHS = [ - ('/pbl_blueprint/index', 'GET', 'PBL蓝图管理主页'), - ('/pbl_blueprint/blueprint/edit', 'GET', '蓝图编辑页'), - ('/pbl_blueprint/template/list', 'GET', '蓝图模板库页'), -] +# 子对象节点 +PATHS_NODE = ( + "/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", +) -# ---- 表级数据权限路径(CRUD 定义 json/*.json 对应)---- -TABLE_PATHS = [ - ('/pbl_blueprint/tbl/pbl_blueprint', 'CRUD', '蓝图主表数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_version', 'CRUD', '蓝图版本表数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_template', 'CRUD', '蓝图模板表数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_template_item', 'CRUD', '蓝图模板条目表数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_task', 'CRUD', '子对象-任务数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_mission', 'CRUD', '子对象-关卡数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_role', 'CRUD', '子对象-角色数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_learning_goal', 'CRUD', '子对象-学习目标数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_evidence_spec', 'CRUD', '子对象-证据规格数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_artifact_spec', 'CRUD', '子对象-产出物规格数据权限'), - ('/pbl_blueprint/tbl/pbl_blueprint_reflection_spec', 'CRUD', '子对象-反思规格数据权限'), -] +# 关系边 +PATHS_EDGE = ( + "/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", +) -ALL_PATHS = API_PATHS + PAGE_PATHS + TABLE_PATHS +# 版本与差异 +PATHS_VERSION = ( + "/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", +) + +# 模板 +PATHS_TEMPLATE = ( + "/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", +) + +# 发布 +PATHS_PUBLISH = ( + "/api/pbl_blueprint_publish/list.dspy", + "/api/pbl_blueprint_publish/create.dspy", + "/api/pbl_blueprint_publish/get.dspy", + "/api/pbl_blueprint_publish/revoke.dspy", +) + +# 离线包 +PATHS_OFFLINE = ( + "/api/pbl_blueprint_offline/list.dspy", + "/api/pbl_blueprint_offline/export.dspy", + "/api/pbl_blueprint_offline/import.dspy", + "/api/pbl_blueprint_offline/get.dspy", +) + +# 血缘 +PATHS_FORK = ( + "/api/pbl_blueprint_fork/list.dspy", + "/api/pbl_blueprint_fork/get.dspy", +) + +# 编辑锁 +PATHS_LOCK = ( + "/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", +) + +# 审计(只读) +PATHS_AUDIT = ( + "/api/pbl_blueprint_audit/list.dspy", + "/api/pbl_blueprint_audit/get.dspy", +) + +PATHS = ( + PATHS_BLUEPRINT + PATHS_NODE + PATHS_EDGE + PATHS_VERSION + PATHS_TEMPLATE + + PATHS_PUBLISH + PATHS_OFFLINE + PATHS_FORK + PATHS_LOCK + PATHS_AUDIT +) + +# 只读路径(RBAC 上归为 read 权限组) +READONLY_PATHS = tuple(p for p in PATHS if p.rsplit("/", 1)[-1].split(".")[0] in + ("list", "get", "tree", "forks", "stats")) +WRITE_PATHS = tuple(p for p in PATHS if p not in READONLY_PATHS) -def load_paths(env=None): - """把 ALL_PATHS 注册进 RBAC(幂等:存在则更新描述,不存在则新增)。 - - :return: {'registered': n, 'skipped': m, 'failed': k} - """ - result = {'registered': 0, 'skipped': 0, 'failed': 0, 'paths': []} - if env is None: - try: - from sage import ServerEnv - env = ServerEnv() - except Exception: - env = None - - rbac = None - if env is not None: - for attr in ('rbac', 'RBAC'): - rbac = getattr(env, attr, None) - if rbac is not None: - break - if rbac is None: - getter = getattr(env, 'get_rbac', None) - if callable(getter): - try: - rbac = getter() - except Exception: - rbac = None - - for path, method, caption in ALL_PATHS: - item = {'path': path, 'method': method, 'caption': caption, 'module': MODULE_NAME} - result['paths'].append(item) - if rbac is None: - result['skipped'] += 1 +def validate(paths=None): + """校验路径合法性:非空、以 / 开头、.dspy 结尾、无通配符、无重复。返回错误列表。""" + ps = list(paths if paths is not None else PATHS) + errs = [] + seen = set() + for p in ps: + if not p or not isinstance(p, str): + errs.append("空路径或非字符串: %r" % (p,)) continue - try: - registered = False - for fn_name in ('register_path', 'add_path', 'load_path', 'register'): - fn = getattr(rbac, fn_name, None) - if callable(fn): - try: - fn(path, method, caption) - except TypeError: - fn(item) - registered = True - break - if registered: - result['registered'] += 1 - else: - result['skipped'] += 1 - except Exception: - result['failed'] += 1 - return result + if not p.startswith("/api/"): + errs.append("路径必须以 /api/ 开头: %s" % p) + if not p.endswith(".dspy"): + errs.append("路径必须以 .dspy 结尾: %s" % p) + if "%" in p or "*" in p or "?" in p: + errs.append("路径含通配符(禁止): %s" % p) + if " " in p: + errs.append("路径含空格: %s" % p) + if p in seen: + errs.append("路径重复: %s" % p) + seen.add(p) + return errs -def main(): - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - res = load_paths() - print('[pbl_blueprint] load_path done: registered=%d skipped=%d failed=%d total=%d' % ( - res['registered'], res['skipped'], res['failed'], len(res['paths']))) +def check_against_init(): + """与 pbl_blueprint/init.py 的 RBAC_PATHS 逐条比对,返回 (ok, diff_msg)。""" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if root not in sys.path: + sys.path.insert(0, root) + try: + from pbl_blueprint import init as _init # noqa + except Exception as e: + return False, "无法导入 pbl_blueprint.init: %s" % e + a = list(PATHS) + b = list(getattr(_init, "RBAC_PATHS", ())) + if a == b: + return True, "RBAC 路径一致(%d 条,顺序相同)" % len(a) + only_a = [x for x in a if x not in b] + only_b = [x for x in b if x not in a] + return False, "RBAC 路径不一致: 仅 load_path=%s / 仅 init=%s / 顺序差异=%s" % ( + only_a, only_b, a != b and not only_a and not only_b) + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if "--check" in argv: + errs = validate() + good, msg = check_against_init() + for e in errs: + print("[PATH-ERR] %s" % e) + print("[CHECK] %s" % msg) + print("[STATS] total=%d readonly=%d write=%d" % (len(PATHS), len(READONLY_PATHS), len(WRITE_PATHS))) + return 0 if (good and not errs) else 1 + print("# %s RBAC 路径清单(显式枚举,共 %d 条,无通配符)" % (MODULE_NAME, len(PATHS))) + for p in PATHS: + print(p) + errs = validate() + if errs: + for e in errs: + print("[PATH-ERR] %s" % e, file=sys.stderr) + return 1 return 0 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/selfcheck.py b/scripts/selfcheck.py index d7fec46..af5becd 100644 --- a/scripts/selfcheck.py +++ b/scripts/selfcheck.py @@ -1,90 +1,936 @@ -#!/usr/bin/env python3 -"""规范机械自检:目录结构 / models 四段式 / CRUD 根键 / 三处同步 / 无硬编码库名 / i18n / SQL 方言。""" +# -*- coding: utf-8 -*- +"""pbl_blueprint M1a 自查脚本(可在机构工作空间根目录复跑)。 + +用法: + python3 modules/pbl_blueprint/scripts/selfcheck.py # 全量自查 + python3 modules/pbl_blueprint/scripts/selfcheck.py --report PATH # 同时写报告 + +退出码:0=全部通过;1=存在失败项。 + +检查项(机械可验,全部基于真实落盘文件): + A 目录结构:包目录/__init__.py/init.py/pyproject.toml/scripts/load_path.py/skill/SKILL.md + B 表定义:11 表 models/*.json 四段式(summary/fields/indexes/codes)、primary=["id"]、 + id str32、tenant_id 首业务字段、金额 double(18,2)、索引 tenant_id 打头 + C CRUD 定义:11 个 json/*.json 根键 tblname+params、editable 三个 .dspy、browserfields 非空 + D 代码质量:py_compile 全量、禁硬编码库名、sqlor 白名单、三处注册同步 + E RBAC:load_path.PATHS 与 init.RBAC_PATHS 一致、显式枚举无通配符 + F 契约运行:内存假 sqlor 跑通 蓝图CRUD/树/fork/版本delta/模板实例化/离线兜底/发布/锁 + G 租户 fail-closed:tenant_id 缺失/空/通配符/越权一律拒绝 +""" + +import datetime +import io import json import os +import py_compile import re import sys +import traceback -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -PKG = os.path.join(ROOT, 'pbl_blueprint') -sys.path.insert(0, ROOT) -FAIL = [] +HERE = os.path.dirname(os.path.abspath(__file__)) +MODULE_ROOT = os.path.dirname(HERE) +PKG_DIR = os.path.join(MODULE_ROOT, "pbl_blueprint") +MODEL_DIR = os.path.join(PKG_DIR, "models") +JSON_DIR = os.path.join(PKG_DIR, "json") + +if MODULE_ROOT not in sys.path: + sys.path.insert(0, MODULE_ROOT) + +RESULTS = [] -def chk(cond, msg): - print(('PASS ' if cond else 'FAIL ') + msg) - if not cond: - FAIL.append(msg) +def check(group, name, ok, msg=""): + RESULTS.append({"group": group, "name": name, "ok": bool(ok), "msg": msg or ""}) + return bool(ok) -# 1 目录结构 -for must in ['pyproject.toml', 'scripts/load_path.py', 'skill/SKILL.md', 'tests', - os.path.join('pbl_blueprint', '__init__.py'), os.path.join('pbl_blueprint', 'init.py'), - os.path.join('pbl_blueprint', 'models'), os.path.join('pbl_blueprint', 'json'), - os.path.join('pbl_blueprint', 'wwwroot'), os.path.join('pbl_blueprint', 'i18n')]: - chk(os.path.exists(os.path.join(ROOT, must)), '目录结构存在 %s' % must) -for stray in ['models.py', 'db.py', 'blueprint_crud.py', 'subobjects.py', '__init__.py']: - chk(not os.path.exists(os.path.join(ROOT, stray)), '仓库根无游离包文件 %s' % stray) +EXPECT_TABLES = [ + "pbl_blueprint", + "pbl_blueprint_node", + "pbl_blueprint_edge", + "pbl_blueprint_version", + "pbl_blueprint_version_delta", + "pbl_blueprint_template", + "pbl_blueprint_publish", + "pbl_blueprint_offline", + "pbl_blueprint_fork", + "pbl_blueprint_lock", + "pbl_blueprint_audit", +] -# 2 models 四段式 + tenant 打头 -from pbl_blueprint import models as M -for t in M.table_names(): - chk(M.validate_model(t) == [], 'models/%s.json 四段式自检: %s' % (t, M.validate_model(t) or 'ok')) +MONEY_FIELDS = ("budget_amount", "score_amount", "price_amount", "cost_amount", "weight") -# 3 CRUD json 根键 -for f in sorted(os.listdir(os.path.join(PKG, 'json'))): - d = json.load(open(os.path.join(PKG, 'json', f), encoding='utf-8')) - chk('tblname' in d and 'params' in d and 'table' not in d and 'list' not in d, - 'CRUD %s 根键 tblname+params' % f) - chk(d.get('tblname') in M.table_names(), 'CRUD %s tblname 有对应 models 定义' % f) - for key in ('browserfields', 'editexclouded', 'edit_exclouded_fields'): - for fld in (d['params'].get('browserfields', {}).get('alters', {}) if key == 'browserfields' - else d['params'].get(key, []) if not isinstance(d['params'].get(key), dict) else []): - chk(fld in M.fields_of(d['tblname']), 'CRUD %s 字段 %s 存在于表定义' % (f, fld)) -# 4 三处同步 -import pbl_blueprint as P -from pbl_blueprint import init as I -impl = set() -for mod in ['blueprint_crud', 'subobjects', 'templates']: - m = __import__('pbl_blueprint.' + mod, fromlist=['x']) - impl |= {n for n in dir(m) if re.match(r'^(blueprint|subobject|template)_\w+$', n) and callable(getattr(m, n))} -exported = {n for n in dir(P) if re.match(r'^(blueprint|subobject|template)_\w+$', n)} -src = open(os.path.join(PKG, 'init.py'), encoding='utf-8').read() -registered = set(re.findall(r'env\.(\w+)\s*=', src)) | {n for n in I.CONTRACT_FUNCS.__iter__()} if False else set(re.findall(r'env\.(\w+)\s*=', src)) | {f.__name__ for f in I.CONTRACT_FUNCS} -chk(impl == exported, '①定义==②导出: 差集 %s' % (impl ^ exported)) -chk(impl <= registered, '①定义⊆③init注册: 缺 %s' % (impl - registered)) +# ------------------------------------------------------------------ A 目录结构 -# 5 无硬编码 DB 名 -for dirpath, _, names in os.walk(PKG): - for n in names: - if n.endswith('.py'): - text = open(os.path.join(dirpath, n), encoding='utf-8').read() - chk(not re.search(r"(?i)^\s*DBNAME\s*=", text, re.M), '%s 无硬编码 DBNAME' % n) - chk('get_module_dbname' in text or 'db.py' == n or n not in ('blueprint_crud.py', 'subobjects.py', 'templates.py', '__init__.py'), - '%s 库名走 get_module_dbname 链路' % n) +def check_structure(): + g = "A.目录结构" + must_files = [ + os.path.join(PKG_DIR, "__init__.py"), + os.path.join(PKG_DIR, "init.py"), + os.path.join(PKG_DIR, "errors.py"), + os.path.join(PKG_DIR, "tenant.py"), + os.path.join(PKG_DIR, "db.py"), + os.path.join(PKG_DIR, "crud.py"), + os.path.join(PKG_DIR, "audit.py"), + os.path.join(PKG_DIR, "service.py"), + os.path.join(MODULE_ROOT, "pyproject.toml"), + os.path.join(MODULE_ROOT, "README.md"), + os.path.join(HERE, "load_path.py"), + os.path.join(MODULE_ROOT, "skill", "SKILL.md"), + ] + for f in must_files: + rel = os.path.relpath(f, MODULE_ROOT) + check(g, "存在 %s" % rel, os.path.isfile(f), "" if os.path.isfile(f) else "文件缺失") + check(g, "包目录名=模块名 pbl_blueprint/", os.path.isdir(PKG_DIR)) + check(g, "models/ 目录存在", os.path.isdir(MODEL_DIR)) + check(g, "json/ 目录存在", os.path.isdir(JSON_DIR)) + bad = [x for x in ("module.json", "conf", "rp.json", "app.py", "Dockerfile") + if os.path.exists(os.path.join(MODULE_ROOT, x))] + check(g, "无违禁替代文件(module.json/conf/rp.json/app.py/Dockerfile)", not bad, str(bad)) -# 6 i18n 覆盖接口 message 键 -i18n = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.zh_CN.json'), encoding='utf-8')) -keys = set() -for mod in ['blueprint_crud', 'subobjects', 'templates']: - text = open(os.path.join(PKG, mod + '.py'), encoding='utf-8').read() - keys |= set(re.findall(r"_err\('([\w.]+)'", text)) -chk(keys <= set(i18n), 'i18n 覆盖全部 message 键, 缺 %s' % (keys - set(i18n))) -en = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.en_US.json'), encoding='utf-8')) -chk(set(en) == set(i18n), 'zh_CN/en_US 键一致') -# 7 SQL 方言禁项 + engine 声明 -sql = open(os.path.join(PKG, 'sql', 'pbl_blueprint.core.sql'), encoding='utf-8').read() -chk(not re.search(r'(?i)\b(bigserial|serial|nextval)\b', sql), 'SQL 无 BIGSERIAL/SERIAL/nextval') -chk(re.search(r'(?m)^--\s*engine:', sql) is not None, 'SQL 声明 engine') -chk(not re.search(r'\bboolean\b|\bint\(11\)\b', sql), 'SQL 无 boolean/int(11)') +# ------------------------------------------------------------------ B 表定义 -# 8 dspy 无 import/f-string/print/uuid(复用 audit) -sys.path.insert(0, os.path.join(ROOT, 'scripts')) -import audit_dspy -files, problems = audit_dspy.audit() -chk(not problems, 'dspy 审计零命中(%d 文件): %s' % (len(files), problems[:3])) +def check_models(): + g = "B.表定义" + files = sorted([f for f in os.listdir(MODEL_DIR) if f.endswith(".json")]) \ + if os.path.isdir(MODEL_DIR) else [] + names = [f[:-5] for f in files] + check(g, "models 表数量=11", len(names) == 11, "实际 %d: %s" % (len(names), names)) + missing = [t for t in EXPECT_TABLES if t not in names] + extra = [t for t in names if t not in EXPECT_TABLES] + check(g, "11 表清单与预期一致", not missing and not extra, + "缺失=%s 多余=%s" % (missing, extra)) + for fn in files: + tbl = fn[:-5] + path = os.path.join(MODEL_DIR, fn) + try: + with io.open(path, encoding="utf-8") as f: + m = json.load(f) + except Exception as e: + check(g, "%s JSON 可解析" % tbl, False, str(e)) + continue + seg = [k for k in ("summary", "fields", "indexes", "codes") if k in m] + check(g, "%s 四段式齐备(summary/fields/indexes/codes)" % tbl, len(seg) == 4, + "缺 %s" % [k for k in ("summary", "fields", "indexes", "codes") if k not in m]) + check(g, "%s summary 非空" % tbl, bool(str(m.get("summary") or "").strip())) + check(g, '%s primary==["id"]' % tbl, m.get("primary") == ["id"], str(m.get("primary"))) + flds = m.get("fields") or {} + order = list(flds.keys()) + idf = flds.get("id") or {} + check(g, "%s id 为 str(32) notnull" % tbl, + idf.get("type") == "str" and int(idf.get("size") or 0) == 32 and idf.get("notnull"), + str(idf)) + check(g, "%s tenant_id 为首业务字段(id 之后第 1 个)" % tbl, + len(order) >= 2 and order[0] == "id" and order[1] == "tenant_id", str(order[:3])) + tf = flds.get("tenant_id") or {} + check(g, "%s tenant_id str(32) notnull" % tbl, + tf.get("type") == "str" and int(tf.get("size") or 0) == 32 and tf.get("notnull"), + str(tf)) + for name, spec in flds.items(): + if name in MONEY_FIELDS and spec.get("type") == "double": + sz = spec.get("size") + good = isinstance(sz, list) and len(sz) == 2 and sz == [18, 2] + check(g, "%s.%s 金额 double(18,2)" % (tbl, name), good, str(sz)) + check(g, "%s.%s 有 summary" % (tbl, name), bool(str(spec.get("summary") or "").strip())) + idxs = m.get("indexes") or {} + check(g, "%s indexes 非空" % tbl, len(idxs) > 0, "0 个索引") + for iname, ispec in idxs.items(): + fs = ispec.get("fields") or [] + check(g, "%s.%s 索引 tenant_id 打头" % (tbl, iname), + bool(fs) and fs[0] == "tenant_id", str(fs)) + check(g, "%s.%s 有 unique 标记" % (tbl, iname), "unique" in ispec) + check(g, "%s.%s 有 summary" % (tbl, iname), bool(str(ispec.get("summary") or "").strip())) + for c in fs: + check(g, "%s.%s 索引列 %s 已定义" % (tbl, iname, c), c in flds) + codes = m.get("codes") or {} + for cname, cspec in codes.items(): + items = cspec.get("items") or {} + check(g, "%s.codes.%s 有 items 且非空" % (tbl, cname), len(items) > 0) + check(g, "%s.codes.%s 有 summary" % (tbl, cname), + bool(str(cspec.get("summary") or "").strip())) + if cname in flds: + check(g, "%s.codes.%s 对应字段存在" % (tbl, cname), cname in flds) -print('SELFCHECK_%s (%d fails)' % ('PASS' if not FAIL else 'FAIL', len(FAIL))) -sys.exit(1 if FAIL else 0) + +# ------------------------------------------------------------------ C CRUD 定义 + +def check_crud_json(): + g = "C.CRUD定义" + files = sorted([f for f in os.listdir(JSON_DIR) if f.endswith(".json")]) \ + if os.path.isdir(JSON_DIR) else [] + names = [f[:-5] for f in files] + check(g, "json/ CRUD 定义数量=11", len(names) == 11, "实际 %d" % len(names)) + missing = [t for t in EXPECT_TABLES if t not in names] + check(g, "json/ 覆盖全部 11 表", not missing, "缺失=%s" % missing) + for fn in files: + tbl = fn[:-5] + path = os.path.join(JSON_DIR, fn) + try: + with io.open(path, encoding="utf-8") as f: + d = json.load(f) + except Exception as e: + check(g, "%s CRUD JSON 可解析" % tbl, False, str(e)) + continue + keys = set(d.keys()) + check(g, "%s 根键含 tblname+params" % tbl, "tblname" in keys and "params" in keys, + str(sorted(keys))) + check(g, "%s 无自创 table/list 根键" % tbl, + "table" not in keys and "list" not in keys, str(sorted(keys))) + check(g, "%s tblname 与文件名一致" % tbl, d.get("tblname") == tbl, str(d.get("tblname"))) + ed = d.get("editable") + ok_ed = isinstance(ed, list) and len(ed) == 3 and all( + isinstance(x, str) and x.startswith("api/") and x.endswith(".dspy") for x in ed) + check(g, "%s editable 为 3 个 api/*.dspy" % tbl, ok_ed, str(ed)) + bf = d.get("browserfields") + check(g, "%s browserfields 非空列表" % tbl, + isinstance(bf, list) and len(bf) > 0, str(bf)) + params = d.get("params") or {} + check(g, "%s params 非空" % tbl, len(params) > 0, "0 字段") + porder = list(params.keys()) + check(g, "%s params tenant_id 在 id 之后首位" % tbl, + len(porder) >= 2 and porder[0] == "id" and porder[1] == "tenant_id", + str(porder[:3])) + check(g, "%s params.tenant_id editable=false" % tbl, + (params.get("tenant_id") or {}).get("editable") is False, + str(params.get("tenant_id"))) + check(g, "%s params.id editable=false" % tbl, + (params.get("id") or {}).get("editable") is False, str(params.get("id"))) + # 与 models 字段一致性 + mpath = os.path.join(MODEL_DIR, "%s.json" % tbl) + if os.path.isfile(mpath): + with io.open(mpath, encoding="utf-8") as f: + mm = json.load(f) + mfields = set((mm.get("fields") or {}).keys()) + diff1 = [k for k in params if k not in mfields] + diff2 = [k for k in mfields if k not in params] + check(g, "%s params 与 models.fields 字段集一致" % tbl, + not diff1 and not diff2, "仅CRUD=%s 仅models=%s" % (diff1, diff2)) + for bf_name in (bf or []): + check(g, "%s browserfields.%s 在 params 中" % (tbl, bf_name), bf_name in params) + + +# ------------------------------------------------------------------ D 代码质量 + +def check_code_quality(): + g = "D.代码质量" + pys = [] + for root, dirs, fs in os.walk(MODULE_ROOT): + dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", ".venv")] + for f in fs: + if f.endswith(".py"): + pys.append(os.path.join(root, f)) + check(g, "Python 文件数>0", len(pys) > 0, str(len(pys))) + fails = [] + for p in pys: + try: + py_compile.compile(p, doraise=True, cfile=os.devnull) + except Exception as e: + fails.append("%s: %s" % (os.path.relpath(p, MODULE_ROOT), e)) + check(g, "py_compile 全量编译通过(%d 文件)" % len(pys), not fails, "; ".join(fails)) + + hard = [] + sqlor_bad = [] + allowed_sqlor = {"C", "U", "D", "R", "I", "sqlExe"} + pat_hard = re.compile(r"(^|[^A-Za-z0-9_])(DBNAME\s*=|DB_NAME\s*=|dbname\s*=\s*['\"][^'\"]+['\"])") + pat_sor = re.compile(r"\bsor\.([A-Za-z_][A-Za-z0-9_]*)\s*\(") + pat_sor2 = re.compile(r"\b_sor\w*\.([A-Za-z_][A-Za-z0-9_]*)\s*\(") + for p in pys: + rel = os.path.relpath(p, MODULE_ROOT) + if rel.startswith("scripts") or rel.startswith("tests"): + continue + txt = io.open(p, encoding="utf-8").read() + for i, line in enumerate(txt.splitlines(), 1): + st = line.strip() + if st.startswith("#"): + continue + if pat_hard.search(line): + hard.append("%s:%d %s" % (rel, i, st[:80])) + for m in list(pat_sor.finditer(line)) + list(pat_sor2.finditer(line)): + if m.group(1) not in allowed_sqlor: + sqlor_bad.append("%s:%d sor.%s" % (rel, i, m.group(1))) + check(g, "无硬编码库名(DBNAME=/DB_NAME=/dbname='…')", not hard, "; ".join(hard[:5])) + check(g, "sqlor 仅用白名单 C/U/D/R/I/sqlExe", not sqlor_bad, "; ".join(sqlor_bad[:5])) + + # 三处注册同步 + try: + import pbl_blueprint as pkg + from pbl_blueprint import init as init_mod + contract = list(getattr(pkg, "CONTRACT_FUNCTIONS", ())) + reg = list(getattr(init_mod, "REGISTER_FUNCTIONS", ())) + fns = pkg.get_contract_functions() + check(g, "__init__.CONTRACT_FUNCTIONS 非空", len(contract) > 0, str(len(contract))) + check(g, "init.REGISTER_FUNCTIONS 与 CONTRACT_FUNCTIONS 一致", + sorted(contract) == sorted(reg), + "仅__init__=%s 仅init=%s" % ([x for x in contract if x not in reg], + [x for x in reg if x not in contract])) + undef = [n for n in contract if not callable(fns.get(n))] + check(g, "契约函数均已定义且可调用", not undef, str(undef)) + first_bad = [] + import inspect + for n in contract: + fn = fns.get(n) + if not callable(fn): + continue + try: + args = list(inspect.getargspec(fn).args) if hasattr(inspect, "getargspec") \ + else list(inspect.signature(fn).parameters.keys()) + except Exception: + args = [] + if not args or args[0] != "tenant_id": + first_bad.append("%s(%s)" % (n, args[:1])) + check(g, "契约函数首参一律 tenant_id", not first_bad, "; ".join(first_bad[:6])) + tables = list(getattr(init_mod, "REGISTER_TABLES", ())) + check(g, "init.REGISTER_TABLES=11 且与 models 落盘一致", + sorted(tables) == sorted(EXPECT_TABLES) and + sorted(tables) == sorted([f[:-5] for f in os.listdir(MODEL_DIR) if f.endswith(".json")]), + str(tables)) + check(g, "pkg.TABLES 与 init.REGISTER_TABLES 一致", + sorted(getattr(pkg, "TABLES", ())) == sorted(tables)) + except Exception as e: + check(g, "三处注册同步可校验", False, "%s\n%s" % (e, traceback.format_exc()[-500:])) + + +# ------------------------------------------------------------------ E RBAC + +def check_rbac(): + g = "E.RBAC路径" + try: + sys.path.insert(0, HERE) + import load_path as lp + from pbl_blueprint import init as init_mod + errs = lp.validate() + check(g, "load_path.PATHS 合法(无通配符/无重复/.dspy)", not errs, "; ".join(errs[:5])) + wild = [p for p in lp.PATHS if "%" in p or "*" in p or "?" in p] + check(g, "load_path 无 %/*/? 通配符", not wild, str(wild)) + same = list(lp.PATHS) == list(init_mod.RBAC_PATHS) + check(g, "load_path.PATHS 与 init.RBAC_PATHS 完全一致(含顺序)", same, + "仅load_path=%s 仅init=%s" % ([x for x in lp.PATHS if x not in init_mod.RBAC_PATHS], + [x for x in init_mod.RBAC_PATHS if x not in lp.PATHS])) + wild2 = [p for p in init_mod.RBAC_PATHS if "%" in p or "*" in p] + check(g, "init.RBAC_PATHS 无通配符", not wild2, str(wild2)) + check(g, "RBAC 路径数>0", len(lp.PATHS) > 0, str(len(lp.PATHS))) + cov = set() + for t in EXPECT_TABLES: + for p in lp.PATHS: + if p.startswith("/api/%s/" % t): + cov.add(t) + check(g, "11 表均有 RBAC 路径覆盖", len(cov) == 11, "未覆盖=%s" % sorted(set(EXPECT_TABLES) - cov)) + except Exception as e: + check(g, "RBAC 校验可执行", False, "%s\n%s" % (e, traceback.format_exc()[-400:])) + + +# ------------------------------------------------------------------ F 假 sqlor + +class FakeSor(object): + """内存假 sqlor:只实现白名单 C/U/D/R/I/sqlExe,用于契约级跑通验证。""" + + def __init__(self, dbname="fake_db"): + self.dbname = dbname + self.tables = {} + self.calls = [] + + def _t(self, tbl): + return self.tables.setdefault(tbl, []) + + def C(self, tbl, data): + self.calls.append(("C", tbl)) + rows = self._t(tbl) + row = dict(data) + pk = row.get("id") + for r in rows: + if pk is not None and r.get("id") == pk: + raise Exception("Duplicate entry for PRIMARY") + rows.append(row) + return 1 + + def U(self, tbl, data, where, args=None): + self.calls.append(("U", tbl)) + n = 0 + for r in self._t(tbl): + if _match(r, where, args): + r.update(data) + n += 1 + return n + + def D(self, tbl, where, args=None): + self.calls.append(("D", tbl)) + rows = self._t(tbl) + keep = [r for r in rows if not _match(r, where, args)] + n = len(rows) - len(keep) + self.tables[tbl] = keep + return n + + def R(self, tbl, where, args=None, fields="*", order="", limit=0, offset=0): + self.calls.append(("R", tbl)) + out = [dict(r) for r in self._t(tbl) if _match(r, where, args)] + if order: + for part in reversed([x.strip() for x in order.split(",") if x.strip()]): + desc = part.endswith(" desc") + col = part.replace(" desc", "").replace(" asc", "").strip() + out.sort(key=lambda r: (r.get(col) is None, r.get(col)), reverse=desc) + if offset: + out = out[offset:] + if limit: + out = out[:limit] + if fields and fields != "*": + cols = [c.strip() for c in fields.split(",") if c.strip()] + out = [{c: r.get(c) for c in cols} for r in out] + return out + + def I(self, tbl, where="", args=None): + self.calls.append(("I", tbl)) + return len([r for r in self._t(tbl) if _match(r, where, args)]) + + def sqlExe(self, sql, args=None): + self.calls.append(("sqlExe", sql.split()[0].lower())) + s = " ".join(sql.split()).lower() + if s.startswith("update pbl_blueprint_template set use_count=use_count+1"): + a = list(args or []) + n = 0 + for r in self._t("pbl_blueprint_template"): + if len(a) >= 3 and r.get("tenant_id") == a[1] and r.get("id") == a[2]: + r["use_count"] = int(r.get("use_count") or 0) + 1 + r["update_time"] = a[0] + n += 1 + return n + if s.startswith("update pbl_blueprint_lock set status='expired'"): + a = list(args or []) + now = a[0] if a else "" + tid = a[1] if len(a) > 1 else "" + n = 0 + for r in self._t("pbl_blueprint_lock"): + if r.get("tenant_id") == tid and r.get("status") == "holding" \ + and (r.get("expire_time") or "") < now: + r["status"] = "expired" + n += 1 + return n + if s.startswith("create table"): + m = re.search(r"create table if not exists ([a-z_0-9]+)", s) + if m: + self._t(m.group(1)) + return 0 + return 0 + + +def _match(row, where, args): + """极简 WHERE 求值:支持 tenant_id=? / and / or / in (...) / like / 比较 / is null。""" + where = str(where or "").strip() + if not where: + return True + args = list(args or []) + pos = [0] + + def take(): + v = args[pos[0]] if pos[0] < len(args) else None + pos[0] += 1 + return v + + def eval_or(s): + parts = _split_top(s, " or ") + return any(eval_and(p) for p in parts) + + def eval_and(s): + parts = _split_top(s, " and ") + return all(eval_atom(p) for p in parts) + + def eval_atom(s): + s = s.strip() + while s.startswith("(") and _balanced(s): + s = s[1:-1].strip() + low = s.lower() + m = re.match(r"^([a-z_0-9]+)\s+in\s*\((.*)\)$", low) + if m: + col = m.group(1) + n = m.group(2).count("?") + 1 + vals = [take() for _ in range(n)] + return row.get(col) in vals + if low.endswith(" is null"): + col = low[:-8].strip() + return row.get(col) is None + if low.endswith(" is not null"): + col = low[:-12].strip() + return row.get(col) is not None + m = re.match(r"^([a-z_0-9]+)\s*(>=|<=|!=|<>|=|>|<)\s*(\?.*)$", low) + if m: + col, op, rest = m.group(1), m.group(2), m.group(3) + val = take() + if rest.strip() != "?": + return True + rv = row.get(col) + try: + if op == "=": + return str(rv) == str(val) + if op in ("!=", "<>"): + return str(rv) != str(val) + if isinstance(rv, (int, float)) and isinstance(val, (int, float)): + return {" >": 0}.get(" ") is None and _cmp(rv, op, val) + return _cmp(str(rv or ""), op, str(val or "")) + except Exception: + return False + m = re.match(r"^([a-z_0-9]+)\s+like\s+\?$", low) + if m: + col = m.group(1) + pat = str(take() or "") + rx = "^" + re.escape(pat).replace("%", ".*").replace("_", ".") + "$" + return re.match(rx, str(row.get(col) or "")) is not None + # 未识别片段:消耗其占位符后放行 + for _ in range(s.count("?")): + take() + return True + + return eval_or(where) + + +def _cmp(a, op, b): + if op == ">": + return a > b + if op == ">=": + return a >= b + if op == "<": + return a < b + if op == "<=": + return a <= b + return False + + +def _split_top(s, sep): + out, depth, cur, i = [], 0, "", 0 + low = s.lower() + while i < len(s): + if s[i] == "(": + depth += 1 + elif s[i] == ")": + depth -= 1 + if depth == 0 and low.startswith(sep, i): + out.append(cur) + cur = "" + i += len(sep) + continue + cur += s[i] + i += 1 + out.append(cur) + return [x for x in out if x.strip()] + + +def _balanced(s): + if not s.startswith("("): + return False + d = 0 + for i, ch in enumerate(s): + if ch == "(": + d += 1 + elif ch == ")": + d -= 1 + if d == 0: + return i == len(s) - 1 + return False + + +def _install_fake_sor(): + sor = FakeSor() + from pbl_blueprint import db as _db + _db.clear_cache() + _db.get_sor = lambda dbname=None: sor + _db.get_dbname = lambda: sor.dbname + return sor + + +# ------------------------------------------------------------------ F 契约跑通 + +def check_runtime(): + g = "F.契约运行" + try: + sor = _install_fake_sor() + import pbl_blueprint as bp + from pbl_blueprint.errors import PblBlueprintError + T = "t_a" + T2 = "t_b" + + r = bp.create_blueprint(T, "海洋生态PBL", subject="科学", grade="五年级", op_user="u1", sor=sor) + check(g, "create_blueprint 成功", r["success"] and r["data"]["tenant_id"] == T, str(r)[:120]) + bid = r["data"]["id"] + check(g, "create_blueprint 生成 str32 id", len(bid) == 32, bid) + check(g, "create_blueprint 默认 status=draft/quality=q0", + r["data"]["status"] == "draft" and r["data"]["quality_status"] == "q0") + check(g, "create_blueprint 自动编码 PBL-BP-", str(r["data"]["code"]).startswith("PBL-BP-"), + r["data"]["code"]) + + got = bp.get_blueprint(T, bid, sor=sor) + check(g, "get_blueprint 命中", got["success"] and got["data"]["id"] == bid) + try: + bp.get_blueprint(T2, bid, sor=sor) + check(g, "跨租户 get 被拒(fail-closed)", False, "未抛错") + except PblBlueprintError as e: + check(g, "跨租户 get 被拒(fail-closed)", e.errcode == bp.ERR_NOT_FOUND, e.errcode) + + upd = bp.update_blueprint(T, bid, {"summary": "更新简介", "duration_hours": 12}, + op_user="u1", sor=sor) + check(g, "update_blueprint 成功", upd["success"] and upd["data"]["affected"] >= 1, str(upd)[:120]) + try: + bp.update_blueprint(T, bid, {"tenant_id": T2}, sor=sor) + check(g, "update 禁改 tenant_id", False, "未抛错") + except PblBlueprintError as e: + check(g, "update 禁改 tenant_id", e.errcode == bp.ERR_PARAM_INVALID, e.errcode) + + lst = bp.list_blueprints(T, {"subject": "科学"}, sor=sor) + check(g, "list_blueprints 条件命中", lst["data"]["total"] >= 1, str(lst["data"]["total"])) + lst2 = bp.list_blueprints(T2, {}, sor=sor) + check(g, "list_blueprints 租户隔离(他租户 0 条)", lst2["data"]["total"] == 0, + str(lst2["data"]["total"])) + + # 节点:7 类各建一个 + node_ids = {} + for nt in bp.NODE_TYPES: + rr = bp.create_node(T, bid, nt, "%s节点" % nt, spec_json={"k": nt}, + weight=1.5, op_user="u1", sor=sor) + okk = rr["success"] and rr["data"]["node_type"] == nt + node_ids[nt] = rr["data"]["id"] if okk else "" + check(g, "create_node[%s] 成功" % nt, okk, str(rr)[:120]) + check(g, "7 类子对象全部可建", len([v for v in node_ids.values() if v]) == 7, + str(len(bp.NODE_TYPES))) + sub = bp.create_node(T, bid, "task", "子任务", parent_id=node_ids["task"], sor=sor) + check(g, "create_node 带 parent_id 成功", sub["success"], str(sub)[:120]) + try: + bp.create_node(T, bid, "badtype", "x", sor=sor) + check(g, "非法 node_type 被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "非法 node_type 被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode) + try: + bp.create_node(T, bid, "task", "嵌套task", parent_id=node_ids["task"], sor=sor) + check(g, "task 嵌套 task 被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "task 嵌套 task 被拒", e.errcode == bp.ERR_STATE_INVALID, e.errcode) + + tree = bp.get_blueprint_tree(T, bid, sor=sor) + check(g, "get_blueprint_tree node_count=8", + tree["data"]["node_count"] == 8, str(tree["data"]["node_count"])) + check(g, "get_blueprint_tree roots=7(1 个 task 子节点挂在父下)", + len(tree["data"]["roots"]) == 7, str(len(tree["data"]["roots"]))) + + # 边 + e1 = bp.create_edge(T, bid, node_ids["role"], node_ids["task"], "unlocks", sor=sor) + check(g, "create_edge 成功", e1["success"], str(e1)[:120]) + try: + bp.create_edge(T, bid, node_ids["role"], node_ids["task"], "unlocks", sor=sor) + check(g, "重复边被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "重复边被拒", e.errcode == bp.ERR_DUPLICATE, e.errcode) + try: + bp.create_edge(T, bid, node_ids["task"], node_ids["role"], "depends", sor=sor) + check(g, "成环边被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "成环边被拒", e.errcode == bp.ERR_STATE_INVALID, e.errcode) + try: + bp.create_edge(T, bid, node_ids["role"], node_ids["role"], "depends", sor=sor) + check(g, "自环边被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "自环边被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode) + + # 版本 + delta + v1 = bp.save_version(T, bid, "draft", "初版", op_user="u1", sor=sor) + check(g, "save_version v1 成功", v1["success"] and v1["data"]["version"]["version_no"] == 1, + str(v1)[:150]) + bp.update_node(T, node_ids["rule"], {"name": "规则改名"}, op_user="u1", sor=sor) + bp.create_node(T, bid, "resource", "新增资源", sor=sor) + v2 = bp.save_version(T, bid, "minor", "改规则+加资源", op_user="u1", sor=sor) + check(g, "save_version v2 成功", v2["success"] and v2["data"]["version"]["version_no"] == 2) + check(g, "v2 change_delta 检出变更(>=2)", v2["data"]["delta_count"] >= 2, + str(v2["data"]["delta_count"])) + d = bp.get_change_delta(T, bid, 1, 2, sor=sor) + check(g, "get_change_delta 返回差异行", d["success"] and d["data"]["total"] >= 2, + str(d["data"]["total"])) + ops = set(x["op_type"] for x in d["data"]["rows"]) + check(g, "change_delta 含 add/update", "add" in ops and "update" in ops, str(ops)) + vs = bp.list_versions(T, bid, sor=sor) + check(g, "list_versions 返回 2 个版本", vs["data"]["total"] == 2, str(vs["data"]["total"])) + bp2 = bp.get_blueprint(T, bid, sor=sor) + check(g, "current_version 回写为 2", int(bp2["data"]["current_version"]) == 2, + str(bp2["data"]["current_version"])) + + # fork + fk = bp.fork_blueprint(T, bid, "副本蓝图", "copy", op_user="u2", sor=sor) + check(g, "fork_blueprint 成功", fk["success"], str(fk)[:150]) + check(g, "fork 复制节点数=9", fk["data"]["node_count"] == 9, str(fk["data"]["node_count"])) + check(g, "fork 复制边数=1", fk["data"]["edge_count"] == 1, str(fk["data"]["edge_count"])) + newid = fk["data"]["blueprint"]["id"] + check(g, "fork 新蓝图 source=fork 且 source_id 指向源", + fk["data"]["blueprint"]["source"] == "fork" and + fk["data"]["blueprint"]["source_id"] == bid) + fkl = bp.list_forks(T, bid, "children", sor=sor) + check(g, "list_forks(children) 命中血缘", fkl["data"]["total"] >= 1, + str(fkl["data"]["total"])) + fkt = bp.get_blueprint_tree(T, newid, sor=sor) + check(g, "fork 后新蓝图树节点数=9", fkt["data"]["node_count"] == 9, + str(fkt["data"]["node_count"])) + + # 模板 + 实例化 + tpl_payload = { + "blueprint": {"subject": "数学", "grade": "三年级", "summary": "模板"}, + "nodes": [ + {"id": "n1", "code": "T1", "name": "角色A", "node_type": "role", + "spec_json": {"desc": "a"}}, + {"id": "n2", "code": "T2", "name": "任务A", "node_type": "task", + "parent_id": "n1"}, + {"id": "n3", "code": "T3", "name": "产出A", "node_type": "artifact"}, + ], + "edges": [{"from": "n1", "to": "n2", "edge_type": "unlocks"}], + } + ct = bp.create_template(T, "数学模板", category="stem", payload=tpl_payload, + price_amount=99.5, op_user="u1", sor=sor) + check(g, "create_template 成功", ct["success"], str(ct)[:150]) + check(g, "create_template 统计 node_count=3/edge_count=1", + ct["data"]["node_count"] == 3 and ct["data"]["edge_count"] == 1, + "%s/%s" % (ct["data"]["node_count"], ct["data"]["edge_count"])) + tid_ = ct["data"]["id"] + inst = bp.instantiate_template(T, tid_, "实例化蓝图", owner_id="u9", sor=sor) + check(g, "instantiate_template 成功", inst["success"], str(inst)[:150]) + check(g, "实例化节点数=3", inst["data"]["node_count"] == 3, str(inst["data"]["node_count"])) + check(g, "实例化边数=1", inst["data"]["edge_count"] == 1, str(inst["data"]["edge_count"])) + check(g, "实例化蓝图 source=template", + inst["data"]["blueprint"]["source"] == "template") + itree = bp.get_blueprint_tree(T, inst["data"]["blueprint"]["id"], sor=sor) + check(g, "实例化后树 roots=2(n2 挂 n1 下)", len(itree["data"]["roots"]) == 2, + str(len(itree["data"]["roots"]))) + tl = bp.list_templates(T, {}, sor=sor) + check(g, "list_templates 命中", tl["data"]["total"] >= 1, str(tl["data"]["total"])) + try: + bp.instantiate_template(T2, tid_, "越权实例化", sor=sor) + check(g, "他租户实例化模板被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "他租户实例化模板被拒", e.errcode == bp.ERR_NOT_FOUND, e.errcode) + + # 离线兜底 + exp = bp.export_offline(T, bid, "海洋离线包", op_user="u1", sor=sor) + check(g, "export_offline 成功", exp["success"], str(exp)[:150]) + check(g, "export_offline 生成 sha256 校验和(64位)", + len(exp["data"]["checksum"]) == 64, exp["data"]["checksum"][:16]) + check(g, "export_offline file_size>0", int(exp["data"]["file_size"]) > 0, + str(exp["data"]["file_size"])) + off_row = [r for r in sor.tables["pbl_blueprint_offline"] if r["id"] == exp["data"]["id"]][0] + imp = bp.import_offline(T, off_row["pkg_json"], "离线导入蓝图", op_user="u1", sor=sor) + check(g, "import_offline 成功", imp["success"], str(imp)[:150]) + check(g, "import_offline 节点数>0", imp["data"]["node_count"] > 0, + str(imp["data"]["node_count"])) + pay = bp.instantiate_payload(T, tpl_payload, "payload兜底实例化", sor=sor) + check(g, "instantiate_payload 离线兜底成功", pay["success"], str(pay)[:150]) + try: + bp.import_offline(T, {"schema_version": "9.9", "nodes": [1]}, sor=sor) + check(g, "schema_version 不兼容被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "schema_version 不兼容被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode) + try: + bad_pkg = dict(json.loads(off_row["pkg_json"])) + bad_pkg["checksum"] = "0" * 64 + bp.import_offline(T, bad_pkg, sor=sor) + check(g, "校验和不匹配被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "校验和不匹配被拒", e.errcode == bp.ERR_PARAM_INVALID, e.errcode) + + # 发布 + pub = bp.publish_blueprint(T, bid, 0, "class", "cls_1", class_id="cls_1", + op_user="u1", sor=sor) + check(g, "publish_blueprint 成功", pub["success"], str(pub)[:150]) + check(g, "发布后蓝图 status=published", + bp.get_blueprint(T, bid, sor=sor)["data"]["status"] == "published") + pub2 = bp.publish_blueprint(T, bid, 0, "class", "cls_1", class_id="cls_1", + op_user="u1", sor=sor) + check(g, "重复发布幂等(idempotent=True)", + pub2["success"] and pub2["data"]["idempotent"] is True, str(pub2)[:120]) + rv = bp.revoke_publish(T, pub["data"]["publish"]["id"], op_user="u1", sor=sor) + check(g, "revoke_publish 成功", rv["success"], str(rv)[:120]) + + # 锁 + lk = bp.lock_blueprint(T, bid, "u1", holder_name="张三", ttl_seconds=600, sor=sor) + check(g, "lock_blueprint 成功", lk["success"] and lk["data"]["rev"] == 1, str(lk)[:120]) + lk2 = bp.lock_blueprint(T, bid, "u1", ttl_seconds=600, sor=sor) + check(g, "同人续锁 rev 递增", lk2["success"] and lk2["data"]["rev"] == 2, + str(lk2["data"].get("rev"))) + try: + bp.lock_blueprint(T, bid, "u2", sor=sor) + check(g, "他人抢锁被拒(ERR_LOCKED)", False, "未抛错") + except PblBlueprintError as e: + check(g, "他人抢锁被拒(ERR_LOCKED)", e.errcode == bp.ERR_LOCKED, e.errcode) + try: + bp.unlock_blueprint(T, bid, "u2", sor=sor) + check(g, "非持锁人解锁被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "非持锁人解锁被拒", e.errcode == bp.ERR_FORBIDDEN, e.errcode) + ul = bp.unlock_blueprint(T, bid, "u1", sor=sor) + check(g, "持锁人解锁成功", ul["success"] and ul["data"]["released"] is True, str(ul)[:120]) + cl = bp.clean_expired_locks(T, sor=sor) + check(g, "clean_expired_locks 可执行", cl["success"], str(cl)[:120]) + + # 统计 + 删除 + st = bp.blueprint_stats(T, bid, sor=sor) + check(g, "blueprint_stats 返回 7 类节点计数", + len(st["data"]["node_by_type"]) == 7, str(st["data"]["node_by_type"])) + check(g, "blueprint_stats node_total>=9", st["data"]["node_total"] >= 9, + str(st["data"]["node_total"])) + dl = bp.delete_node(T, node_ids["resource"], op_user="u1", sor=sor) + check(g, "delete_node 成功", dl["success"], str(dl)[:120]) + dbp = bp.delete_blueprint(T, newid, op_user="u1", cascade=True, sor=sor) + check(g, "delete_blueprint 级联成功", dbp["success"] and dbp["data"]["cascade"] > 0, + str(dbp["data"])) + try: + bp.get_blueprint(T, newid, sor=sor) + check(g, "删除后 get 不可见(逻辑删除)", False, "未抛错") + except PblBlueprintError as e: + check(g, "删除后 get 不可见(逻辑删除)", e.errcode == bp.ERR_NOT_FOUND, e.errcode) + + # 审计 append-only + au = bp.list_audit(T, bid, sor=sor) + check(g, "审计记录已写入(>0)", au["data"]["total"] > 0, str(au["data"]["total"])) + try: + bp.crud_update("pbl_blueprint_audit", T, au["data"]["rows"][0]["id"], + {"action": "x"}, sor=sor) + check(g, "审计表禁改(append-only)", False, "未抛错") + except PblBlueprintError as e: + check(g, "审计表禁改(append-only)", e.errcode == bp.ERR_FORBIDDEN, e.errcode) + try: + bp.crud_delete("pbl_blueprint_audit", T, au["data"]["rows"][0]["id"], sor=sor) + check(g, "审计表禁删(append-only)", False, "未抛错") + except PblBlueprintError as e: + check(g, "审计表禁删(append-only)", e.errcode == bp.ERR_FORBIDDEN, e.errcode) + + # 所有写操作 tenant_id 打头 + no_tenant = [r for tbl, rows in sor.tables.items() for r in rows + if "tenant_id" in r and not r.get("tenant_id")] + check(g, "落库行 tenant_id 全部非空", not no_tenant, str(len(no_tenant))) + check(g, "11 张表均有数据落库", len(sor.tables) >= 11, str(sorted(sor.tables.keys()))) + api_used = sorted(set(c for c, _ in sor.calls)) + check(g, "运行时仅调用 sqlor 白名单 API", + set(api_used) <= {"C", "U", "D", "R", "I", "sqlExe"}, str(api_used)) + except Exception as e: + check(g, "契约运行无异常", False, "%s\n%s" % (e, traceback.format_exc()[-1200:])) + + +# ------------------------------------------------------------------ G 租户 fail-closed + +def check_tenant(): + g = "G.租户fail-closed" + try: + sor = _install_fake_sor() + import pbl_blueprint as bp + from pbl_blueprint.errors import PblBlueprintError + bad_inputs = [None, "", " ", "*", "tenant%", "a" * 33, "t'or'1=1"] + for v in bad_inputs: + try: + bp.create_blueprint(v, "x", sor=sor) + check(g, "非法 tenant_id 被拒: %r" % (v,), False, "未抛错") + except PblBlueprintError as e: + check(g, "非法 tenant_id 被拒: %r" % (v,), + e.errcode == bp.ERR_TENANT_MISSING, e.errcode) + try: + bp.require_tenant("t_a", "t_b") + check(g, "上下文租户不一致被拒", False, "未抛错") + except PblBlueprintError as e: + check(g, "上下文租户不一致被拒", e.errcode == bp.ERR_FORBIDDEN, e.errcode) + w, a = bp.tenant_where("t_a", "id=?", ["x"]) + check(g, "tenant_where 以 tenant_id=? 开头", str(w).startswith("tenant_id=?"), str(w)) + check(g, "tenant_where 参数 tenant_id 打头", a[0] == "t_a", str(a)) + inj = bp.inject_tenant("t_a", {"name": "n", "tenant_id": "hack"}) + check(g, "inject_tenant 覆盖外部 tenant_id 且置首位", + list(inj.keys())[0] == "tenant_id" and inj["tenant_id"] == "t_a", str(inj)) + # 契约函数首参 tenant_id(运行时再验一次) + import inspect + bad = [] + for n in bp.CONTRACT_FUNCTIONS: + fn = getattr(bp, n, None) + if not callable(fn): + bad.append(n) + continue + try: + params = list(inspect.signature(fn).parameters.keys()) + except Exception: + params = [] + if not params or params[0] != "tenant_id": + bad.append(n) + check(g, "全部契约函数首参为 tenant_id", not bad, str(bad)) + except Exception as e: + check(g, "租户校验可执行", False, "%s\n%s" % (e, traceback.format_exc()[-600:])) + + +# ------------------------------------------------------------------ H DDL + +def check_ddl(): + g = "H.DDL生成" + try: + from pbl_blueprint import init as init_mod + txt = init_mod.ddl_script() + check(g, "ddl_script 生成非空", len(txt) > 500, str(len(txt))) + for t in EXPECT_TABLES: + check(g, "DDL 含 create table %s" % t, ("create table if not exists `%s`" % t) in txt) + check(g, "DDL 无库名前缀硬编码", "`.`" not in txt and "use " not in txt.lower().split("\n")[0]) + check(g, "金额列 DDL 为 decimal(18,2)", "decimal(18,2)" in txt) + check(g, "主键均为 id", txt.count("primary key (`id`)") == len(EXPECT_TABLES), + str(txt.count("primary key (`id`)"))) + except Exception as e: + check(g, "DDL 生成可执行", False, "%s\n%s" % (e, traceback.format_exc()[-400:])) + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + report = "" + if "--report" in argv: + i = argv.index("--report") + if i + 1 < len(argv): + report = argv[i + 1] + t0 = datetime.datetime.now() + check_structure() + check_models() + check_crud_json() + check_code_quality() + check_rbac() + check_runtime() + check_tenant() + check_ddl() + t1 = datetime.datetime.now() + + total = len(RESULTS) + passed = len([r for r in RESULTS if r["ok"]]) + failed = [r for r in RESULTS if not r["ok"]] + lines = [] + lines.append("=" * 78) + lines.append("pbl_blueprint M1a 自查报告") + lines.append("模块路径 : %s" % MODULE_ROOT) + lines.append("执行时间 : %s ~ %s (%.2fs)" % (t0.strftime("%F %T"), t1.strftime("%F %T"), + (t1 - t0).total_seconds())) + lines.append("Python : %s" % sys.version.split()[0]) + lines.append("=" * 78) + groups = [] + for r in RESULTS: + if r["group"] not in groups: + groups.append(r["group"]) + for gp in groups: + rs = [r for r in RESULTS if r["group"] == gp] + ps = len([r for r in rs if r["ok"]]) + lines.append("") + lines.append("[%s] %d/%d 通过" % (gp, ps, len(rs))) + for r in rs: + flag = "PASS" if r["ok"] else "FAIL" + lines.append(" %-4s %s%s" % (flag, r["name"], (" | " + r["msg"]) if (r["msg"] and not r["ok"]) else "")) + lines.append("") + lines.append("-" * 78) + lines.append("合计: %d 项, 通过 %d, 失败 %d" % (total, passed, len(failed))) + if failed: + lines.append("失败清单:") + for r in failed: + lines.append(" * [%s] %s | %s" % (r["group"], r["name"], r["msg"])) + lines.append("结论: %s" % ("ALL PASS" if not failed else "HAS FAILURES")) + lines.append("-" * 78) + txt = "\n".join(lines) + print(txt) + if report: + d = os.path.dirname(os.path.abspath(report)) + if d and not os.path.isdir(d): + os.makedirs(d) + with io.open(report, "w", encoding="utf-8") as f: + f.write(txt + "\n") + print("报告已写入: %s" % report) + return 0 if not failed else 1 + + +if __name__ == "__main__": + sys.exit(main())