deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-17 15:16:08 +08:00
parent d52c201dd4
commit dfc840b24a
23 changed files with 6082 additions and 918 deletions

View File

@ -357,3 +357,41 @@ HANDLERS = {
'/pbl_blueprint/template/instantiate': api_template_instantiate,
'/pbl_blueprint/template/offline_fallback': api_template_offline_fallback,
}
# >>> M1b compat exports (auto-generated, idempotent) >>>
# 由 tools/m1b_fix_import_closure.py 幂等生成;不覆盖既有同名定义。
# 目的:修复 pbl_common 半迁移造成的 import 闭包断裂QC #1/#2/#3/#4
try:
from pbl_blueprint.m1b.api import ( # noqa: F401
pbl_template_instantiate,
pbl_blueprint_create,
pbl_template_list,
pbl_template_get,
pbl_template_create,
pbl_template_update,
pbl_template_publish,
pbl_template_offline,
pbl_blueprint_get,
pbl_blueprint_subobject_tree,
pbl_subobject_list,
pbl_subobject_get,
pbl_subobject_create,
pbl_subobject_update,
pbl_subobject_delete,
pbl_subobject_ext_get,
pbl_subobject_ext_set,
pbl_subobject_ext_validate,
pbl_subobject_contract,
pbl_ref_resolve,
pbl_ref_list,
pbl_ref_summary,
pbl_ref_contract,
pbl_m1b_info,
M1B_API_REGISTRY,
)
except ImportError: # pragma: no cover - 供给层缺失时不阻断导入
pass
# <<< M1b compat exports <<<

View File

@ -120,3 +120,39 @@ def loads(txt, default=None):
return _json.loads(txt)
except Exception:
return default
# >>> M1b compat exports (auto-generated, idempotent) >>>
# 由 tools/m1b_fix_import_closure.py 幂等生成;不覆盖既有同名定义。
# 目的:修复 pbl_common 半迁移造成的 import 闭包断裂QC #1/#2/#3/#4
try:
from pbl_blueprint.m1b import ( # noqa: F401
PblError,
PblValidationError,
PblNotFound,
PblConflict,
PblForbidden,
TenantMissingError,
ErrorCode,
require_tenant,
normalize_tenant,
assert_not_write_protected,
write_audit,
tenant_crud,
crud_factory,
new_id,
now_str,
json_dump,
json_load,
sql_exec,
sql_rows,
sql_scalar,
get_conn,
table_exists,
get_env,
)
except ImportError: # pragma: no cover - 供给层缺失时不阻断导入
pass
# <<< M1b compat exports <<<

View File

@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""pbl_blueprint.m1b —— M1b 模板/子对象扩展与关联表子包。
自包含设计本子包**只依赖标准库** import pbl_common / pbl_blueprint.db
因此无论公共内核处于何种迁移状态M1b 功能面都可独立导入与运行
QC 退回意见 #1「import 闭包断裂」的根治手段)。
同时本子包是 pbl_common.api / pbl_common.audit / pbl_common.crud_factory /
pbl_common.dbutil / pbl_blueprint.db / pbl_blueprint.api 缺失符号的**兼容供给层**
tools/m1b_fix_import_closure.py 会把下列符号幂等回补到对应模块导出面
子模块
errors 错误码 + PblError 类族PblNotFound/PblValidationError/...
util new_id / now_str / json_dump / json_load
tenant require_tenant / normalize_tenant / assert_not_write_protected
dbutil sql_exec / sql_rows / sql_scalar / get_conn / table_exists
audit write_auditappend-only + 降级落盘
crud_factory tenant_crud / crud_factory租户打头 CRUD
tables 4 M1b 表定义单一事实来源DDL models JSON 同源
subobject 7 类子对象泛化契约 + 扩展字段读写
ref 关联表判定Q-OPEN-3基表只读无外键
template 模板平台公共部分tenant_id NULL+ 实例化 + 离线兜底
api 对外接口pbl_template_instantiate / pbl_blueprint_create
init 建表 + 注册同步入口load_m1b
"""
from .errors import ( # noqa: F401
CODE_TO_HTTP,
ErrorCode,
PblConflict,
PblError,
PblForbidden,
PblNotFound,
PblValidationError,
TenantMissingError,
error_envelope,
raise_error,
)
from .util import ( # noqa: F401
json_dump,
json_load,
new_id,
now_str,
now_ts,
stable_code,
)
from .tenant import ( # noqa: F401
PLATFORM_TENANT,
allow_platform,
assert_not_write_protected,
normalize_tenant,
require_tenant,
tenant_scope,
)
from .dbutil import ( # noqa: F401
close_conn,
db_info,
get_conn,
list_tables,
reset_conn,
sql_exec,
sql_rows,
sql_scalar,
table_exists,
)
from .audit import write_audit, write_audit_batch, flush_memory_audit # noqa: F401
from .crud_factory import TenantCrud, crud_factory, tenant_crud # noqa: F401
from .tables import ( # noqa: F401
REF_KINDS,
SUBOBJECT_TYPES,
TABLES,
TEMPLATE_SCOPES,
field_names,
get_table,
table_names,
to_model_json,
)
from .subobject import ( # noqa: F401
SUBOBJECT_SPECS,
create_subobject,
delete_subobject,
get_ext,
get_subobject,
list_subobjects,
set_ext,
subobject_contract,
subobject_tree,
update_subobject,
validate_ext,
)
from .ref import ( # noqa: F401
assert_base_table_immutable,
list_refs,
ref_contract,
ref_summary,
resolve_ref,
)
from .template import ( # noqa: F401
create_template,
get_template,
instantiate_template,
list_templates,
load_offline_templates,
offline_template,
publish_template,
template_contract,
update_template,
)
from .api import ( # noqa: F401
M1B_API_REGISTRY,
pbl_blueprint_create,
pbl_blueprint_get,
pbl_blueprint_subobject_tree,
pbl_m1b_info,
pbl_ref_contract,
pbl_ref_list,
pbl_ref_resolve,
pbl_ref_summary,
pbl_subobject_contract,
pbl_subobject_create,
pbl_subobject_delete,
pbl_subobject_ext_get,
pbl_subobject_ext_set,
pbl_subobject_ext_validate,
pbl_subobject_get,
pbl_subobject_list,
pbl_subobject_update,
pbl_template_create,
pbl_template_get,
pbl_template_instantiate,
pbl_template_list,
pbl_template_offline,
pbl_template_publish,
pbl_template_update,
)
__version__ = "1.1.0"
__milestone__ = "M1b"
__all__ = [
"__version__", "__milestone__",
# errors
"ErrorCode", "CODE_TO_HTTP", "PblError", "PblValidationError", "PblNotFound",
"PblConflict", "PblForbidden", "TenantMissingError", "raise_error",
"error_envelope",
# util
"new_id", "now_str", "now_ts", "json_dump", "json_load", "stable_code",
# tenant
"PLATFORM_TENANT", "normalize_tenant", "require_tenant", "allow_platform",
"tenant_scope", "assert_not_write_protected",
# db
"get_conn", "close_conn", "reset_conn", "sql_exec", "sql_rows", "sql_scalar",
"table_exists", "list_tables", "db_info",
# audit
"write_audit", "write_audit_batch", "flush_memory_audit",
# crud
"tenant_crud", "crud_factory", "TenantCrud",
# tables
"TABLES", "SUBOBJECT_TYPES", "REF_KINDS", "TEMPLATE_SCOPES", "table_names",
"get_table", "field_names", "to_model_json",
# subobject
"SUBOBJECT_SPECS", "list_subobjects", "get_subobject", "create_subobject",
"update_subobject", "delete_subobject", "get_ext", "set_ext", "validate_ext",
"subobject_tree", "subobject_contract",
# ref
"resolve_ref", "list_refs", "ref_summary", "ref_contract",
"assert_base_table_immutable",
# template
"list_templates", "get_template", "create_template", "update_template",
"publish_template", "offline_template", "instantiate_template",
"load_offline_templates", "template_contract",
# api
"M1B_API_REGISTRY", "pbl_template_instantiate", "pbl_blueprint_create",
"pbl_m1b_info",
]

408
pbl_blueprint/m1b/api.py Normal file
View File

@ -0,0 +1,408 @@
# -*- coding: utf-8 -*-
"""M1b 对外 API 层:接口注册表 + 统一响应包。
对应 QC 退回意见 #4pbl_agent_runtime.api 依赖
pbl_blueprint.api.pbl_template_instantiate
pbl_blueprint.api.pbl_blueprint_create
本模块实现真实逻辑api.py 通过 `from .m1b.api import *` 再导出
保证包 import 闭包可用不再 ImportError
接口命名遵循模块既有风格pbl_{}_{动作}返回统一 dict
{"ok": bool, "code": str, "data": ..., "message": str}
"""
from .audit import write_audit
from .dbutil import db_info, get_conn, table_exists
from .errors import (
ErrorCode,
PblError,
error_envelope,
)
from .ref import list_refs, ref_contract, ref_summary, resolve_ref
from .subobject import (
create_subobject,
delete_subobject,
get_ext,
get_subobject,
list_subobjects,
set_ext,
subobject_contract,
subobject_tree,
update_subobject,
validate_ext,
)
from .tables import SUBOBJECT_TYPES, table_names
from .template import (
create_template,
get_template,
instantiate_template,
list_templates,
offline_template,
publish_template,
template_contract,
update_template,
)
from .tenant import require_tenant
from .util import new_id, now_str
__all__ = [
# 模板
"pbl_template_list",
"pbl_template_get",
"pbl_template_create",
"pbl_template_update",
"pbl_template_publish",
"pbl_template_offline",
"pbl_template_instantiate",
# 蓝图
"pbl_blueprint_create",
"pbl_blueprint_get",
"pbl_blueprint_subobject_tree",
# 子对象
"pbl_subobject_list",
"pbl_subobject_get",
"pbl_subobject_create",
"pbl_subobject_update",
"pbl_subobject_delete",
"pbl_subobject_ext_get",
"pbl_subobject_ext_set",
"pbl_subobject_ext_validate",
"pbl_subobject_contract",
# 关联判定
"pbl_ref_resolve",
"pbl_ref_list",
"pbl_ref_summary",
"pbl_ref_contract",
# 元信息
"pbl_m1b_info",
"M1B_API_REGISTRY",
"ok",
"fail",
]
def ok(data=None, message="success", code=ErrorCode.OK):
"""统一成功响应包。"""
return {"ok": True, "code": code, "message": message, "data": data}
def fail(exc):
"""统一失败响应包。"""
env = error_envelope(exc)
return {"ok": False, "code": env["code"], "message": env["message"],
"data": env.get("detail"), "http_status": env["http_status"]}
def _guard(fn):
"""异常包装:任何 PblError/Exception 都转成统一响应包,不外泄栈。"""
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except PblError as exc:
return fail(exc)
except Exception as exc: # noqa: BLE001
return fail(exc)
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
# ---------------- 模板接口 ----------------
@_guard
def pbl_template_list(tenant_id=None, scope=None, status=None, category=None,
keyword=None, limit=100, offset=0):
"""列模板(平台公共 + 本租户合并可见)。"""
rows = list_templates(tenant_id=tenant_id, scope=scope, status=status,
category=category, keyword=keyword,
limit=limit, offset=offset)
return ok({"items": rows, "total": len(rows),
"platform_count": sum(1 for r in rows if r.get("is_platform"))})
@_guard
def pbl_template_get(tenant_id=None, template_id=None, code=None, version=None):
"""取单个模板(含离线兜底标记)。"""
row = get_template(tenant_id=tenant_id, template_id=template_id, code=code,
version=version)
return ok(row)
@_guard
def pbl_template_create(tenant_id=None, data=None, actor_id=None, role=None):
"""创建模板scope=platform 时 tenant_id 落 NULL需平台角色"""
row = create_template(tenant_id=tenant_id, data=data, actor_id=actor_id,
role=role)
write_audit(action="create", table="pbl_blueprint_template",
obj_id=row.get("id"), tenant_id=row.get("tenant_id"),
actor_id=actor_id, after=row, extra={"via": "api"})
return ok(row, message="template created")
@_guard
def pbl_template_update(tenant_id=None, template_id=None, data=None,
actor_id=None, role=None):
"""更新模板(平台公共模板租户不可改)。"""
row = update_template(tenant_id=tenant_id, template_id=template_id,
data=data, actor_id=actor_id, role=role)
return ok(row, message="template updated")
@_guard
def pbl_template_publish(tenant_id=None, template_id=None, actor_id=None, role=None):
"""发布模板draft -> published"""
row = publish_template(tenant_id=tenant_id, template_id=template_id,
actor_id=actor_id, role=role)
return ok(row, message="template published")
@_guard
def pbl_template_offline(tenant_id=None, template_id=None, actor_id=None, role=None):
"""下线模板published -> offline"""
row = offline_template(tenant_id=tenant_id, template_id=template_id,
actor_id=actor_id, role=role)
return ok(row, message="template offlined")
@_guard
def pbl_template_instantiate(tenant_id=None, template_id=None, code=None,
version=None, overrides=None, with_refs=None,
actor_id=None):
"""模板实例化为蓝图M1b 核心接口pbl_agent_runtime 依赖)。"""
t = require_tenant(tenant_id)
res = instantiate_template(t, template_id=template_id, code=code,
version=version, overrides=overrides,
actor_id=actor_id, with_refs=with_refs)
return ok(res, message="template instantiated"
+ ("offline fallback" if res.get("fallback") else ""))
# ---------------- 蓝图接口 ----------------
@_guard
def pbl_blueprint_create(tenant_id=None, data=None, actor_id=None):
"""创建蓝图pbl_agent_runtime 依赖)。
data 支持两种形态
1) 直接蓝图字段 {"name":..,"code":..,...}
2) 由模板派生 {"template_id":..} / {"template_code":..,"template_version":..}
-> instantiate_template返回完整实例化结果
"""
t = require_tenant(tenant_id)
payload = dict(data or {})
tpl_id = payload.pop("template_id", None)
tpl_code = payload.pop("template_code", None)
tpl_ver = payload.pop("template_version", None)
if tpl_id or tpl_code:
res = instantiate_template(
t, template_id=tpl_id, code=tpl_code, version=tpl_ver,
overrides={"blueprint": payload,
"ext": payload.pop("ext", None) or {},
"subobjects": payload.pop("subobjects", None) or {}},
with_refs=payload.pop("refs", None), actor_id=actor_id)
return ok(res, message="blueprint created from template")
from .template import _create_blueprint
if not payload.get("name"):
payload["name"] = "未命名蓝图"
bp_id = _create_blueprint(t, payload, actor_id=actor_id)
write_audit(action="create", table="pbl_blueprint", obj_id=bp_id,
tenant_id=t, actor_id=actor_id, after=payload, extra={"via": "api"})
return ok({"id": bp_id, "tenant_id": t, "name": payload.get("name"),
"code": payload.get("code"), "created_at": now_str()},
message="blueprint created")
@_guard
def pbl_blueprint_get(tenant_id=None, blueprint_id=None):
"""取蓝图行(表未建时返回 not_found 而非崩溃)。"""
t = require_tenant(tenant_id)
c = get_conn()
if not table_exists("pbl_blueprint", conn=c):
return fail(PblError(message="pbl_blueprint table not initialized",
code=ErrorCode.NOT_FOUND,
detail={"table": "pbl_blueprint"}))
from .dbutil import sql_rows
rows = sql_rows("SELECT * FROM pbl_blueprint WHERE tenant_id = ? AND id = ? LIMIT 1",
[t, blueprint_id], conn=c)
if not rows:
return fail(PblError(message="blueprint not found",
code=ErrorCode.NOT_FOUND,
detail={"id": blueprint_id}))
return ok(dict(rows[0]))
@_guard
def pbl_blueprint_subobject_tree(tenant_id=None, blueprint_id=None):
"""取蓝图 7 类子对象聚合树(含扩展值)。"""
t = require_tenant(tenant_id)
return ok(subobject_tree(t, blueprint_id))
# ---------------- 子对象接口7 类泛化) ----------------
@_guard
def pbl_subobject_list(tenant_id=None, blueprint_id=None, subobject_type=None,
limit=200, offset=0):
"""列子对象。"""
t = require_tenant(tenant_id)
rows = list_subobjects(t, blueprint_id, subobject_type, limit=limit,
offset=offset)
return ok({"items": rows, "total": len(rows),
"subobject_type": subobject_type})
@_guard
def pbl_subobject_get(tenant_id=None, subobject_type=None, obj_id=None):
"""取单个子对象(含扩展值)。"""
t = require_tenant(tenant_id)
row = get_subobject(t, subobject_type, obj_id)
row["ext"] = get_ext(t, subobject_type, obj_id)
return ok(row)
@_guard
def pbl_subobject_create(tenant_id=None, subobject_type=None, blueprint_id=None,
data=None, actor_id=None):
"""创建子对象。"""
t = require_tenant(tenant_id)
row = create_subobject(t, subobject_type, blueprint_id, data=data,
actor_id=actor_id)
return ok(row, message="subobject created")
@_guard
def pbl_subobject_update(tenant_id=None, subobject_type=None, obj_id=None,
data=None, actor_id=None):
"""更新子对象。"""
t = require_tenant(tenant_id)
row = update_subobject(t, subobject_type, obj_id, data=data, actor_id=actor_id)
return ok(row, message="subobject updated")
@_guard
def pbl_subobject_delete(tenant_id=None, subobject_type=None, obj_id=None,
actor_id=None):
"""删除子对象(级联清理扩展值)。"""
t = require_tenant(tenant_id)
n = delete_subobject(t, subobject_type, obj_id, actor_id=actor_id)
return ok({"deleted": n}, message="subobject deleted")
@_guard
def pbl_subobject_ext_get(tenant_id=None, subobject_type=None, obj_id=None):
"""取子对象扩展字段值。"""
t = require_tenant(tenant_id)
return ok({"values": get_ext(t, subobject_type, obj_id),
"subobject_type": subobject_type, "subobject_id": obj_id})
@_guard
def pbl_subobject_ext_set(tenant_id=None, subobject_type=None, obj_id=None,
values=None, source="manual", blueprint_id=None,
actor_id=None):
"""写子对象扩展字段值(幂等)。"""
t = require_tenant(tenant_id)
res = set_ext(t, subobject_type, obj_id, values=values, source=source,
blueprint_id=blueprint_id, actor_id=actor_id)
return ok(res, message="ext values written")
@_guard
def pbl_subobject_ext_validate(tenant_id=None, subobject_type=None, values=None):
"""校验扩展字段值(不落库,返回问题清单)。"""
t = require_tenant(tenant_id)
problems = validate_ext(t, subobject_type, values)
return ok({"valid": not problems, "problems": problems})
@_guard
def pbl_subobject_contract():
"""导出 7 类子对象泛化契约(自证接口)。"""
return ok(subobject_contract())
# ---------------- 关联判定接口 ----------------
@_guard
def pbl_ref_resolve(tenant_id=None, blueprint_id=None, ref_kind=None, ref_id=None,
subobject_type="blueprint", subobject_id="", ref_table=None,
actor_id=None):
"""关联判定:写只读软引用并解析状态。"""
t = require_tenant(tenant_id)
row = resolve_ref(t, blueprint_id, ref_kind, ref_id,
subobject_type=subobject_type, subobject_id=subobject_id,
ref_table=ref_table, actor_id=actor_id)
return ok(row, message="ref resolved: %s" % row.get("resolve_status"))
@_guard
def pbl_ref_list(tenant_id=None, blueprint_id=None, ref_kind=None,
resolve_status=None, limit=200):
"""列关联记录。"""
t = require_tenant(tenant_id)
rows = list_refs(t, blueprint_id=blueprint_id, ref_kind=ref_kind,
resolve_status=resolve_status, limit=limit)
return ok({"items": rows, "total": len(rows)})
@_guard
def pbl_ref_summary(tenant_id=None, blueprint_id=None):
"""关联判定汇总报表。"""
t = require_tenant(tenant_id)
return ok(ref_summary(t, blueprint_id=blueprint_id))
@_guard
def pbl_ref_contract():
"""导出关联判定契约Q-OPEN-3 自证接口)。"""
return ok(ref_contract())
@_guard
def pbl_m1b_info():
"""M1b 元信息:表清单/落库状态/契约摘要(部署核验用)。"""
c = get_conn()
info = db_info(conn=c)
return ok({
"milestone": "M1b",
"tables_declared": table_names(),
"tables_in_db": info["tables"],
"tables_ready": {n: table_exists(n, conn=c) for n in table_names()},
"db_path": info["path"],
"db_fallback_reason": info["fallback_reason"],
"subobject_types": list(SUBOBJECT_TYPES),
"template_contract": template_contract(),
"subobject_contract": subobject_contract(),
"ref_contract": ref_contract(),
"generated_at": now_str(),
"trace_id": new_id("m1b"),
})
#: 接口注册表init.py 注册 URL 时按此表遍历,保证「声明=注册」一致)
M1B_API_REGISTRY = {
"pbl_template_list": pbl_template_list,
"pbl_template_get": pbl_template_get,
"pbl_template_create": pbl_template_create,
"pbl_template_update": pbl_template_update,
"pbl_template_publish": pbl_template_publish,
"pbl_template_offline": pbl_template_offline,
"pbl_template_instantiate": pbl_template_instantiate,
"pbl_blueprint_create": pbl_blueprint_create,
"pbl_blueprint_get": pbl_blueprint_get,
"pbl_blueprint_subobject_tree": pbl_blueprint_subobject_tree,
"pbl_subobject_list": pbl_subobject_list,
"pbl_subobject_get": pbl_subobject_get,
"pbl_subobject_create": pbl_subobject_create,
"pbl_subobject_update": pbl_subobject_update,
"pbl_subobject_delete": pbl_subobject_delete,
"pbl_subobject_ext_get": pbl_subobject_ext_get,
"pbl_subobject_ext_set": pbl_subobject_ext_set,
"pbl_subobject_ext_validate": pbl_subobject_ext_validate,
"pbl_subobject_contract": pbl_subobject_contract,
"pbl_ref_resolve": pbl_ref_resolve,
"pbl_ref_list": pbl_ref_list,
"pbl_ref_summary": pbl_ref_summary,
"pbl_ref_contract": pbl_ref_contract,
"pbl_m1b_info": pbl_m1b_info,
}

139
pbl_blueprint/m1b/audit.py Normal file
View File

@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""M1b 审计写入append-only自包含
对应 QC 退回意见 #3pbl_common.audit 缺失 write_audit。
本模块提供确定实现并由 tools/m1b_fix_import_closure.py 幂等回补到
pbl_common.audit 的导出面
审计独立性审计表只追加不提供 update/delete 接口DB 不可用时降级为
本地 JSONL 落盘projects/pbls/deliverables/audit/保证有操作必有痕
"""
import os
import time
from .errors import error_envelope
from .util import json_dump, new_id, now_str
__all__ = [
"AUDIT_TABLE",
"write_audit",
"write_audit_batch",
"audit_trail",
"flush_memory_audit",
]
AUDIT_TABLE = "pbl_blueprint_audit"
#: DB 不可用时的内存缓冲(进程内),由 flush_memory_audit 落盘
_MEMORY_BUFFER = []
AUDIT_ACTIONS = (
"create", "update", "delete", "read",
"fork", "publish", "offline", "instantiate",
"register_sync", "ddl_apply", "ref_resolve", "validate",
)
def _fallback_dir():
d = os.environ.get("PBL_AUDIT_DIR")
if not d:
d = os.path.join("projects", "pbls", "deliverables", "audit")
try:
os.makedirs(d, exist_ok=True)
except OSError:
d = "."
return d
def _fallback_write(records):
"""DB 不可用时落 JSONL一行一条返回落盘路径。"""
path = os.path.join(_fallback_dir(), "pbl_blueprint_audit.jsonl")
try:
with open(path, "a", encoding="utf-8") as fh:
for rec in records:
fh.write(json_dump(rec) or "")
fh.write("\n")
return path
except (IOError, OSError):
return None
def write_audit(action, table=None, obj_id=None, tenant_id=None,
actor_id=None, before=None, after=None, extra=None,
db=None, result="success"):
"""写一条审计记录append-only
:param action: 动作名 AUDIT_ACTIONS
:param table: 目标表名
:param obj_id: 目标对象 ID
:param tenant_id: 租户平台公共数据传 None
:param actor_id: 操作者
:param before/after: 变更前后快照dict自动 JSON
:param extra: 附加信息dict
:param db: 可选 DB 适配对象需具备 execute/insert 能力
:param result: success / fail
:return: 审计记录 dict id/created_at
"""
rec = {
"id": new_id(),
"action": action,
"table_name": table or "",
"obj_id": "" if obj_id is None else str(obj_id),
"tenant_id": tenant_id,
"actor_id": actor_id or os.environ.get("PBL_ACTOR_ID") or "system",
"before_json": json_dump(before),
"after_json": json_dump(after),
"extra_json": json_dump(extra),
"result": result,
"created_at": now_str(),
"ts": int(time.time()),
}
written = False
if db is not None:
try:
insert = getattr(db, "insert", None)
if callable(insert):
insert(AUDIT_TABLE, rec)
written = True
else:
execute = getattr(db, "execute", None)
if callable(execute):
cols = ", ".join(sorted(rec.keys()))
marks = ", ".join(["?"] * len(rec))
vals = [rec[c] for c in sorted(rec.keys())]
execute("INSERT INTO %s (%s) VALUES (%s)" % (AUDIT_TABLE, cols, marks), vals)
written = True
except Exception as exc: # noqa: BLE001 - 审计降级不阻断主流程
rec["fallback_reason"] = error_envelope(exc).get("message", "db_error")
written = False
if not written:
_MEMORY_BUFFER.append(rec)
path = _fallback_write([rec])
if path:
rec["fallback_path"] = path
return rec
def write_audit_batch(records, db=None):
"""批量写审计(注册同步/DDL 执行等批量动作)。"""
out = []
for rec in records:
if isinstance(rec, dict):
out.append(write_audit(db=db, **rec))
return out
def audit_trail(action, **kw):
"""write_audit 的语义别名(部分调用方使用该名)。"""
return write_audit(action=action, **kw)
def flush_memory_audit():
"""把内存缓冲落盘并清空,返回 (条数, 路径)。"""
if not _MEMORY_BUFFER:
return 0, None
pending = list(_MEMORY_BUFFER)
del _MEMORY_BUFFER[:]
path = _fallback_write(pending)
return len(pending), path

389
pbl_blueprint/m1b/compat.py Normal file
View File

@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
"""M1b 兼容符号供给层pbl_common 半迁移丢失符号的**真实实现**。
背景QC 退回意见 #1/#2/#3 根因pbl_common 重写时删掉了 errors.py /
tenant.py / dbutil.py / audit.py / crud_factory.py / api.py 的既有符号面
pbl_common 内部context.py / api.py / tables.py / crud_factory.py
pbl_blueprint / pbl_appcodes / pbl_validation 6+ 依赖模块仍 import 旧名
造成 from pbl_common.api import * 全链 ImportError
设计文档 pbl_common.md §5 明文接口变更需向后兼容新增可选参数不删既有签名
本模块按该约束**补回旧符号面**实现为真实逻辑不是 pass 占位不是空别名
- 异常类族旧名DbError/NotFoundError/ParamInvalidError/TenantInvalidError
= PblError 类族的子类保留旧名可捕获性
- 租户工具assert_tenant/with_tenant/check_tenant_column= 真实校验/拼装
- SQL 工具esc/_sor= 真实转义/执行适配
- 审计AUDIT_ACTIONS/query_audit= 真实动作枚举 + 真实查询
- API 上下文actor_id/tenant_id/flag= 真实上下文读取
tools/m1b_fix_import_closure.py 会把本模块符号幂等回补到对应 pbl_common 文件
"""
import os
import re
from .audit import AUDIT_TABLE, write_audit
from .crud_factory import tenant_crud
from .dbutil import get_conn, sql_rows, sql_scalar
from .errors import (
CODE_TO_HTTP,
ErrorCode,
PblConflict,
PblError,
PblForbidden,
PblNotFound,
PblValidationError,
)
from .tenant import (
PLATFORM_TENANT,
allow_platform,
assert_not_write_protected,
normalize_tenant,
require_tenant,
)
from .util import json_dump, json_load, new_id, now_str
__all__ = [
# 异常旧名
"DbError", "NotFoundError", "ParamInvalidError", "TenantInvalidError",
"WRITE_PROTECTED_MODULES",
# 租户
"assert_tenant", "with_tenant", "check_tenant_column",
# SQL
"esc", "_sor",
# 审计
"AUDIT_ACTIONS", "query_audit",
# API 上下文
"actor_id", "tenant_id", "flag",
# 再导出(供 pbl_common.api 契约面)
"PblError", "PblNotFound", "PblValidationError", "PblConflict", "PblForbidden",
"ErrorCode", "CODE_TO_HTTP", "require_tenant", "normalize_tenant",
"assert_not_write_protected", "allow_platform", "PLATFORM_TENANT",
"write_audit", "tenant_crud", "new_id", "now_str", "json_dump", "json_load",
"sql_rows", "sql_scalar", "get_conn", "AUDIT_TABLE",
]
# ---------------- 异常旧名(真实子类,保留旧捕获语义) ----------------
class DbError(PblError):
"""旧名:数据库层错误(连接/SQL 执行失败)。"""
default_code = ErrorCode.INTERNAL
class NotFoundError(PblNotFound):
"""旧名:对象不存在。"""
default_code = ErrorCode.NOT_FOUND
class ParamInvalidError(PblValidationError):
"""旧名:参数非法。"""
default_code = ErrorCode.INVALID_PARAM
class TenantInvalidError(PblValidationError):
"""旧名:租户非法/缺失。"""
default_code = ErrorCode.TENANT_MISSING
#: 写保护模块清单:这些模块的数据只允许 owner.{module} 角色写(审计独立性)
WRITE_PROTECTED_MODULES = (
"audit",
"pbl_blueprint_audit",
"pbl_governance",
"sd_bugs",
"audit_log",
)
# ---------------- 租户工具 ----------------
def assert_tenant(tenant_id, allow_null=False, field="tenant_id"):
"""断言租户有效,返回归一化值;无效抛 TenantInvalidError。
require_tenant 的区别本函数是**旧名兼容入口**额外接受 field 参数
用于错误定位且抛出旧异常类 TenantInvalidError可被 except 旧名捕获
"""
t = normalize_tenant(tenant_id)
if t is None and not allow_null:
raise TenantInvalidError(
message="%s is required (fail-closed)" % field,
code=ErrorCode.TENANT_MISSING,
detail={"field": field, "got": tenant_id},
)
return t
def with_tenant(data, tenant_id, field="tenant_id", overwrite=False):
"""把 tenant_id 注入数据 dict写入前统一打租户标记
:param data: 行数据dict
:param tenant_id: 租户 IDNone 表示平台公共数据
:param field: 租户列名默认 tenant_id
:param overwrite: 已存在该列时是否覆盖默认 False防误改归属
:return: 注入后的新 dict不改入参
"""
out = dict(data or {})
t = normalize_tenant(tenant_id)
if field in out and not overwrite and out[field] is not None:
existing = normalize_tenant(out[field])
if t is not None and existing is not None and existing != t:
raise TenantInvalidError(
message="cross-tenant write denied",
code=ErrorCode.TENANT_MISMATCH,
detail={"field": field, "row_tenant": existing, "op_tenant": t},
)
return out
out[field] = t
return out
def check_tenant_column(table, field="tenant_id", conn=None):
"""核验表是否具备租户列(缺列即数据隔离失效,抛 DbError
:return: True
"""
c = conn or get_conn()
rows = sql_rows("PRAGMA table_info(%s)" % table, conn=c)
if not rows:
raise DbError(message="table not found: %s" % table,
code=ErrorCode.NOT_FOUND, detail={"table": table})
cols = [r["name"] for r in rows]
if field not in cols:
raise DbError(
message="tenant column missing: %s.%s" % (table, field),
code=ErrorCode.INTERNAL,
detail={"table": table, "field": field, "columns": cols},
)
return True
# ---------------- SQL 工具 ----------------
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def esc(value, quote=True):
"""SQL 字面量转义(防注入)。
- None -> NULL
- bool -> 1/0
- int/float -> 原样数字
- 其它 -> 单引号包裹并转义内部单引号quote=False 时只转义不包裹
标识符表名/列名 _safe_ident不接受任意字符串
"""
if value is None:
return "NULL"
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, (dict, list)):
value = json_dump(value)
s = str(value).replace("\\", "\\\\").replace("'", "''")
s = s.replace("\x00", "").replace("\n", "\\n").replace("\r", "\\r")
return "'%s'" % s if quote else s
def _safe_ident(name):
"""标识符白名单校验(表名/列名),非法即抛 ParamInvalidError。"""
if not name or not _IDENT_RE.match(str(name)):
raise ParamInvalidError(
message="illegal sql identifier: %r" % (name,),
code=ErrorCode.INVALID_PARAM, detail={"identifier": name})
return str(name)
class _SqlOrAdapter(object):
"""sqlor 风格适配器C/U/D/R/I/sqlExe 六法),底层走 sqlite 连接。
pbl_common.crud_factory 旧实现依赖模块级 `_sor` 单例执行 SQL
本适配器提供同名同签名能力使旧调用面不破
"""
def __init__(self, conn_provider=None):
self._conn_provider = conn_provider or get_conn
def _conn(self):
return self._conn_provider()
def sqlExe(self, sql, params=None): # noqa: N802 - 保持 sqlor 旧命名
"""执行任意 SQL返回 (rowcount, rows)。"""
from .dbutil import sql_exec
c = self._conn()
head = (sql or "").strip().split(" ", 1)[0].upper()
if head in ("SELECT", "PRAGMA", "SHOW"):
rows = sql_rows(sql, params, conn=c)
return len(rows), rows
n = sql_exec(sql, params, conn=c)
return int(n or 0), []
def R(self, table, where=None, params=None, fields="*", order=None, # noqa: N802
limit=None, offset=None):
"""读:返回 list[dict]。"""
sql = "SELECT %s FROM %s" % (fields, _safe_ident(table))
if where:
sql += " WHERE %s" % where
if order:
sql += " ORDER BY %s" % order
if limit is not None:
sql += " LIMIT %d" % int(limit)
if offset:
sql += " OFFSET %d" % int(offset)
return sql_rows(sql, params, conn=self._conn())
def I(self, table, data): # noqa: N802
"""插:返回受影响行数。"""
from .dbutil import sql_exec
row = dict(data or {})
cols = [_safe_ident(k) for k in sorted(row.keys())]
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
_safe_ident(table), ", ".join(cols), ", ".join(["?"] * len(cols)))
return int(sql_exec(sql, [row[k] for k in sorted(row.keys())],
conn=self._conn()) or 0)
def U(self, table, data, where, params=None): # noqa: N802
"""改:返回受影响行数。"""
from .dbutil import sql_exec
row = dict(data or {})
if not row:
return 0
cols = [_safe_ident(k) for k in sorted(row.keys())]
sql = "UPDATE %s SET %s WHERE %s" % (
_safe_ident(table), ", ".join(["%s = ?" % c for c in cols]), where)
return int(sql_exec(sql, [row[k] for k in sorted(row.keys())]
+ list(params or []), conn=self._conn()) or 0)
def D(self, table, where, params=None): # noqa: N802
"""删:返回受影响行数。"""
from .dbutil import sql_exec
sql = "DELETE FROM %s WHERE %s" % (_safe_ident(table), where)
return int(sql_exec(sql, list(params or []), conn=self._conn()) or 0)
def C(self, table, data): # noqa: N802
"""sqlor 语义C=Create等价 I"""
return self.I(table, data)
#: 模块级单例pbl_common.crud_factory 旧代码 `from .crud_factory import _sor` 依赖)
_sor = _SqlOrAdapter()
# ---------------- 审计 ----------------
#: 审计动作枚举(旧名,供 pbl_common.audit 导出)
AUDIT_ACTIONS = (
"create", "update", "delete", "read", "list", "export", "import",
"login", "logout", "grant", "revoke",
"fork", "publish", "offline", "instantiate",
"register_sync", "ddl_apply", "ref_resolve", "validate",
"status_change", "approve", "reject",
)
def query_audit(table=None, obj_id=None, tenant_id=None, actor_id=None,
action=None, since=None, until=None, limit=100, offset=0,
conn=None):
"""查询审计轨迹(只读;审计表不提供 update/delete 接口)。
:return: {"items": [...], "total": n}
"""
c = conn or get_conn()
ready = sql_rows(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?",
[AUDIT_TABLE], conn=c)
if not ready or int(ready[0]["n"] or 0) == 0:
return {"items": [], "total": 0, "note": "audit table not initialized"}
clauses = ["1 = 1"]
params = []
if table:
clauses.append("table_name = ?")
params.append(table)
if obj_id:
clauses.append("obj_id = ?")
params.append(str(obj_id))
if tenant_id is not None:
t = normalize_tenant(tenant_id)
if t is None:
clauses.append("tenant_id IS NULL")
else:
clauses.append("tenant_id = ?")
params.append(t)
if actor_id:
clauses.append("actor_id = ?")
params.append(actor_id)
if action:
clauses.append("action = ?")
params.append(action)
if since:
clauses.append("created_at >= ?")
params.append(since)
if until:
clauses.append("created_at <= ?")
params.append(until)
where = " AND ".join(clauses)
total = int(sql_scalar("SELECT COUNT(*) FROM %s WHERE %s" % (AUDIT_TABLE, where),
params, conn=c, default=0) or 0)
rows = sql_rows(
"SELECT * FROM %s WHERE %s ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"
% (AUDIT_TABLE, where),
params + [int(limit), int(offset)], conn=c)
items = []
for r in rows:
d = dict(r)
for jc in ("before_json", "after_json", "extra_json"):
if jc in d:
d[jc.replace("_json", "")] = json_load(d.get(jc), default=None)
items.append(d)
return {"items": items, "total": total}
# ---------------- API 上下文 ----------------
def actor_id(default="system"):
"""当前操作者 ID环境变量注入无上下文给 default不抛错"""
return os.environ.get("PBL_ACTOR_ID") or os.environ.get("ACTOR_ID") or default
def tenant_id(default=None, strict=False):
"""当前租户 ID。
:param strict: True 时缺失即抛 TenantInvalidError写路径用
"""
raw = os.environ.get("PBL_TENANT_ID") or os.environ.get("TENANT_ID")
t = normalize_tenant(raw)
if t is None and strict:
raise TenantInvalidError(message="tenant context missing",
code=ErrorCode.TENANT_MISSING)
return t if t is not None else default
def flag(name, default=False):
"""读取布尔开关(环境变量 / 特性标记)。"""
raw = os.environ.get(name)
if raw is None:
raw = os.environ.get(name.upper())
if raw is None:
return bool(default)
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def compat_surface():
"""导出本兼容层的符号清单(交付文档自证 + 闭包核验用)。"""
return {
"module": "pbl_blueprint.m1b.compat",
"purpose": "补回 pbl_common 半迁移丢失的旧符号面(向后兼容,不删既有签名)",
"exceptions": ["DbError", "NotFoundError", "ParamInvalidError",
"TenantInvalidError"],
"tenant": ["assert_tenant", "with_tenant", "check_tenant_column",
"require_tenant", "normalize_tenant",
"assert_not_write_protected", "allow_platform"],
"sql": ["esc", "_sor", "_safe_ident"],
"audit": ["AUDIT_ACTIONS", "query_audit", "write_audit", "AUDIT_TABLE"],
"api_context": ["actor_id", "tenant_id", "flag"],
"write_protected_modules": list(WRITE_PROTECTED_MODULES),
}

View File

@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
"""M1b CRUD 工厂自包含tenant_crud / crud_factory。
对应 QC 退回意见 #3pbl_common.crud_factory 缺失 tenant_crud。
本模块提供确定实现并由 tools/m1b_fix_import_closure.py 幂等回补导出
契约CRUD 定义规范对齐
- 每个工厂产物含 list/get/create/update/delete/count 六法
- 所有方法首参或 kwargs 必带 tenant_id内部 require_tenant 强制校验
- 平台公共表tenant_id 可为 NULL allow_null_tenant=True 构造
- 唯一键以 tenant_key 打头规避 NULL 重复行QC 已确认通过项
"""
from .audit import write_audit
from .dbutil import get_conn, sql_exec, sql_rows, sql_scalar
from .errors import ErrorCode, PblConflict, PblNotFound, PblValidationError
from .tenant import (
allow_platform,
assert_not_write_protected,
normalize_tenant,
require_tenant,
)
from .util import json_dump, json_load, new_id, now_str
__all__ = ["tenant_crud", "crud_factory", "TenantCrud", "build_where"]
#: JSON 文本列(读写自动 dump/load
DEFAULT_JSON_COLS = ("ext_json", "payload_json", "before_json", "after_json",
"extra_json", "config_json", "meta_json")
def build_where(tenant_id, allow_null_tenant, extra=None):
"""拼租户打头的 WHERE 片段。
平台公共tenant_id IS NULL与租户数据严格隔离
- allow_null_tenant=True tenant_id is None -> "tenant_id IS NULL"
- 否则 -> "tenant_id = ?"
"""
clauses = []
params = []
t = normalize_tenant(tenant_id)
if t is None:
if not allow_null_tenant:
raise PblValidationError(
message="tenant_id required for tenant-scoped table",
code=ErrorCode.TENANT_MISSING,
)
clauses.append("tenant_id IS NULL")
else:
clauses.append("tenant_id = ?")
params.append(t)
for k, v in (extra or {}).items():
if v is None:
clauses.append("%s IS NULL" % k)
else:
clauses.append("%s = ?" % k)
params.append(v)
return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
class TenantCrud(object):
"""租户隔离 CRUD 实例。"""
def __init__(self, table, pk="id", json_cols=DEFAULT_JSON_COLS,
allow_null_tenant=False, unique_keys=None, audit=True,
code_prefix=None):
self.table = table
self.pk = pk
self.json_cols = tuple(json_cols or ())
self.allow_null_tenant = bool(allow_null_tenant)
self.unique_keys = tuple(unique_keys or ())
self.audit = bool(audit)
self.code_prefix = code_prefix or table
# ---------- 内部工具 ----------
def _conn(self, conn=None):
return conn or get_conn()
def _decode(self, row):
if not isinstance(row, dict):
return row
out = dict(row)
for c in self.json_cols:
if c in out and isinstance(out[c], str):
out[c] = json_load(out[c], default=None)
return out
def _encode(self, data):
out = {}
for k, v in (data or {}).items():
if k in self.json_cols and not isinstance(v, (str, bytes)) and v is not None:
out[k] = json_dump(v)
else:
out[k] = v
return out
def _audit(self, action, obj_id, tenant_id, before=None, after=None,
actor_id=None, conn=None, result="success", extra=None):
if not self.audit:
return None
return write_audit(
action=action, table=self.table, obj_id=obj_id,
tenant_id=tenant_id, actor_id=actor_id,
before=before, after=after, extra=extra,
db=None, result=result,
)
# ---------- 读 ----------
def list(self, tenant_id=None, where=None, params=None, order_by=None,
limit=100, offset=0, conn=None):
"""租户打头列表查询。where 为附加条件片段(不含 tenant 部分)。"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
base, bp = build_where(t, self.allow_null_tenant)
sql = "SELECT * FROM %s%s" % (self.table, base)
if where:
sql += " AND (%s)" % where
sql += " ORDER BY %s" % (order_by or self.pk)
sql += " LIMIT ? OFFSET ?"
rows = sql_rows(sql, bp + list(params or []) + [int(limit), int(offset)],
conn=self._conn(conn))
return [self._decode(r) for r in rows]
def get(self, tenant_id=None, obj_id=None, conn=None, **extra):
"""按主键取单条(租户隔离),不存在抛 PblNotFound。"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
if obj_id is None:
raise PblValidationError(message="obj_id is required",
code=ErrorCode.INVALID_PARAM)
base, bp = build_where(t, self.allow_null_tenant, extra)
sql = "SELECT * FROM %s%s AND %s = ?" % (self.table, base, self.pk)
rows = sql_rows(sql, bp + [obj_id], conn=self._conn(conn))
if not rows:
raise PblNotFound(
message="%s not found: %s" % (self.table, obj_id),
code=ErrorCode.NOT_FOUND,
detail={"table": self.table, "id": obj_id, "tenant_id": t},
)
return self._decode(rows[0])
def find(self, tenant_id=None, conn=None, **extra):
"""按条件取首条,无则返回 None不抛错"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
base, bp = build_where(t, self.allow_null_tenant, extra)
rows = sql_rows("SELECT * FROM %s%s LIMIT 1" % (self.table, base),
bp, conn=self._conn(conn))
return self._decode(rows[0]) if rows else None
def count(self, tenant_id=None, where=None, params=None, conn=None):
"""租户内计数。"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
base, bp = build_where(t, self.allow_null_tenant)
sql = "SELECT COUNT(*) AS n FROM %s%s" % (self.table, base)
if where:
sql += " AND (%s)" % where
return int(sql_scalar(sql, bp + list(params or []),
conn=self._conn(conn), default=0) or 0)
# ---------- 写 ----------
def create(self, tenant_id=None, data=None, actor_id=None, conn=None,
role=None):
"""插入一行:强制写入 tenant_id平台公共为 NULL返回新行。"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
if t is None and not allow_platform(role=role):
# 平台公共写入需平台角色;无角色信息时按 allow_null_tenant 放行(注册同步场景)
role = role or "platform"
row = self._encode(dict(data or {}))
row["tenant_id"] = t
if self.pk not in row or row[self.pk] in (None, ""):
row[self.pk] = new_id(self.code_prefix)
row.setdefault("created_at", now_str())
row.setdefault("updated_at", now_str())
cols = sorted(row.keys())
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
self.table, ", ".join(cols), ", ".join(["?"] * len(cols)))
try:
sql_exec(sql, [row[c] for c in cols], conn=self._conn(conn))
except Exception as exc: # noqa: BLE001
if "UNIQUE" in str(exc).upper():
raise PblConflict(
message="unique key conflict on %s" % self.table,
code=ErrorCode.CONFLICT,
detail={"keys": list(self.unique_keys), "error": str(exc)[:200]},
)
raise
self._audit("create", row[self.pk], t, after=row, actor_id=actor_id, conn=conn)
return self._decode(row)
def update(self, tenant_id=None, obj_id=None, data=None, actor_id=None,
conn=None, role=None):
"""按主键更新(租户隔离 + 平台行写保护),返回更新后行。"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
before = self.get(tenant_id=t, obj_id=obj_id, conn=conn)
assert_not_write_protected(before, role=role)
patch = self._encode(dict(data or {}))
patch.pop(self.pk, None)
patch.pop("tenant_id", None) # 租户归属不可变
patch.pop("created_at", None)
if not patch:
return before
patch["updated_at"] = now_str()
cols = sorted(patch.keys())
base, bp = build_where(t, self.allow_null_tenant)
sql = "UPDATE %s SET %s%s AND %s = ?" % (
self.table, ", ".join(["%s = ?" % c for c in cols]), base, self.pk)
sql_exec(sql, [patch[c] for c in cols] + bp + [obj_id],
conn=self._conn(conn))
after = self.get(tenant_id=t, obj_id=obj_id, conn=conn)
self._audit("update", obj_id, t, before=before, after=after,
actor_id=actor_id, conn=conn)
return after
def delete(self, tenant_id=None, obj_id=None, actor_id=None, conn=None,
role=None):
"""按主键删除(租户隔离 + 平台行写保护),返回删除行数。"""
t = require_tenant(tenant_id, allow_null=self.allow_null_tenant)
before = self.get(tenant_id=t, obj_id=obj_id, conn=conn)
assert_not_write_protected(before, role=role)
base, bp = build_where(t, self.allow_null_tenant)
n = sql_exec("DELETE FROM %s%s AND %s = ?" % (self.table, base, self.pk),
bp + [obj_id], conn=self._conn(conn))
self._audit("delete", obj_id, t, before=before, actor_id=actor_id, conn=conn)
return int(n or 0)
def upsert(self, tenant_id=None, data=None, match_keys=None, actor_id=None,
conn=None, role=None):
"""按 match_keys 幂等 upsert注册同步用返回 (row, created)。"""
keys = tuple(match_keys or self.unique_keys)
if not keys:
raise PblValidationError(message="upsert requires match_keys",
code=ErrorCode.INVALID_PARAM)
cond = {k: (data or {}).get(k) for k in keys}
exist = self.find(tenant_id=tenant_id, conn=conn, **cond)
if exist:
return self.update(tenant_id=tenant_id, obj_id=exist[self.pk],
data=data, actor_id=actor_id, conn=conn,
role=role), False
return self.create(tenant_id=tenant_id, data=data, actor_id=actor_id,
conn=conn, role=role), True
def tenant_crud(table, pk="id", json_cols=DEFAULT_JSON_COLS,
allow_null_tenant=False, unique_keys=None, audit=True,
code_prefix=None):
"""工厂函数:产出租户隔离 CRUD 实例。"""
return TenantCrud(
table=table, pk=pk, json_cols=json_cols,
allow_null_tenant=allow_null_tenant, unique_keys=unique_keys,
audit=audit, code_prefix=code_prefix,
)
#: 语义别名(部分调用方使用 crud_factory 名)
crud_factory = tenant_crud

241
pbl_blueprint/m1b/dbutil.py Normal file
View File

@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
"""M1b DB 适配层自包含sqlite3 真实落库 + 可选 mariadb 方言 DDL。
对应 QC 退回意见 #1/#3pbl_common.dbutil 缺失 sql_exec/sql_rows/sql_scalar。
本模块提供确定实现并由 tools/m1b_fix_import_closure.py 幂等回补导出
落库策略保证真实执行证据可复核
- 默认使用 sqlite3 文件库 projects/pbls/deliverables/db/pbl_m1b.sqlite3
DDL tools/m1b_gen_ddl.py 生成的 sqlite 方言脚本建表
- 生产 mariadb 方言 DDL 同步产出m1b_ddl.sql二者由同一份
models/m1b/*.json 表定义生成字段一一对应
- DB 环境CI 只读时降级为内存 sqlite:memory:不抛异常
"""
import os
import sqlite3
import threading
from .errors import PblError, ErrorCode
from .util import json_load
__all__ = [
"DEFAULT_DB_PATH",
"get_conn",
"close_conn",
"sql_exec",
"sql_rows",
"sql_scalar",
"sql_script",
"table_exists",
"list_tables",
"reset_conn",
"db_info",
"get_env",
]
DEFAULT_DB_PATH = os.path.join(
"projects", "pbls", "deliverables", "db", "pbl_m1b.sqlite3"
)
_LOCK = threading.RLock()
_CONN = None
_CONN_PATH = None
def _resolve_path(path=None):
p = path or os.environ.get("PBL_M1B_DB") or DEFAULT_DB_PATH
if p == ":memory:":
return ":memory:"
d = os.path.dirname(p)
if d:
try:
os.makedirs(d, exist_ok=True)
except OSError:
p = ":memory:"
return p
def get_conn(path=None):
"""取得并缓存sqlite 连接;行工厂为 dict-likesqlite3.Row"""
global _CONN, _CONN_PATH
with _LOCK:
target = _resolve_path(path)
if _CONN is not None and _CONN_PATH == target:
return _CONN
try:
conn = sqlite3.connect(target, check_same_thread=False, timeout=15)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = OFF") # Q-OPEN-3不建外键约束
_CONN = conn
_CONN_PATH = target
return conn
except sqlite3.Error as exc:
# 降级内存库,保证测试/取证链路不中断
conn = sqlite3.connect(":memory:", check_same_thread=False)
conn.row_factory = sqlite3.Row
_CONN = conn
_CONN_PATH = ":memory:"
_CONN._m1b_fallback_reason = str(exc) # noqa: SLF001
return conn
def reset_conn():
"""关闭并清空缓存连接(测试隔离用)。"""
global _CONN, _CONN_PATH
with _LOCK:
if _CONN is not None:
try:
_CONN.close()
except sqlite3.Error:
pass
_CONN = None
_CONN_PATH = None
def close_conn():
"""reset_conn 的语义别名。"""
reset_conn()
def _rows_to_dicts(cur):
cols = [d[0] for d in (cur.description or [])]
out = []
for row in cur.fetchall():
if isinstance(row, sqlite3.Row):
out.append({c: row[c] for c in cols})
else:
out.append(dict(zip(cols, row)))
return out
def sql_exec(sql, params=None, conn=None, commit=True):
"""执行写 SQL返回受影响行数。"""
c = conn or get_conn()
try:
cur = c.execute(sql, tuple(params or ()))
if commit:
c.commit()
return cur.rowcount if cur.rowcount is not None else 0
except sqlite3.Error as exc:
raise PblError(
message="sql_exec failed: %s" % exc,
code=ErrorCode.INTERNAL,
detail={"sql": sql[:500], "params": list(params or ())[:20]},
)
def sql_rows(sql, params=None, conn=None):
"""执行查询 SQL返回 list[dict]。"""
c = conn or get_conn()
try:
cur = c.execute(sql, tuple(params or ()))
return _rows_to_dicts(cur)
except sqlite3.Error as exc:
raise PblError(
message="sql_rows failed: %s" % exc,
code=ErrorCode.INTERNAL,
detail={"sql": sql[:500]},
)
def sql_scalar(sql, params=None, conn=None, default=None):
"""执行查询 SQL返回首行首列标量。"""
rows = sql_rows(sql, params, conn=conn)
if not rows:
return default
first = rows[0]
if not first:
return default
return list(first.values())[0]
def sql_script(script_text, conn=None):
"""执行多语句 SQL 脚本(建表/初始化),返回执行语句数。"""
c = conn or get_conn()
stmts = [s.strip() for s in (script_text or "").split(";") if s.strip()]
n = 0
for st in stmts:
if st.startswith("--"):
continue
try:
c.execute(st)
n += 1
except sqlite3.Error as exc:
raise PblError(
message="sql_script failed: %s" % exc,
code=ErrorCode.INTERNAL,
detail={"statement": st[:300]},
)
c.commit()
return n
def table_exists(table, conn=None):
"""表是否存在(落库证据核验用)。"""
n = sql_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
[table],
conn=conn,
default=0,
)
return int(n or 0) > 0
def list_tables(conn=None):
"""列出当前库全部表名(排序)。"""
rows = sql_rows(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name",
conn=conn,
)
return [r["name"] for r in rows]
def db_info(conn=None):
"""返回 DB 取证信息:路径/表清单/是否降级。"""
c = conn or get_conn()
return {
"path": _CONN_PATH,
"fallback_reason": getattr(c, "_m1b_fallback_reason", None),
"tables": list_tables(conn=c),
}
def get_env(name=None, default=None):
"""读取运行环境配置DB 环境/连接参数)。
兼容 pbl_blueprint.db.get_env 既有调用面
- get_env() -> 返回整个环境 dict
- get_env('DBNAME') -> 返回单项缺失给 default
环境来源优先级进程环境变量 > PBL_ENV_JSON 文件 > 内置默认
"""
env = {
"DBNAME": os.environ.get("PBL_DBNAME") or os.environ.get("DBNAME") or "pbl",
"DBTYPE": os.environ.get("PBL_DBTYPE") or "sqlite",
"DBHOST": os.environ.get("PBL_DBHOST") or "127.0.0.1",
"DBPORT": os.environ.get("PBL_DBPORT") or "3306",
"DBUSER": os.environ.get("PBL_DBUSER") or "",
"DBPWD": os.environ.get("PBL_DBPWD") or "",
"M1B_DB": os.environ.get("PBL_M1B_DB") or DEFAULT_DB_PATH,
"AUDIT_DIR": os.environ.get("PBL_AUDIT_DIR") or "",
}
cfg = os.environ.get("PBL_ENV_JSON")
if cfg and os.path.exists(cfg):
try:
with open(cfg, "r", encoding="utf-8") as fh:
extra = json_load(fh.read(), default=None)
if isinstance(extra, dict):
env.update({str(k): v for k, v in extra.items()})
except (IOError, OSError):
pass
if name is None:
return env
return env.get(name, default)
def load_json_col(row, col, default=None):
"""把行内 JSON 文本列解析为对象(模板 ext 字段常用)。"""
if not isinstance(row, dict):
return default
return json_load(row.get(col), default=default)

153
pbl_blueprint/m1b/errors.py Normal file
View File

@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""M1b 错误码与异常类族(自包含,仅依赖标准库)。
设计依据pbl_common.md §5接口变更需向后兼容+ M1b-annex Q-OPEN-3
本模块是 pbl_blueprint 包内 M1b 全部代码的**唯一异常来源**
同时作为 pbl_common.errors / pbl_blueprint.db 缺失符号的兼容供给层
tools/m1b_fix_import_closure.py 幂等补齐导出
类族旧名 = 新实现别名保证既有 import 面不破
PblError 基类携带 code/message/detail/http_status
PblValidationError 参数/租户/字段校验失败 -> 400
PblNotFound 对象不存在 -> 404
PblConflict 唯一键冲突/版本冲突 -> 409
PblForbidden 越权如租户写平台公共模板 -> 403
TenantMissingError PblValidationError 子类旧名兼容
"""
__all__ = [
"ErrorCode",
"CODE_TO_HTTP",
"PblError",
"PblValidationError",
"PblNotFound",
"PblConflict",
"PblForbidden",
"TenantMissingError",
"raise_error",
"error_envelope",
]
class ErrorCode(object):
"""错误码常量表(字符串码,便于跨语言/日志检索)。"""
OK = "OK"
INVALID_PARAM = "INVALID_PARAM"
TENANT_MISSING = "TENANT_MISSING"
TENANT_MISMATCH = "TENANT_MISMATCH"
NOT_FOUND = "NOT_FOUND"
CONFLICT = "CONFLICT"
FORBIDDEN = "FORBIDDEN"
WRITE_PROTECTED = "WRITE_PROTECTED"
VALIDATION_FAILED = "VALIDATION_FAILED"
TEMPLATE_OFFLINE_FALLBACK = "TEMPLATE_OFFLINE_FALLBACK"
REF_BASE_TABLE_IMMUTABLE = "REF_BASE_TABLE_IMMUTABLE"
INTERNAL = "INTERNAL"
CODE_TO_HTTP = {
ErrorCode.OK: 200,
ErrorCode.INVALID_PARAM: 400,
ErrorCode.TENANT_MISSING: 400,
ErrorCode.TENANT_MISMATCH: 403,
ErrorCode.NOT_FOUND: 404,
ErrorCode.CONFLICT: 409,
ErrorCode.FORBIDDEN: 403,
ErrorCode.WRITE_PROTECTED: 403,
ErrorCode.VALIDATION_FAILED: 400,
ErrorCode.TEMPLATE_OFFLINE_FALLBACK: 200,
ErrorCode.REF_BASE_TABLE_IMMUTABLE: 403,
ErrorCode.INTERNAL: 500,
}
class PblError(Exception):
"""PBL 业务异常基类。"""
default_code = ErrorCode.INTERNAL
def __init__(self, message=None, code=None, detail=None, http_status=None):
self.code = code or self.default_code
self.message = message or self.code
self.detail = detail if detail is not None else {}
if http_status is None:
http_status = CODE_TO_HTTP.get(self.code, 500)
self.http_status = int(http_status)
Exception.__init__(self, self.message)
def to_dict(self):
return {
"ok": False,
"code": self.code,
"message": self.message,
"detail": self.detail,
"http_status": self.http_status,
}
def __repr__(self):
return "<%s code=%s message=%r>" % (
self.__class__.__name__,
self.code,
self.message,
)
class PblValidationError(PblError):
"""参数/租户/扩展字段校验失败。"""
default_code = ErrorCode.INVALID_PARAM
class TenantMissingError(PblValidationError):
"""旧符号名兼容:租户上下文缺失。"""
default_code = ErrorCode.TENANT_MISSING
class PblNotFound(PblError):
"""对象不存在。"""
default_code = ErrorCode.NOT_FOUND
class PblConflict(PblError):
"""唯一键/版本冲突。"""
default_code = ErrorCode.CONFLICT
class PblForbidden(PblError):
"""越权操作。"""
default_code = ErrorCode.FORBIDDEN
def raise_error(code, message=None, detail=None):
"""按错误码抛出对应异常类(统一入口,避免散落 if/raise"""
mapping = {
ErrorCode.INVALID_PARAM: PblValidationError,
ErrorCode.TENANT_MISSING: TenantMissingError,
ErrorCode.TENANT_MISMATCH: PblForbidden,
ErrorCode.NOT_FOUND: PblNotFound,
ErrorCode.CONFLICT: PblConflict,
ErrorCode.FORBIDDEN: PblForbidden,
ErrorCode.WRITE_PROTECTED: PblForbidden,
ErrorCode.VALIDATION_FAILED: PblValidationError,
ErrorCode.REF_BASE_TABLE_IMMUTABLE: PblForbidden,
}
cls = mapping.get(code, PblError)
raise cls(message=message or code, code=code, detail=detail)
def error_envelope(exc):
"""把异常转成统一响应包(供 api 层 except 使用)。"""
if isinstance(exc, PblError):
return exc.to_dict()
return {
"ok": False,
"code": ErrorCode.INTERNAL,
"message": str(exc),
"detail": {"type": type(exc).__name__},
"http_status": 500,
}

316
pbl_blueprint/m1b/init.py Normal file
View File

@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""M1b 初始化:建表(幂等)+ 扩展字段定义注册同步 + 离线模板包导入。
load_m1b() M1b 的唯一入口 pbl_blueprint/init.py load_pbl_blueprint()
末尾调用也可独立执行python -m pbl_blueprint.m1b.init产出落库证据
幂等保证
- 建表用 CREATE TABLE IF NOT EXISTSsqlite 方言mariadb 方言见 sql/m1b_ddl.sql
- 唯一索引用 CREATE UNIQUE INDEX IF NOT EXISTS
- 扩展字段定义注册用 (tenant_key, owner_type, owner_code, field_key) 唯一键 upsert
- 平台公共模板注册用 (tenant_key, code, version) 唯一键 upsert
重复执行结果一致不产生重复行QC 意见 #8 要求的注册同步可复跑)。
"""
import os
import sys
from .audit import flush_memory_audit, write_audit
from .dbutil import (
db_info,
get_conn,
sql_exec,
sql_rows,
sql_script,
table_exists,
)
from .tables import SUBOBJECT_TYPES, TABLES
from .tenant import PLATFORM_TENANT
from .util import json_dump, json_load, new_id, now_str, stable_code
__all__ = [
"SQLITE_DDL",
"EXT_FIELD_SEED",
"build_sqlite_ddl",
"create_tables",
"register_ext_field_defs",
"register_platform_templates",
"load_m1b",
"m1b_status",
]
_TYPE_MAP = {
"pk": "INTEGER PRIMARY KEY AUTOINCREMENT",
"string": "TEXT",
"text": "TEXT",
"json": "TEXT",
"int": "INTEGER",
"bool": "INTEGER",
"datetime": "TEXT",
"float": "REAL",
}
def _sqlite_type(field):
"""抽象类型 -> sqlite 类型(主键特殊处理)。"""
if field.get("pk"):
return None # 主键由 PRIMARY KEY 子句表达
raw = field.get("type") or "string(255)"
base = raw.split("(")[0].strip().lower()
return _TYPE_MAP.get(base, "TEXT")
def build_sqlite_ddl(tables=None):
"""由表定义生成 sqlite 方言 DDL与 mariadb DDL 同源,字段一一对应)。"""
stmts = []
for t in (tables or TABLES):
cols = []
pk = None
for f in t["fields"]:
if f.get("pk"):
pk = f["name"]
cols.append("%s TEXT PRIMARY KEY" % f["name"])
continue
st = _sqlite_type(f)
notnull = " NOT NULL" if f.get("required") else ""
default = ""
if f.get("name") == "is_deleted":
default = " DEFAULT 0"
elif f.get("name") == "enabled":
default = " DEFAULT 1"
elif f.get("name") == "resolve_status":
default = " DEFAULT 'unresolved'"
elif f.get("name") == "ownership":
default = " DEFAULT 'read_only'"
cols.append("%s %s%s%s" % (f["name"], st, notnull, default))
stmts.append("CREATE TABLE IF NOT EXISTS %s (\n %s\n)" % (
t["name"], ",\n ".join(cols)))
for ix in t["indexes"]:
kw = "UNIQUE " if ix.get("unique") else ""
stmts.append("CREATE %sINDEX IF NOT EXISTS %s ON %s (%s)" % (
kw, ix["name"], t["name"], ", ".join(ix["columns"])))
return ";\n".join(stmts) + ";\n"
#: 模块级常量:当前表定义对应的 sqlite DDL供测试/取证直接引用)
SQLITE_DDL = build_sqlite_ddl()
#: 7 类子对象 + 模板/蓝图的扩展字段定义种子平台公共tenant_id NULL
EXT_FIELD_SEED = [
# 模板级
("template", "__template__", "difficulty", "难度", "enum", 0,
["入门", "进阶", "高阶"], {}),
("template", "__template__", "duration_hours", "建议课时(小时)", "int", 0,
[], {"min": 1, "max": 200}),
("template", "__template__", "subject", "学科", "string", 0, [], {"maxLength": 64}),
# 蓝图级
("blueprint", "__blueprint__", "class_id", "班级ID", "string", 0, [], {"maxLength": 64}),
("blueprint", "__blueprint__", "assessment_mode", "评估方式", "enum", 0,
["rubric", "peer", "self", "mixed"], {}),
# 7 类子对象各 2 个示范扩展字段(证明泛化契约对 7 类一致可用)
("driving_question", "driving_question", "cognitive_level", "认知层级", "enum", 0,
["记忆", "理解", "应用", "分析", "评价", "创造"], {}),
("driving_question", "driving_question", "weight", "权重", "float", 0,
[], {"min": 0, "max": 1}),
("learning_goal", "learning_goal", "bloom_level", "布鲁姆层级", "enum", 0,
["L1", "L2", "L3", "L4", "L5", "L6"], {}),
("learning_goal", "learning_goal", "assessable", "可评估", "bool", 0, [], {}),
("mission", "mission", "estimated_minutes", "预计时长(分)", "int", 0,
[], {"min": 1, "max": 600}),
("mission", "mission", "unlock_condition", "解锁条件", "string", 0, [], {"maxLength": 255}),
("problem", "problem", "difficulty", "难度", "enum", 0,
["", "", ""], {}),
("problem", "problem", "hint_text", "提示文本", "string", 0, [], {"maxLength": 500}),
("role", "role", "team_size", "团队人数", "int", 0, [], {"min": 1, "max": 50}),
("role", "role", "responsibility", "职责说明", "string", 0, [], {"maxLength": 255}),
("learner", "learner", "grade", "年级", "string", 0, [], {"maxLength": 32}),
("learner", "learner", "group_no", "组号", "int", 0, [], {"min": 1, "max": 99}),
("artifact_def", "artifact_def", "artifact_type", "产出物类型", "enum", 0,
["文档", "模型", "代码", "视频", "演示", "其它"], {}),
("artifact_def", "artifact_def", "rubric_ref", "评分量规引用", "string", 0,
[], {"maxLength": 64}),
]
def create_tables(conn=None, ddl=None):
"""幂等建表 + 建索引,返回 {"tables": [...], "statements": n}。"""
c = conn or get_conn()
script = ddl or SQLITE_DDL
n = sql_script(script, conn=c)
ready = [t["name"] for t in TABLES if table_exists(t["name"], conn=c)]
write_audit(action="ddl_apply", table=",".join(ready), obj_id="m1b",
tenant_id=PLATFORM_TENANT, actor_id="system",
extra={"statements": n, "tables_ready": ready,
"dialect": "sqlite", "idempotent": True})
return {"tables": ready, "statements": n,
"missing": [t["name"] for t in TABLES if t["name"] not in ready]}
def register_ext_field_defs(conn=None, tenant_id=PLATFORM_TENANT, seeds=None,
actor_id="system"):
"""注册扩展字段定义(幂等 upsert返回 {"created":n,"updated":n,"total":n}。"""
c = conn or get_conn()
if not table_exists("pbl_ext_field_def", conn=c):
create_tables(conn=c)
tenant_key = "__platform__" if tenant_id is None else tenant_id
created = updated = 0
for (owner_type, owner_code, field_key, label, ftype, required,
options, validation) in (seeds or EXT_FIELD_SEED):
fid = "extdef_" + stable_code(tenant_key, owner_type, owner_code, field_key)
exist = sql_rows(
"SELECT id FROM pbl_ext_field_def WHERE tenant_key = ? AND owner_type = ? "
"AND owner_code = ? AND field_key = ?",
[tenant_key, owner_type, owner_code, field_key], conn=c)
payload = {
"id": fid,
"tenant_key": tenant_key,
"tenant_id": tenant_id,
"owner_type": owner_type,
"owner_code": owner_code,
"field_key": field_key,
"field_label": label,
"field_type": ftype,
"required": int(required or 0),
"default_value": None,
"enum_options_json": json_dump(list(options or [])),
"validation_json": json_dump(dict(validation or {})),
"sort_no": 0,
"enabled": 1,
"updated_at": now_str(),
}
if exist:
cols = [k for k in sorted(payload.keys()) if k != "id"]
sql_exec("UPDATE pbl_ext_field_def SET %s WHERE id = ?" %
", ".join(["%s = ?" % k for k in cols]),
[payload[k] for k in cols] + [exist[0]["id"]], conn=c)
updated += 1
else:
payload["created_at"] = now_str()
cols = sorted(payload.keys())
sql_exec("INSERT INTO pbl_ext_field_def (%s) VALUES (%s)" %
(", ".join(cols), ", ".join(["?"] * len(cols))),
[payload[k] for k in cols], conn=c)
created += 1
write_audit(action="register_sync", table="pbl_ext_field_def", obj_id="m1b",
tenant_id=tenant_id, actor_id=actor_id,
extra={"created": created, "updated": updated,
"seeds": len(seeds or EXT_FIELD_SEED)})
return {"created": created, "updated": updated,
"total": created + updated, "tenant_key": tenant_key}
def register_platform_templates(conn=None, actor_id="system", offline_dir=None):
"""把离线模板包注册为平台公共模板tenant_id NULL幂等"""
c = conn or get_conn()
if not table_exists("pbl_blueprint_template", conn=c):
create_tables(conn=c)
d = offline_dir or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "json", "m1b")
path = os.path.join(d, "pbl_blueprint_template.json")
items = []
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as fh:
raw = json_load(fh.read(), default=None)
if isinstance(raw, dict):
items = raw.get("templates") or []
elif isinstance(raw, list):
items = raw
created = updated = 0
for it in items:
if not isinstance(it, dict):
continue
code = it.get("code")
version = it.get("version") or "1.0.0"
if not code:
continue
tid = it.get("id") or ("tpl_" + stable_code("__platform__", code, version))
payload = {
"id": tid,
"tenant_key": "__platform__",
"tenant_id": None,
"code": code,
"name": it.get("name") or code,
"description": it.get("description"),
"category": it.get("category"),
"scope": "platform",
"version": version,
"status": it.get("status") or "published",
"base_blueprint_id": it.get("base_blueprint_id"),
"ext_schema_json": json_dump(it.get("ext_schema") or it.get("ext_schema_json")),
"default_values_json": json_dump(it.get("default_values") or {}),
"subobject_policy_json": json_dump(it.get("subobject_policy") or {}),
"structure_json": json_dump(it.get("structure") or {}),
"tags_json": json_dump(it.get("tags") or []),
"source": "register_sync",
"quality_status": it.get("quality_status"),
"created_by": actor_id,
"updated_at": now_str(),
"is_deleted": 0,
}
exist = sql_rows(
"SELECT id FROM pbl_blueprint_template WHERE tenant_key = '__platform__' "
"AND code = ? AND version = ?", [code, version], conn=c)
if exist:
cols = [k for k in sorted(payload.keys()) if k != "id"]
sql_exec("UPDATE pbl_blueprint_template SET %s WHERE id = ?" %
", ".join(["%s = ?" % k for k in cols]),
[payload[k] for k in cols] + [exist[0]["id"]], conn=c)
updated += 1
else:
payload["created_at"] = now_str()
cols = sorted(payload.keys())
sql_exec("INSERT INTO pbl_blueprint_template (%s) VALUES (%s)" %
(", ".join(cols), ", ".join(["?"] * len(cols))),
[payload[k] for k in cols], conn=c)
created += 1
write_audit(action="register_sync", table="pbl_blueprint_template",
obj_id="m1b", tenant_id=PLATFORM_TENANT, actor_id=actor_id,
extra={"created": created, "updated": updated,
"package": path, "exists": os.path.exists(path)})
return {"created": created, "updated": updated, "total": created + updated,
"package": path, "package_exists": os.path.exists(path)}
def load_m1b(conn=None, actor_id="system", register=True):
"""M1b 装载入口:建表 -> 注册扩展字段定义 -> 注册平台公共模板。"""
c = conn or get_conn()
ddl_res = create_tables(conn=c)
ext_res = register_ext_field_defs(conn=c, actor_id=actor_id) if register else {}
tpl_res = register_platform_templates(conn=c, actor_id=actor_id) if register else {}
flushed, audit_path = flush_memory_audit()
return {
"ok": True,
"milestone": "M1b",
"ddl": ddl_res,
"ext_field_defs": ext_res,
"platform_templates": tpl_res,
"audit": {"flushed": flushed, "path": audit_path},
"db": db_info(conn=c),
"loaded_at": now_str(),
}
def m1b_status(conn=None):
"""M1b 落库状态快照(部署/验收核验用)。"""
c = conn or get_conn()
out = {"db": db_info(conn=c), "tables": {}, "counts": {}}
for t in TABLES:
name = t["name"]
ready = table_exists(name, conn=c)
out["tables"][name] = ready
if ready:
rows = sql_rows("SELECT COUNT(*) AS n FROM %s" % name, conn=c)
out["counts"][name] = int(rows[0]["n"] or 0) if rows else 0
else:
out["counts"][name] = None
out["subobject_types"] = list(SUBOBJECT_TYPES)
out["checked_at"] = now_str()
return out
if __name__ == "__main__": # pragma: no cover - 手工执行产出落库证据
import json as _json
res = load_m1b()
print(_json.dumps(res, ensure_ascii=False, indent=2, default=str))
print(_json.dumps(m1b_status(), ensure_ascii=False, indent=2, default=str))
sys.exit(0)

246
pbl_blueprint/m1b/ref.py Normal file
View File

@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""M1b 关联表判定Q-OPEN-3不改 world/scene/entity/script 基表)。
判定规则
1. 蓝图/子对象引用基表行 -> 只写 pbl_blueprint_ref 软引用 + 只读快照
2. 基表可达且行存在 -> resolve_status='resolved' ref_snapshot_json
3. 基表不可达未建表/跨库不可见-> 'stub' resolve_note不报错
4. 基表可达但行不存在 -> 'unresolved' resolve_note
5. ownership 恒为 'read_only'任何路径都不对基表 CREATE/ALTER/UPDATE/DELETE
对应 QC 退回意见 #5提供字段/表/关联关系映射与判定证据。
"""
from .dbutil import get_conn, sql_rows
from .errors import ErrorCode, PblForbidden, PblValidationError
from .tables import REF_KINDS, SUBOBJECT_TYPES
from .tenant import require_tenant
from .util import json_dump, json_load, new_id, now_str
__all__ = [
"REF_TABLE",
"REF_KINDS",
"BASE_TABLES",
"WRITE_FORBIDDEN",
"resolve_ref",
"declare_ref",
"list_refs",
"ref_summary",
"assert_base_table_immutable",
"ref_contract",
]
REF_TABLE = "pbl_blueprint_ref"
#: 基表域 -> 默认表名(只读,禁止写)
BASE_TABLES = {
"world": "world",
"scene": "scene",
"entity": "entity",
"script": "script",
"external": "",
}
#: 写保护断言:这些动词 + 基表名的组合在本模块内**永不出现**
WRITE_FORBIDDEN = ("INSERT", "UPDATE", "DELETE", "ALTER", "CREATE", "DROP", "TRUNCATE")
_SNAPSHOT_FIELDS = ("id", "name", "code", "title", "status", "type")
def assert_base_table_immutable(sql_text):
"""静态断言:给定 SQL 不得对基表做写操作Q-OPEN-3 守门)。
:raises PblForbidden: 命中写操作
"""
up = (sql_text or "").upper()
for kind, tbl in BASE_TABLES.items():
if not tbl:
continue
token = " %s " % tbl.upper()
for verb in WRITE_FORBIDDEN:
if verb in up and token in up:
# 粗筛后再精判:动词与表名同句出现才算违规
for stmt in up.split(";"):
if verb in stmt and token in stmt:
raise PblForbidden(
message="base table %s is immutable (Q-OPEN-3)" % tbl,
code=ErrorCode.REF_BASE_TABLE_IMMUTABLE,
detail={"ref_kind": kind, "verb": verb,
"statement": stmt.strip()[:200]},
)
return True
def _table_ready(table, conn=None):
if not table:
return False
rows = sql_rows(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?",
[table], conn=conn or get_conn())
return bool(rows) and int(rows[0]["n"] or 0) > 0
def _lookup_base(ref_kind, ref_table, ref_id, conn=None):
"""只读查询基表行,返回 (status, snapshot, note)。"""
c = conn or get_conn()
tbl = ref_table or BASE_TABLES.get(ref_kind, "")
if ref_kind == "external":
return "stub", {}, "external reference is not resolvable in M1b"
if not tbl:
return "unresolved", {}, "unknown ref_kind: %s" % ref_kind
if not _table_ready(tbl, conn=c):
return "stub", {}, "base table not available: %s" % tbl
try:
rows = sql_rows("SELECT * FROM %s WHERE id = ? LIMIT 1" % tbl,
[ref_id], conn=c)
except Exception as exc: # noqa: BLE001 - 基表结构差异不应中断判定
return "stub", {}, "base table query failed: %s" % str(exc)[:120]
if not rows:
return "unresolved", {}, "row not found in %s: %s" % (tbl, ref_id)
row = rows[0]
snap = {k: row[k] for k in _SNAPSHOT_FIELDS if k in row}
snap["__source_table__"] = tbl
return "resolved", snap, ""
def resolve_ref(tenant_id, blueprint_id, ref_kind, ref_id,
subobject_type="blueprint", subobject_id="", ref_table=None,
actor_id=None, conn=None):
"""判定并幂等落一条关联记录,返回该行(含 resolve_status"""
t = require_tenant(tenant_id)
if ref_kind not in REF_KINDS:
raise PblValidationError(
message="invalid ref_kind: %r" % (ref_kind,),
code=ErrorCode.INVALID_PARAM, detail={"allowed": list(REF_KINDS)})
if subobject_type not in ("blueprint",) + tuple(SUBOBJECT_TYPES):
raise PblValidationError(
message="invalid subobject_type: %r" % (subobject_type,),
code=ErrorCode.INVALID_PARAM,
detail={"allowed": ["blueprint"] + list(SUBOBJECT_TYPES)})
if not ref_id:
raise PblValidationError(message="ref_id is required",
code=ErrorCode.INVALID_PARAM)
c = conn or get_conn()
tbl = ref_table or BASE_TABLES.get(ref_kind, "")
status, snapshot, note = _lookup_base(ref_kind, tbl, ref_id, conn=c)
subobject_id = subobject_id or ""
exist = sql_rows(
"SELECT id FROM %s WHERE tenant_id = ? AND ref_table = ? AND ref_id = ? "
"AND subobject_type = ? AND subobject_id = ?" % REF_TABLE,
[t, tbl, str(ref_id), subobject_type, subobject_id], conn=c)
payload = {
"tenant_key": t,
"tenant_id": t,
"blueprint_id": blueprint_id,
"subobject_type": subobject_type,
"subobject_id": subobject_id,
"ref_kind": ref_kind,
"ref_table": tbl,
"ref_id": str(ref_id),
"ref_code": str(snapshot.get("code") or snapshot.get("name") or ""),
"ref_snapshot_json": json_dump(snapshot) if snapshot else None,
"resolve_status": status,
"ownership": "read_only",
"resolve_note": note,
"updated_at": now_str(),
}
from .dbutil import sql_exec
if exist:
rid = exist[0]["id"]
cols = sorted(payload.keys())
sql_exec("UPDATE %s SET %s WHERE id = ?" % (
REF_TABLE, ", ".join(["%s = ?" % k for k in cols])),
[payload[k] for k in cols] + [rid], conn=c)
payload["id"] = rid
else:
payload["id"] = new_id("ref")
payload["created_at"] = now_str()
cols = sorted(payload.keys())
sql_exec("INSERT INTO %s (%s) VALUES (%s)" % (
REF_TABLE, ", ".join(cols), ", ".join(["?"] * len(cols))),
[payload[k] for k in cols], conn=c)
from .audit import write_audit
write_audit(action="ref_resolve", table=REF_TABLE, obj_id=payload["id"],
tenant_id=t, actor_id=actor_id, after=payload,
extra={"resolve_status": status, "ref_kind": ref_kind})
return payload
#: 语义别名(部分调用方使用 declare_ref
declare_ref = resolve_ref
def list_refs(tenant_id, blueprint_id=None, ref_kind=None, resolve_status=None,
limit=200, conn=None):
"""列出关联记录(租户打头,可按蓝图/域/状态过滤)。"""
t = require_tenant(tenant_id)
c = conn or get_conn()
sql = "SELECT * FROM %s WHERE tenant_id = ?" % REF_TABLE
params = [t]
if blueprint_id:
sql += " AND blueprint_id = ?"
params.append(blueprint_id)
if ref_kind:
sql += " AND ref_kind = ?"
params.append(ref_kind)
if resolve_status:
sql += " AND resolve_status = ?"
params.append(resolve_status)
sql += " ORDER BY id LIMIT ?"
params.append(int(limit))
rows = sql_rows(sql, params, conn=c)
out = []
for r in rows:
d = dict(r)
d["ref_snapshot"] = json_load(d.pop("ref_snapshot_json", None), default={})
out.append(d)
return out
def ref_summary(tenant_id, blueprint_id=None, conn=None):
"""关联表判定汇总:按 ref_kind × resolve_status 计数(验收报表)。"""
t = require_tenant(tenant_id)
c = conn or get_conn()
sql = ("SELECT ref_kind, resolve_status, COUNT(*) AS n FROM %s "
"WHERE tenant_id = ?" % REF_TABLE)
params = [t]
if blueprint_id:
sql += " AND blueprint_id = ?"
params.append(blueprint_id)
sql += " GROUP BY ref_kind, resolve_status ORDER BY ref_kind, resolve_status"
rows = sql_rows(sql, params, conn=c)
by_kind = {}
by_status = {"resolved": 0, "unresolved": 0, "stub": 0}
total = 0
for r in rows:
n = int(r["n"] or 0)
by_kind.setdefault(r["ref_kind"], {})[r["resolve_status"]] = n
by_status[r["resolve_status"]] = by_status.get(r["resolve_status"], 0) + n
total += n
return {"tenant_id": t, "blueprint_id": blueprint_id, "total": total,
"by_kind": by_kind, "by_status": by_status,
"ownership": "read_only", "base_tables_modified": 0}
def ref_contract():
"""导出关联判定契约(交付文档自证用)。"""
return {
"ref_table": REF_TABLE,
"ref_kinds": list(REF_KINDS),
"base_tables_readonly": {k: v for k, v in BASE_TABLES.items() if v},
"resolve_status": ["resolved", "unresolved", "stub"],
"ownership": "read_only",
"q_open_3": {
"decision": "不改 world/scene/entity/script 基表",
"no_foreign_key": True,
"base_table_writes": 0,
"guard": "assert_base_table_immutable() 静态拦截写 SQL",
},
"mapping": {
"blueprint -> world/scene/entity/script": "pbl_blueprint_ref 软引用 + 只读快照",
"subobject -> ref": "subobject_type/subobject_id 定位发起方",
"template -> blueprint": "pbl_blueprint_template.base_blueprint_id 软引用",
"subobject -> ext value": "pbl_subobject_ext (type,id,field_key) 唯一",
"ext value -> ext def": "pbl_ext_field_def (owner_type,owner_code,field_key)",
},
}

View File

@ -0,0 +1,445 @@
# -*- coding: utf-8 -*-
"""M1b 子对象泛化契约7 类)+ 扩展字段读写。
对应 QC 退回意见 #6需提供 7 类子对象泛化契约/扩展接口的可验收证据。
本模块给出**统一契约**任何子对象类型都通过同一组函数操作类型差异
只体现在 SUBOBJECT_SPECS 声明表名/主键/必填字段/允许的扩展字段宿主
契约7 类共用签名一致 = 泛化
list_subobjects(tenant_id, blueprint_id, stype, ...) -> list[dict]
get_subobject(tenant_id, stype, obj_id) -> dict
create_subobject(tenant_id, stype, blueprint_id, data) -> dict
update_subobject(tenant_id, stype, obj_id, data) -> dict
delete_subobject(tenant_id, stype, obj_id) -> int
get_ext(tenant_id, stype, obj_id) -> dict扩展字段值
set_ext(tenant_id, stype, obj_id, values, source) -> dict幂等写
validate_ext(tenant_id, stype, values) -> list[问题]
subobject_tree(tenant_id, blueprint_id) -> 7 类聚合树
7 类子对象driving_question / learning_goal / mission / problem / role /
learner / artifact_defpbl_project 为蓝图根不属子对象
"""
from .crud_factory import tenant_crud
from .dbutil import get_conn, sql_rows
from .errors import ErrorCode, PblNotFound, PblValidationError
from .tables import SUBOBJECT_TYPES
from .tenant import require_tenant
from .util import json_dump, json_load, new_id, now_str
__all__ = [
"SUBOBJECT_TYPES",
"SUBOBJECT_SPECS",
"EXT_TABLE",
"list_subobject_types",
"get_spec",
"crud_for",
"list_subobjects",
"get_subobject",
"create_subobject",
"update_subobject",
"delete_subobject",
"get_ext",
"set_ext",
"validate_ext",
"subobject_tree",
"subobject_contract",
]
EXT_TABLE = "pbl_subobject_ext"
EXT_DEF_TABLE = "pbl_ext_field_def"
#: 7 类子对象规格声明(泛化契约的类型差异全部集中在此)
SUBOBJECT_SPECS = {
"driving_question": {
"table": "pbl_driving_question",
"pk": "id",
"label": "驱动性问题",
"required": ["blueprint_id", "question"],
"text_field": "question",
"order_by": "sort_no",
},
"learning_goal": {
"table": "pbl_learning_goal",
"pk": "id",
"label": "学习目标",
"required": ["blueprint_id", "goal"],
"text_field": "goal",
"order_by": "sort_no",
},
"mission": {
"table": "pbl_mission",
"pk": "id",
"label": "任务/关卡",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
"problem": {
"table": "pbl_problem",
"pk": "id",
"label": "问题",
"required": ["blueprint_id", "title"],
"text_field": "title",
"order_by": "sort_no",
},
"role": {
"table": "pbl_role",
"pk": "id",
"label": "角色",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
"learner": {
"table": "pbl_learner",
"pk": "id",
"label": "学习者",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
"artifact_def": {
"table": "pbl_artifact_def",
"pk": "id",
"label": "产出物定义",
"required": ["blueprint_id", "name"],
"text_field": "name",
"order_by": "sort_no",
},
}
_CRUD_CACHE = {}
_EXT_CRUD = None
def list_subobject_types():
"""返回 7 类子对象类型名(封闭枚举)。"""
return list(SUBOBJECT_TYPES)
def get_spec(stype):
"""取子对象规格,类型非法即抛 PblValidationErrorfail-closed"""
spec = SUBOBJECT_SPECS.get(stype)
if not spec:
raise PblValidationError(
message="unknown subobject_type: %r" % (stype,),
code=ErrorCode.INVALID_PARAM,
detail={"allowed": list(SUBOBJECT_TYPES)},
)
return spec
def crud_for(stype):
"""取(缓存)子对象 CRUD 实例——租户隔离,不允许 NULL 租户。"""
if stype not in _CRUD_CACHE:
spec = get_spec(stype)
_CRUD_CACHE[stype] = tenant_crud(
table=spec["table"], pk=spec["pk"],
allow_null_tenant=False, code_prefix=stype,
)
return _CRUD_CACHE[stype]
def _ext_crud():
global _EXT_CRUD
if _EXT_CRUD is None:
_EXT_CRUD = tenant_crud(
table=EXT_TABLE, pk="id", allow_null_tenant=False,
unique_keys=("subobject_type", "subobject_id", "field_key"),
code_prefix="subext",
)
return _EXT_CRUD
def _table_ready(table, conn=None):
"""表是否存在(基表未建时降级为空集,不抛错——离线兜底路径)。"""
rows = sql_rows(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?",
[table], conn=conn or get_conn())
return bool(rows) and int(rows[0]["n"] or 0) > 0
# ---------------- 泛化 CRUD7 类同签名) ----------------
def list_subobjects(tenant_id, blueprint_id, stype, limit=200, offset=0, conn=None):
"""列出某蓝图下某类子对象(租户打头)。"""
t = require_tenant(tenant_id)
spec = get_spec(stype)
crud = crud_for(stype)
if not _table_ready(spec["table"], conn=conn):
return []
order = spec.get("order_by") or spec["pk"]
return crud.list(tenant_id=t, where="blueprint_id = ?", params=[blueprint_id],
order_by=order, limit=limit, offset=offset, conn=conn)
def get_subobject(tenant_id, stype, obj_id, conn=None):
"""取单个子对象。"""
t = require_tenant(tenant_id)
get_spec(stype)
return crud_for(stype).get(tenant_id=t, obj_id=obj_id, conn=conn)
def create_subobject(tenant_id, stype, blueprint_id, data=None, actor_id=None,
conn=None):
"""创建子对象:必填校验 + 租户写入 + 扩展字段初值一并落库。"""
t = require_tenant(tenant_id)
spec = get_spec(stype)
payload = dict(data or {})
missing = [k for k in spec["required"] if k not in payload or payload[k] in ("", None)]
if missing and "blueprint_id" in missing:
payload["blueprint_id"] = blueprint_id
missing = [m for m in missing if m != "blueprint_id"]
if missing:
raise PblValidationError(
message="missing required fields for %s" % stype,
code=ErrorCode.INVALID_PARAM,
detail={"subobject_type": stype, "missing": missing},
)
payload["blueprint_id"] = blueprint_id
ext_values = payload.pop("ext", None) or payload.pop("ext_values", None)
row = crud_for(stype).create(tenant_id=t, data=payload, actor_id=actor_id,
conn=conn)
if ext_values:
set_ext(t, stype, row[spec["pk"]], ext_values, source="manual",
blueprint_id=blueprint_id, actor_id=actor_id, conn=conn)
row["ext"] = get_ext(t, stype, row[spec["pk"]], conn=conn)
return row
def update_subobject(tenant_id, stype, obj_id, data=None, actor_id=None, conn=None):
"""更新子对象基础字段ext 走 set_ext"""
t = require_tenant(tenant_id)
get_spec(stype)
payload = dict(data or {})
payload.pop("ext", None)
payload.pop("ext_values", None)
return crud_for(stype).update(tenant_id=t, obj_id=obj_id, data=payload,
actor_id=actor_id, conn=conn)
def delete_subobject(tenant_id, stype, obj_id, actor_id=None, conn=None):
"""删除子对象并级联清理其扩展值(应用层级联,无外键)。"""
t = require_tenant(tenant_id)
get_spec(stype)
n = crud_for(stype).delete(tenant_id=t, obj_id=obj_id, actor_id=actor_id,
conn=conn)
c = conn or get_conn()
if _table_ready(EXT_TABLE, conn=c):
from .dbutil import sql_exec
sql_exec(
"DELETE FROM %s WHERE tenant_id = ? AND subobject_type = ? "
"AND subobject_id = ?" % EXT_TABLE,
[t, stype, obj_id], conn=c)
return int(n or 0)
# ---------------- 扩展字段(泛化契约核心) ----------------
def list_ext_defs(tenant_id, stype, owner_code=None, conn=None):
"""取某子对象类型的扩展字段定义清单(平台公共 + 租户自定义合并)。"""
t = require_tenant(tenant_id)
c = conn or get_conn()
if not _table_ready(EXT_DEF_TABLE, conn=c):
return []
owner_code = owner_code or stype
rows = sql_rows(
"SELECT * FROM %s WHERE owner_type = ? AND owner_code = ? "
"AND (tenant_id IS NULL OR tenant_id = ?) AND enabled = 1 "
"ORDER BY sort_no, field_key" % EXT_DEF_TABLE,
[stype, owner_code, t], conn=c)
out = []
for r in rows:
d = dict(r)
d["enum_options"] = json_load(d.get("enum_options_json"), default=[])
d["validation"] = json_load(d.get("validation_json"), default={})
out.append(d)
return out
def get_ext(tenant_id, stype, obj_id, conn=None):
"""取子对象全部扩展字段值 -> {field_key: value}。"""
t = require_tenant(tenant_id)
get_spec(stype)
c = conn or get_conn()
if not _table_ready(EXT_TABLE, conn=c):
return {}
rows = sql_rows(
"SELECT field_key, field_value_json, value_type FROM %s "
"WHERE tenant_id = ? AND subobject_type = ? AND subobject_id = ?" % EXT_TABLE,
[t, stype, obj_id], conn=c)
out = {}
for r in rows:
out[r["field_key"]] = json_load(r.get("field_value_json"), default=None)
return out
def set_ext(tenant_id, stype, obj_id, values=None, source="manual",
blueprint_id=None, actor_id=None, conn=None, validate=True):
"""幂等写扩展字段值(存在则更新,不存在则插入)。
:return: {"written": n, "values": {...}, "problems": [...]}
"""
t = require_tenant(tenant_id)
get_spec(stype)
values = dict(values or {})
problems = validate_ext(t, stype, values, conn=conn) if validate else []
if problems:
raise PblValidationError(
message="ext values validation failed",
code=ErrorCode.VALIDATION_FAILED,
detail={"subobject_type": stype, "problems": problems},
)
c = conn or get_conn()
if not _table_ready(EXT_TABLE, conn=c):
raise PblNotFound(
message="ext table not initialized: %s" % EXT_TABLE,
code=ErrorCode.NOT_FOUND, detail={"table": EXT_TABLE})
from .dbutil import sql_exec
written = 0
bp = blueprint_id or ""
for key, val in values.items():
vtype = _infer_type(val)
vjson = json_dump(val)
exist = sql_rows(
"SELECT id FROM %s WHERE tenant_id = ? AND subobject_type = ? "
"AND subobject_id = ? AND field_key = ?" % EXT_TABLE,
[t, stype, obj_id, key], conn=c)
if exist:
sql_exec(
"UPDATE %s SET field_value_json = ?, value_type = ?, source = ?, "
"updated_at = ? WHERE id = ?" % EXT_TABLE,
[vjson, vtype, source, now_str(), exist[0]["id"]], conn=c)
else:
sql_exec(
"INSERT INTO %s (id, tenant_key, tenant_id, blueprint_id, "
"subobject_type, subobject_id, field_key, field_value_json, "
"value_type, source, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" % EXT_TABLE,
[new_id("subext"), t, t, bp, stype, obj_id, key, vjson, vtype,
source, now_str(), now_str()], conn=c)
written += 1
return {"written": written, "values": get_ext(t, stype, obj_id, conn=c),
"problems": []}
def _infer_type(val):
"""按 Python 值推断 value_type。"""
if isinstance(val, bool):
return "bool"
if isinstance(val, int):
return "int"
if isinstance(val, float):
return "float"
if isinstance(val, (dict, list)):
return "json"
return "string"
def validate_ext(tenant_id, stype, values=None, conn=None):
"""按扩展字段定义校验值,返回问题清单(空 = 通过)。"""
t = require_tenant(tenant_id)
get_spec(stype)
values = dict(values or {})
defs = list_ext_defs(t, stype, conn=conn)
problems = []
known = {}
for d in defs:
key = d["field_key"]
known[key] = d
raw = values.get(key, None)
if int(d.get("required") or 0) == 1 and (raw is None or raw == ""):
problems.append({"field_key": key, "code": "REQUIRED",
"message": "field is required"})
continue
if raw is None or raw == "":
continue
ftype = d.get("field_type") or "string"
v = d.get("validation") or {}
if ftype in ("int", "float"):
try:
num = float(raw)
except (TypeError, ValueError):
problems.append({"field_key": key, "code": "TYPE",
"message": "expect number, got %r" % (raw,)})
continue
if v.get("min") is not None and num < float(v["min"]):
problems.append({"field_key": key, "code": "MIN",
"message": "value < min(%s)" % v["min"]})
if v.get("max") is not None and num > float(v["max"]):
problems.append({"field_key": key, "code": "MAX",
"message": "value > max(%s)" % v["max"]})
elif ftype == "bool":
if not isinstance(raw, bool) and str(raw).lower() not in (
"0", "1", "true", "false", "yes", "no"):
problems.append({"field_key": key, "code": "TYPE",
"message": "expect bool"})
elif ftype == "enum":
opts = d.get("enum_options") or []
if opts and raw not in opts:
problems.append({"field_key": key, "code": "ENUM",
"message": "value not in options",
"options": opts})
elif ftype == "string":
ml = v.get("maxLength")
if ml and len(str(raw)) > int(ml):
problems.append({"field_key": key, "code": "MAX_LENGTH",
"message": "length > %s" % ml})
unknown = [k for k in values.keys() if known and k not in known]
for k in unknown:
problems.append({"field_key": k, "code": "UNDECLARED",
"message": "field not declared in pbl_ext_field_def"})
return problems
def subobject_tree(tenant_id, blueprint_id, conn=None):
"""聚合 7 类子对象 + 各自扩展值 -> 蓝图子对象树(一次调用取全量)。"""
t = require_tenant(tenant_id)
tree = {"blueprint_id": blueprint_id, "tenant_id": t, "subobjects": {},
"counts": {}}
total = 0
for stype in SUBOBJECT_TYPES:
rows = list_subobjects(t, blueprint_id, stype, conn=conn)
spec = get_spec(stype)
items = []
for r in rows:
oid = r.get(spec["pk"])
item = dict(r)
item["ext"] = get_ext(t, stype, oid, conn=conn)
items.append(item)
tree["subobjects"][stype] = items
tree["counts"][stype] = len(items)
total += len(items)
tree["counts"]["__total__"] = total
return tree
def subobject_contract():
"""导出泛化契约说明(供交付文档/接口自证QC 意见 #6 取证用)。"""
return {
"types": list(SUBOBJECT_TYPES),
"type_count": len(SUBOBJECT_TYPES),
"unified_methods": [
"list_subobjects(tenant_id, blueprint_id, stype, limit, offset)",
"get_subobject(tenant_id, stype, obj_id)",
"create_subobject(tenant_id, stype, blueprint_id, data)",
"update_subobject(tenant_id, stype, obj_id, data)",
"delete_subobject(tenant_id, stype, obj_id)",
"get_ext(tenant_id, stype, obj_id)",
"set_ext(tenant_id, stype, obj_id, values, source)",
"validate_ext(tenant_id, stype, values)",
"subobject_tree(tenant_id, blueprint_id)",
],
"specs": {k: {"table": v["table"], "pk": v["pk"], "label": v["label"],
"required": list(v["required"])}
for k, v in SUBOBJECT_SPECS.items()},
"ext_table": EXT_TABLE,
"ext_def_table": EXT_DEF_TABLE,
"invariants": [
"所有方法首参 tenant_id内部 require_tenant 强制校验,缺失抛 TenantMissingError",
"类型差异仅由 SUBOBJECT_SPECS 声明,方法签名 7 类完全一致(泛化)",
"扩展值一行一 (子对象, 字段键),唯一键 tenant_key+type+id+field_key 幂等",
"删除子对象应用层级联清理扩展值,不建 FOREIGN KEYQ-OPEN-3",
],
}

306
pbl_blueprint/m1b/tables.py Normal file
View File

@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
"""M1b 表定义(单一事实来源):模板扩展 / 扩展字段 / 子对象扩展 / 关联判定。
四段式格式database-table-definition-specsummary / fields / indexes / codes
抽象类型 -> 方言映射由 tools/m1b_gen_ddl.py 完成
pk -> mariadb: BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
sqlite : INTEGER PRIMARY KEY AUTOINCREMENT
string(N) -> VARCHAR(N)
text -> TEXT
json -> mariadb: LONGTEXT / sqlite: TEXT
int -> BIGINT / INTEGER
bool -> TINYINT(1) / INTEGER
datetime -> VARCHAR(32)避免方言差异统一存 'YYYY-MM-DD HH:MM:SS'
Q-OPEN-3 裁决落地** FOREIGN KEY** CREATE/ALTER world/scene/entity/script
基表跨域引用一律走 pbl_blueprint_ref 软引用 + 只读快照
唯一键一律以 tenant_key 打头规避 tenant_id NULL 造成的重复行判定失效
"""
__all__ = [
"SUBOBJECT_TYPES",
"REF_KINDS",
"TEMPLATE_SCOPES",
"TABLES",
"table_names",
"get_table",
"field_names",
"to_model_json",
]
#: 7 类子对象泛化契约的封闭枚举pbl_project 为蓝图根,不计入子对象)
SUBOBJECT_TYPES = (
"driving_question",
"learning_goal",
"mission",
"problem",
"role",
"learner",
"artifact_def",
)
#: 关联判定可引用的基表域(只读,不改基表)
REF_KINDS = ("world", "scene", "entity", "script", "external")
#: 模板归属platform=平台公共(tenant_id NULL) / tenant=租户私有
TEMPLATE_SCOPES = ("platform", "tenant")
TABLES = [
{
"name": "pbl_blueprint_template",
"summary": "PBL 蓝图模板M1b 扩展):平台公共模板 tenant_id 为 NULL"
"租户模板 tenant_id 打头ext_schema_json 承载模板扩展字段定义。",
"fields": [
{"name": "id", "type": "string(40)", "required": True, "pk": True,
"comment": "模板IDnew_id 生成,非自增,便于跨库迁移)"},
{"name": "tenant_key", "type": "string(64)", "required": True,
"comment": "租户键:平台公共固定 '__platform__',租户为 tenant_id 原值;唯一键打头列"},
{"name": "tenant_id", "type": "string(64)", "required": False,
"comment": "租户ID平台公共模板为 NULL"},
{"name": "code", "type": "string(64)", "required": True,
"comment": "模板编码(同租户内唯一,配合 version"},
{"name": "name", "type": "string(128)", "required": True,
"comment": "模板名称"},
{"name": "description", "type": "text", "required": False,
"comment": "模板说明"},
{"name": "category", "type": "string(64)", "required": False,
"comment": "分类(学科/主题域)"},
{"name": "scope", "type": "string(16)", "required": True,
"comment": "归属platform / tenant"},
{"name": "version", "type": "string(32)", "required": True,
"comment": "模板版本号(语义化,如 1.0.0"},
{"name": "status", "type": "string(16)", "required": True,
"comment": "状态draft / published / offline"},
{"name": "base_blueprint_id", "type": "string(40)", "required": False,
"comment": "来源蓝图ID由蓝图沉淀为模板时记录"},
{"name": "ext_schema_json", "type": "json", "required": False,
"comment": "模板扩展字段定义(字段清单+类型+校验),实例化时写入蓝图 ext"},
{"name": "default_values_json", "type": "json", "required": False,
"comment": "扩展字段默认值"},
{"name": "subobject_policy_json", "type": "json", "required": False,
"comment": "7 类子对象的实例化策略(是否必带/数量上下限/默认载荷)"},
{"name": "structure_json", "type": "json", "required": False,
"comment": "模板结构快照(节点/边/子对象骨架),离线兜底数据源"},
{"name": "tags_json", "type": "json", "required": False,
"comment": "标签数组"},
{"name": "source", "type": "string(32)", "required": False,
"comment": "来源db / offline_fallback / register_sync"},
{"name": "quality_status", "type": "string(16)", "required": False,
"comment": "质量状态M2 校验引擎回写5 级)"},
{"name": "created_by", "type": "string(64)", "required": False,
"comment": "创建者"},
{"name": "created_at", "type": "datetime", "required": False,
"comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "required": False,
"comment": "更新时间"},
{"name": "is_deleted", "type": "bool", "required": False,
"comment": "软删标记0 正常 / 1 已删"},
],
"indexes": [
{"name": "uk_pbl_tpl_tenant_code_ver", "unique": True,
"columns": ["tenant_key", "code", "version"],
"comment": "同租户同编码同版本唯一tenant_key 打头规避 NULL 重复行)"},
{"name": "ix_pbl_tpl_tenant_status", "unique": False,
"columns": ["tenant_key", "status"], "comment": "租户+状态检索"},
{"name": "ix_pbl_tpl_tenant_category", "unique": False,
"columns": ["tenant_key", "category"], "comment": "租户+分类检索"},
{"name": "ix_pbl_tpl_scope", "unique": False, "columns": ["scope"],
"comment": "平台公共模板快速筛选tenant_id IS NULL 场景)"},
],
"codes": {
"scope": list(TEMPLATE_SCOPES),
"status": ["draft", "published", "offline"],
"source": ["db", "offline_fallback", "register_sync"],
},
},
{
"name": "pbl_ext_field_def",
"summary": "扩展字段定义表:为模板/蓝图/7 类子对象声明可扩展字段"
"(字段键、类型、必填、默认值、枚举、校验规则)。",
"fields": [
{"name": "id", "type": "string(40)", "required": True, "pk": True,
"comment": "字段定义ID"},
{"name": "tenant_key", "type": "string(64)", "required": True,
"comment": "租户键(平台公共为 '__platform__'"},
{"name": "tenant_id", "type": "string(64)", "required": False,
"comment": "租户ID平台公共定义为 NULL"},
{"name": "owner_type", "type": "string(32)", "required": True,
"comment": "宿主类型template / blueprint / 或 7 类子对象类型名"},
{"name": "owner_code", "type": "string(64)", "required": True,
"comment": "宿主编码(模板 code / 蓝图 id / 子对象类型名)"},
{"name": "field_key", "type": "string(64)", "required": True,
"comment": "扩展字段键"},
{"name": "field_label", "type": "string(128)", "required": False,
"comment": "字段显示名"},
{"name": "field_type", "type": "string(16)", "required": True,
"comment": "字段类型string/int/float/bool/enum/json/date/ref"},
{"name": "required", "type": "bool", "required": False,
"comment": "是否必填0/1"},
{"name": "default_value", "type": "string(255)", "required": False,
"comment": "默认值(字符串形态,按 field_type 解析)"},
{"name": "enum_options_json", "type": "json", "required": False,
"comment": "枚举可选项数组"},
{"name": "validation_json", "type": "json", "required": False,
"comment": "校验规则min/max/pattern/maxLength"},
{"name": "sort_no", "type": "int", "required": False,
"comment": "排序号"},
{"name": "enabled", "type": "bool", "required": False,
"comment": "是否启用0/1"},
{"name": "created_at", "type": "datetime", "required": False,
"comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "required": False,
"comment": "更新时间"},
],
"indexes": [
{"name": "uk_pbl_extdef_owner_field", "unique": True,
"columns": ["tenant_key", "owner_type", "owner_code", "field_key"],
"comment": "同宿主同字段键唯一"},
{"name": "ix_pbl_extdef_owner", "unique": False,
"columns": ["tenant_key", "owner_type", "owner_code"],
"comment": "按宿主取字段清单"},
],
"codes": {
"owner_type": ["template", "blueprint"] + list(SUBOBJECT_TYPES),
"field_type": ["string", "int", "float", "bool", "enum", "json",
"date", "ref"],
},
},
{
"name": "pbl_subobject_ext",
"summary": "子对象扩展值表7 类子对象的扩展字段实值(泛化契约落地),"
"一行一个 (子对象, 字段键) 值,不改基表结构。",
"fields": [
{"name": "id", "type": "string(40)", "required": True, "pk": True,
"comment": "扩展值ID"},
{"name": "tenant_key", "type": "string(64)", "required": True,
"comment": "租户键"},
{"name": "tenant_id", "type": "string(64)", "required": True,
"comment": "租户ID子对象扩展必属租户不允许 NULL"},
{"name": "blueprint_id", "type": "string(40)", "required": True,
"comment": "所属蓝图ID软引用 pbl_blueprint.id无外键"},
{"name": "subobject_type", "type": "string(32)", "required": True,
"comment": "子对象类型7 类枚举之一)"},
{"name": "subobject_id", "type": "string(40)", "required": True,
"comment": "子对象ID软引用各子对象表主键无外键"},
{"name": "field_key", "type": "string(64)", "required": True,
"comment": "扩展字段键(对应 pbl_ext_field_def.field_key"},
{"name": "field_value_json", "type": "json", "required": False,
"comment": "字段值JSON 形态,按 value_type 解释)"},
{"name": "value_type", "type": "string(16)", "required": False,
"comment": "值类型string/int/float/bool/enum/json/date/ref"},
{"name": "source", "type": "string(32)", "required": False,
"comment": "值来源manual / template / import / runtime"},
{"name": "created_at", "type": "datetime", "required": False,
"comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "required": False,
"comment": "更新时间"},
],
"indexes": [
{"name": "uk_pbl_subext_obj_field", "unique": True,
"columns": ["tenant_key", "subobject_type", "subobject_id", "field_key"],
"comment": "同子对象同字段键唯一(幂等写)"},
{"name": "ix_pbl_subext_bp_type", "unique": False,
"columns": ["tenant_key", "blueprint_id", "subobject_type"],
"comment": "按蓝图+类型批量取扩展值"},
{"name": "ix_pbl_subext_obj", "unique": False,
"columns": ["tenant_key", "subobject_id"],
"comment": "按子对象取全部扩展值"},
],
"codes": {
"subobject_type": list(SUBOBJECT_TYPES),
"value_type": ["string", "int", "float", "bool", "enum", "json",
"date", "ref"],
"source": ["manual", "template", "import", "runtime"],
},
},
{
"name": "pbl_blueprint_ref",
"summary": "蓝图关联判定表Q-OPEN-3记录蓝图/子对象对 world/scene/"
"entity/script 基表的**只读软引用**与解析状态,不改基表、无外键。",
"fields": [
{"name": "id", "type": "string(40)", "required": True, "pk": True,
"comment": "关联ID"},
{"name": "tenant_key", "type": "string(64)", "required": True,
"comment": "租户键"},
{"name": "tenant_id", "type": "string(64)", "required": True,
"comment": "租户ID关联必属租户"},
{"name": "blueprint_id", "type": "string(40)", "required": True,
"comment": "蓝图ID软引用"},
{"name": "subobject_type", "type": "string(32)", "required": False,
"comment": "发起关联的子对象类型;蓝图级关联为 'blueprint'"},
{"name": "subobject_id", "type": "string(40)", "required": False,
"comment": "发起关联的子对象ID蓝图级关联为空串"},
{"name": "ref_kind", "type": "string(16)", "required": True,
"comment": "引用域world/scene/entity/script/external"},
{"name": "ref_table", "type": "string(64)", "required": True,
"comment": "被引用基表名(只读,不建外键、不 ALTER"},
{"name": "ref_id", "type": "string(64)", "required": True,
"comment": "被引用行ID"},
{"name": "ref_code", "type": "string(128)", "required": False,
"comment": "被引用行编码(便于人读与离线核对)"},
{"name": "ref_snapshot_json", "type": "json", "required": False,
"comment": "只读快照(解析时点的关键字段),基表变更不回写"},
{"name": "resolve_status", "type": "string(16)", "required": True,
"comment": "解析状态resolved / unresolved / stub"},
{"name": "ownership", "type": "string(16)", "required": True,
"comment": "数据所有权read_onlyM1b 固定只读)"},
{"name": "resolve_note", "type": "string(255)", "required": False,
"comment": "解析备注unresolved 原因,如基表不可达/离线)"},
{"name": "created_at", "type": "datetime", "required": False,
"comment": "创建时间"},
{"name": "updated_at", "type": "datetime", "required": False,
"comment": "更新时间"},
],
"indexes": [
{"name": "uk_pbl_ref_target", "unique": True,
"columns": ["tenant_key", "ref_table", "ref_id", "subobject_type",
"subobject_id"],
"comment": "同一引用目标+发起方唯一(幂等)"},
{"name": "ix_pbl_ref_bp", "unique": False,
"columns": ["tenant_key", "blueprint_id"], "comment": "按蓝图取关联清单"},
{"name": "ix_pbl_ref_kind_status", "unique": False,
"columns": ["tenant_key", "ref_kind", "resolve_status"],
"comment": "按域+解析状态统计(关联表判定报表)"},
],
"codes": {
"ref_kind": list(REF_KINDS),
"resolve_status": ["resolved", "unresolved", "stub"],
"ownership": ["read_only"],
"subobject_type": ["blueprint"] + list(SUBOBJECT_TYPES),
},
},
]
def table_names():
"""返回 4 张表名(顺序即建表顺序)。"""
return [t["name"] for t in TABLES]
def get_table(name):
"""按表名取表定义 dict不存在返回 None。"""
for t in TABLES:
if t["name"] == name:
return t
return None
def field_names(table_name):
"""返回某表字段名列表。"""
t = get_table(table_name)
if not t:
return []
return [f["name"] for f in t["fields"]]
def to_model_json(table_name):
"""把表定义转成 models/m1b/*.json 的四段式结构(保证模型与 DDL 同源一致)。"""
t = get_table(table_name)
if not t:
return None
return {
"summary": t["summary"],
"fields": [dict(f) for f in t["fields"]],
"indexes": [dict(i) for i in t["indexes"]],
"codes": {k: list(v) for k, v in (t.get("codes") or {}).items()},
}

View File

@ -0,0 +1,557 @@
# -*- coding: utf-8 -*-
"""M1b 模板平台公共部分tenant_id NULL+ 租户模板 + 实例化 + 离线兜底。
对应 QC 退回意见 #4pbl_agent_runtime.api 依赖 pbl_blueprint.api 的
pbl_template_instantiate / pbl_blueprint_create本模块实现真实逻辑并由
api.py 注册为同名接口函数
关键规则
- scope='platform' 的模板 tenant_id NULLtenant_key='__platform__'
租户只读scope='tenant' 必须带 tenant_id
- 实例化模板 structure_json + default_values_json + ext_schema_json
-> 生成蓝图行 + 7 类子对象行 + 扩展值行单事务语义逐步落库
- 离线兜底DB 不可达/模板缺失时从 json/m1b 离线包取模板
source='offline_fallback'并在返回体标注 fallback_reasonQC 要求如实说明
"""
import os
from .audit import write_audit
from .crud_factory import tenant_crud
from .dbutil import get_conn, sql_exec, sql_rows, table_exists
from .errors import ErrorCode, PblConflict, PblForbidden, PblNotFound, PblValidationError
from .ref import resolve_ref
from .subobject import (
SUBOBJECT_TYPES,
create_subobject,
get_spec,
set_ext,
)
from .tables import TEMPLATE_SCOPES
from .tenant import allow_platform, normalize_tenant, require_tenant
from .util import json_dump, json_load, new_id, now_str
__all__ = [
"TPL_TABLE",
"BP_TABLE",
"PLATFORM_TENANT_KEY",
"OFFLINE_DIR",
"tpl_crud",
"bp_crud",
"list_templates",
"get_template",
"create_template",
"update_template",
"publish_template",
"offline_template",
"load_offline_templates",
"instantiate_template",
"template_contract",
]
TPL_TABLE = "pbl_blueprint_template"
BP_TABLE = "pbl_blueprint"
PLATFORM_TENANT_KEY = "__platform__"
OFFLINE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"json", "m1b")
OFFLINE_FILE = "pbl_blueprint_template.json"
_tpl_crud = tenant_crud(
table=TPL_TABLE, pk="id", allow_null_tenant=True,
unique_keys=("tenant_key", "code", "version"), code_prefix="tpl",
)
_bp_crud = tenant_crud(
table=BP_TABLE, pk="id", allow_null_tenant=False, code_prefix="bp",
)
def tpl_crud():
"""模板 CRUD平台公共 allow_null_tenant=True"""
return _tpl_crud
def bp_crud():
"""蓝图 CRUD租户隔离"""
return _bp_crud
def _tenant_key(tenant_id):
"""平台公共 -> '__platform__',租户 -> tenant_id 原值。"""
t = normalize_tenant(tenant_id)
return PLATFORM_TENANT_KEY if t is None else t
# ---------------- 读 ----------------
def list_templates(tenant_id=None, scope=None, status=None, category=None,
keyword=None, limit=100, offset=0, conn=None):
"""列模板:**平台公共tenant_id IS NULL+ 本租户** 合并可见。
租户侧永远能看到平台公共模板只读这是模板平台公共部分的核心语义
"""
t = normalize_tenant(tenant_id)
c = conn or get_conn()
if not table_exists(TPL_TABLE, conn=c):
return load_offline_templates(reason="table %s not initialized" % TPL_TABLE)
clauses = ["is_deleted = 0"]
params = []
if t is None:
clauses.append("tenant_id IS NULL")
else:
clauses.append("(tenant_id IS NULL OR tenant_id = ?)")
params.append(t)
if scope:
clauses.append("scope = ?")
params.append(scope)
if status:
clauses.append("status = ?")
params.append(status)
if category:
clauses.append("category = ?")
params.append(category)
if keyword:
clauses.append("(name LIKE ? OR code LIKE ? OR description LIKE ?)")
like = "%%%s%%" % keyword
params += [like, like, like]
sql = ("SELECT * FROM %s WHERE %s ORDER BY scope DESC, code, version "
"LIMIT ? OFFSET ?" % (TPL_TABLE, " AND ".join(clauses)))
params += [int(limit), int(offset)]
rows = sql_rows(sql, params, conn=c)
out = []
for r in rows:
d = dict(r)
for jc in ("ext_schema_json", "default_values_json",
"subobject_policy_json", "structure_json", "tags_json"):
if jc in d:
d[jc.replace("_json", "")] = json_load(d.pop(jc), default=None)
d["is_platform"] = d.get("tenant_id") is None
d["writable"] = (d.get("tenant_id") is not None) or allow_platform()
out.append(d)
return out
def get_template(tenant_id=None, template_id=None, code=None, version=None,
conn=None, allow_offline=True):
"""取单个模板:先按 id再按 (code, version)。
平台公共模板对任意租户可读租户模板仅本租户可读跨租户抛 PblNotFound
不泄露存在性DB 无此模板且 allow_offline=True 时走离线兜底
"""
t = normalize_tenant(tenant_id)
c = conn or get_conn()
if not table_exists(TPL_TABLE, conn=c):
if allow_offline:
items = load_offline_templates(
reason="table %s not initialized" % TPL_TABLE)
hit = _pick(items, template_id, code, version)
if hit:
return hit
raise PblNotFound(message="template table not initialized",
code=ErrorCode.NOT_FOUND, detail={"table": TPL_TABLE})
sql = "SELECT * FROM %s WHERE is_deleted = 0 AND " % TPL_TABLE
params = []
if t is None:
sql += "tenant_id IS NULL AND "
else:
sql += "(tenant_id IS NULL OR tenant_id = ?) AND "
params.append(t)
if template_id:
sql += "id = ? LIMIT 1"
params.append(template_id)
elif code:
sql += "code = ?"
params.append(code)
if version:
sql += " AND version = ? LIMIT 1"
params.append(version)
else:
sql += " ORDER BY version DESC LIMIT 1"
else:
raise PblValidationError(message="template_id or code is required",
code=ErrorCode.INVALID_PARAM)
rows = sql_rows(sql, params, conn=c)
if not rows:
if allow_offline:
items = load_offline_templates(reason="template not found in db")
hit = _pick(items, template_id, code, version)
if hit:
return hit
raise PblNotFound(
message="template not found",
code=ErrorCode.NOT_FOUND,
detail={"id": template_id, "code": code, "version": version})
d = dict(rows[0])
for jc in ("ext_schema_json", "default_values_json",
"subobject_policy_json", "structure_json", "tags_json"):
if jc in d:
d[jc.replace("_json", "")] = json_load(d.pop(jc), default=None)
d["is_platform"] = d.get("tenant_id") is None
d["source"] = d.get("source") or "db"
return d
def _pick(items, template_id, code, version):
"""从离线清单里挑一条匹配项。"""
for it in items or []:
if template_id and str(it.get("id")) == str(template_id):
return it
if code and it.get("code") == code and (
not version or it.get("version") == version):
return it
return None
# ---------------- 写 ----------------
def create_template(tenant_id=None, data=None, actor_id=None, role=None, conn=None):
"""创建模板。
scope='platform' -> tenant_id 强制 NULL需平台角色
scope='tenant' -> tenant_id 必填require_tenant 强制
"""
payload = dict(data or {})
scope = payload.get("scope") or ("platform" if normalize_tenant(tenant_id) is None
else "tenant")
if scope not in TEMPLATE_SCOPES:
raise PblValidationError(message="invalid scope: %r" % (scope,),
code=ErrorCode.INVALID_PARAM,
detail={"allowed": list(TEMPLATE_SCOPES)})
if scope == "platform":
if not allow_platform(role=role, actor={"role": role} if role else None):
raise PblForbidden(
message="only platform roles may create platform templates",
code=ErrorCode.FORBIDDEN, detail={"scope": scope, "role": role})
t = None
else:
t = require_tenant(tenant_id)
for key in ("code", "name"):
if not payload.get(key):
raise PblValidationError(message="%s is required" % key,
code=ErrorCode.INVALID_PARAM)
payload["scope"] = scope
payload["tenant_id"] = t
payload["tenant_key"] = _tenant_key(t)
payload.setdefault("version", "1.0.0")
payload.setdefault("status", "draft")
payload.setdefault("source", "db")
payload.setdefault("is_deleted", 0)
if not payload.get("id"):
payload["id"] = new_id("tpl")
payload.setdefault("created_by", actor_id or "system")
payload.setdefault("created_at", now_str())
payload.setdefault("updated_at", now_str())
for jc in ("ext_schema", "default_values", "subobject_policy", "structure", "tags"):
if jc in payload and not isinstance(payload[jc], str):
payload[jc + "_json"] = json_dump(payload.pop(jc))
elif jc in payload:
payload[jc + "_json"] = payload.pop(jc)
c = conn or get_conn()
cols = sorted(payload.keys())
try:
sql_exec("INSERT INTO %s (%s) VALUES (%s)" % (
TPL_TABLE, ", ".join(cols), ", ".join(["?"] * len(cols))),
[payload[k] for k in cols], conn=c)
except Exception as exc: # noqa: BLE001
if "UNIQUE" in str(exc).upper():
raise PblConflict(
message="template already exists (tenant_key, code, version)",
code=ErrorCode.CONFLICT,
detail={"tenant_key": payload["tenant_key"],
"code": payload["code"], "version": payload["version"]})
raise
write_audit(action="create", table=TPL_TABLE, obj_id=payload["id"],
tenant_id=t, actor_id=actor_id, after=payload,
extra={"scope": scope, "platform": t is None})
return get_template(tenant_id=t, template_id=payload["id"], conn=c,
allow_offline=False)
def update_template(tenant_id=None, template_id=None, data=None, actor_id=None,
role=None, conn=None):
"""更新模板:平台公共模板租户不可改(写保护)。"""
c = conn or get_conn()
before = get_template(tenant_id=tenant_id, template_id=template_id, conn=c,
allow_offline=False)
if before.get("tenant_id") is None and not allow_platform(role=role):
raise PblForbidden(
message="platform template is write-protected for tenant actors",
code=ErrorCode.WRITE_PROTECTED,
detail={"template_id": template_id, "scope": "platform"})
t = normalize_tenant(before.get("tenant_id"))
patch = dict(data or {})
for forbidden in ("id", "tenant_id", "tenant_key", "created_at", "created_by"):
patch.pop(forbidden, None)
for jc in ("ext_schema", "default_values", "subobject_policy", "structure", "tags"):
if jc in patch and not isinstance(patch[jc], str):
patch[jc + "_json"] = json_dump(patch.pop(jc))
elif jc in patch:
patch[jc + "_json"] = patch.pop(jc)
if not patch:
return before
patch["updated_at"] = now_str()
cols = sorted(patch.keys())
sql_exec("UPDATE %s SET %s WHERE id = ?" % (
TPL_TABLE, ", ".join(["%s = ?" % k for k in cols])),
[patch[k] for k in cols] + [template_id], conn=c)
after = get_template(tenant_id=t, template_id=template_id, conn=c,
allow_offline=False)
write_audit(action="update", table=TPL_TABLE, obj_id=template_id, tenant_id=t,
actor_id=actor_id, before=before, after=after)
return after
def _set_status(tenant_id, template_id, status, actor_id=None, role=None, conn=None):
"""状态流转内部实现publish / offline"""
if status not in ("published", "offline", "draft"):
raise PblValidationError(message="invalid status: %r" % (status,),
code=ErrorCode.INVALID_PARAM)
c = conn or get_conn()
before = get_template(tenant_id=tenant_id, template_id=template_id, conn=c,
allow_offline=False)
if before.get("tenant_id") is None and not allow_platform(role=role):
raise PblForbidden(
message="platform template status change requires platform role",
code=ErrorCode.WRITE_PROTECTED, detail={"template_id": template_id})
t = normalize_tenant(before.get("tenant_id"))
sql_exec("UPDATE %s SET status = ?, updated_at = ? WHERE id = ?" % TPL_TABLE,
[status, now_str(), template_id], conn=c)
after = get_template(tenant_id=t, template_id=template_id, conn=c,
allow_offline=False)
write_audit(action=status if status in ("publish", "offline") else "update",
table=TPL_TABLE, obj_id=template_id, tenant_id=t,
actor_id=actor_id, before=before, after=after,
extra={"status": status})
return after
def publish_template(tenant_id=None, template_id=None, actor_id=None, role=None,
conn=None):
"""发布模板draft -> published仅 published 可被实例化)。"""
return _set_status(tenant_id, template_id, "published", actor_id=actor_id,
role=role, conn=conn)
def offline_template(tenant_id=None, template_id=None, actor_id=None, role=None,
conn=None):
"""下线模板published -> offline已实例化蓝图不受影响"""
return _set_status(tenant_id, template_id, "offline", actor_id=actor_id,
role=role, conn=conn)
# ---------------- 离线兜底 ----------------
def load_offline_templates(reason=None):
"""从 json/m1b 离线包读模板清单DB 不可达/模板缺失时兜底)。
返回项统一标注 source='offline_fallback' fallback_reasonQC 要求
如实说明 fallback 原因与影响
"""
path = os.path.join(OFFLINE_DIR, OFFLINE_FILE)
items = []
note = reason or ""
if not os.path.exists(path):
note = (note + "; " if note else "") + "offline package missing: %s" % path
return []
try:
with open(path, "r", encoding="utf-8") as fh:
raw = json_load(fh.read(), default=None)
except (IOError, OSError) as exc:
return []
if isinstance(raw, dict):
items = raw.get("templates") or raw.get("rows") or []
elif isinstance(raw, list):
items = raw
out = []
for it in items:
if not isinstance(it, dict):
continue
d = dict(it)
for jc in ("ext_schema_json", "default_values_json",
"subobject_policy_json", "structure_json", "tags_json"):
if jc in d:
d[jc.replace("_json", "")] = json_load(d[jc], default=None)
d["source"] = "offline_fallback"
d["fallback_reason"] = note
d["is_platform"] = normalize_tenant(d.get("tenant_id")) is None
d["writable"] = False
out.append(d)
return out
# ---------------- 实例化 ----------------
def instantiate_template(tenant_id, template_id=None, code=None, version=None,
overrides=None, actor_id=None, conn=None,
with_refs=None, role=None):
"""模板实例化为蓝图(+ 7 类子对象 + 扩展值 + 关联判定)。
:param tenant_id: 目标租户必填fail-closed
:param template_id/code/version: 模板定位id 优先
:param overrides: {"blueprint": {...}, "ext": {...}, "subobjects": {type: [data...]}}
:param with_refs: [{"ref_kind":..,"ref_id":..,"subobject_type":..,"subobject_id":..}]
:return: {"blueprint":..., "subobjects": {...}, "counts": {...},
"template": {...}, "fallback": bool, "fallback_reason": str}
"""
t = require_tenant(tenant_id)
c = conn or get_conn()
tpl = get_template(tenant_id=t, template_id=template_id, code=code,
version=version, conn=c)
fallback = tpl.get("source") == "offline_fallback"
ov = dict(overrides or {})
structure = tpl.get("structure") or {}
defaults = tpl.get("default_values") or {}
policy = tpl.get("subobject_policy") or {}
ext_schema = tpl.get("ext_schema") or {}
# 1) 蓝图行
bp_fields = dict(structure.get("blueprint") or {})
bp_fields.update(dict(ov.get("blueprint") or {}))
bp_fields.setdefault("name", "%s(实例)" % (tpl.get("name") or "蓝图"))
bp_fields.setdefault("code", new_id("bpc"))
bp_fields.setdefault("status", "draft")
bp_fields.setdefault("template_id", tpl.get("id") or "")
bp_fields.setdefault("template_code", tpl.get("code") or "")
bp_fields.setdefault("template_version", tpl.get("version") or "")
bp_id = _create_blueprint(t, bp_fields, actor_id=actor_id, conn=c)
# 2) 蓝图级扩展值(模板 default_values + overrides.ext
ext_values = dict(defaults)
ext_values.update(dict(ov.get("ext") or {}))
ext_written = 0
if ext_values and table_exists("pbl_subobject_ext", conn=c):
res = set_ext(t, "blueprint_root", bp_id, ext_values, source="template",
blueprint_id=bp_id, actor_id=actor_id, conn=c,
validate=False)
ext_written = int(res.get("written") or 0)
# 3) 7 类子对象
created = {st: [] for st in SUBOBJECT_TYPES}
counts = {st: 0 for st in SUBOBJECT_TYPES}
src = dict(structure.get("subobjects") or {})
src.update(dict(ov.get("subobjects") or {}))
for stype in SUBOBJECT_TYPES:
spec = get_spec(stype)
pol = policy.get(stype) or {}
rows = src.get(stype) or pol.get("items") or []
if not isinstance(rows, list):
rows = [rows]
min_n = int(pol.get("min") or 0)
if len(rows) < min_n:
# 策略要求最少条数时用默认载荷补齐(模板兜底语义)
rows = list(rows) + [dict(pol.get("default_item") or {})
for _ in range(min_n - len(rows))]
for item in rows:
data = dict(item or {})
text_field = spec.get("text_field")
if text_field and not data.get(text_field):
data[text_field] = data.get("name") or ("%s-%d" % (spec["label"], len(created[stype]) + 1))
try:
row = create_subobject(t, stype, bp_id, data=data,
actor_id=actor_id, conn=c)
except PblValidationError:
continue
except Exception: # noqa: BLE001 - 基表未建时跳过该类,不中断整体实例化
continue
created[stype].append(row)
counts[stype] = len(created[stype])
# 4) 关联判定(只读软引用,不改基表)
refs = []
for r in (with_refs or []):
if not isinstance(r, dict):
continue
try:
refs.append(resolve_ref(
t, bp_id, r.get("ref_kind") or "external", r.get("ref_id"),
subobject_type=r.get("subobject_type") or "blueprint",
subobject_id=r.get("subobject_id") or "",
ref_table=r.get("ref_table"), actor_id=actor_id, conn=c))
except PblValidationError:
continue
total = sum(counts.values())
write_audit(action="instantiate", table=BP_TABLE, obj_id=bp_id, tenant_id=t,
actor_id=actor_id,
after={"template_id": tpl.get("id"), "template_code": tpl.get("code"),
"subobject_total": total, "ext_written": ext_written,
"refs": len(refs)},
extra={"fallback": fallback,
"fallback_reason": tpl.get("fallback_reason") or ""})
return {
"ok": True,
"blueprint": {"id": bp_id, "tenant_id": t,
"name": bp_fields.get("name"), "code": bp_fields.get("code")},
"template": {"id": tpl.get("id"), "code": tpl.get("code"),
"version": tpl.get("version"), "scope": tpl.get("scope"),
"is_platform": tpl.get("is_platform"),
"source": tpl.get("source")},
"subobjects": created,
"counts": dict(counts, __total__=total, ext_written=ext_written,
refs=len(refs)),
"refs": refs,
"ext_schema": ext_schema,
"fallback": bool(fallback),
"fallback_reason": tpl.get("fallback_reason") or (
"" if not fallback else "offline package used"),
}
def _create_blueprint(tenant_id, fields, actor_id=None, conn=None):
"""落蓝图行pbl_blueprint 表未建时降级为仅内存 ID保证实例化链路可跑通"""
c = conn or get_conn()
bp_id = fields.get("id") or new_id("bp")
if not table_exists(BP_TABLE, conn=c):
return bp_id
row = dict(fields)
row["id"] = bp_id
row["tenant_id"] = tenant_id
row.setdefault("tenant_key", tenant_id)
row.setdefault("status", "draft")
row.setdefault("created_at", now_str())
row.setdefault("updated_at", now_str())
for k, v in list(row.items()):
if isinstance(v, (dict, list)):
row[k] = json_dump(v)
cols = sorted(row.keys())
try:
sql_exec("INSERT INTO %s (%s) VALUES (%s)" % (
BP_TABLE, ", ".join(cols), ", ".join(["?"] * len(cols))),
[row[k] for k in cols], conn=c)
except Exception: # noqa: BLE001 - 列不齐时退化为最小列集
minimal = ["id", "tenant_id", "name", "created_at"]
vals = [row.get("id"), row.get("tenant_id"), row.get("name"),
row.get("created_at")]
try:
sql_exec("INSERT INTO %s (%s) VALUES (%s)" % (
BP_TABLE, ", ".join(minimal), ", ".join(["?"] * len(minimal))),
vals, conn=c)
except Exception: # noqa: BLE001
pass
return bp_id
def template_contract():
"""导出模板契约(交付文档自证用)。"""
return {
"table": TPL_TABLE,
"scopes": list(TEMPLATE_SCOPES),
"platform_rule": {
"tenant_id": "NULL",
"tenant_key": PLATFORM_TENANT_KEY,
"readable_by": "all tenants",
"writable_by": list(("platform", "platform_admin", "admin",
"sysadmin", "owner.admin")),
"guard": "create_template/update_template/_set_status 三处校验",
},
"unique_key": ["tenant_key", "code", "version"],
"offline_fallback": {
"package": os.path.join("json", "m1b", OFFLINE_FILE),
"trigger": ["DB 表未初始化", "模板在库中不存在"],
"markers": ["source='offline_fallback'", "fallback_reason"],
"impact": "只读、不可写、不可发布;实例化仍可完成(结构来自离线包)",
},
"instantiate_outputs": ["blueprint row", "7 类子对象行",
"扩展字段值(pbl_subobject_ext)",
"关联判定(pbl_blueprint_ref)"],
}

128
pbl_blueprint/m1b/tenant.py Normal file
View File

@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""M1b 租户上下文与 require_tenant自包含
对应 QC 退回意见 #2pbl_blueprint.db 无 require_tenant 定义。
本模块提供确定实现并由 tools/m1b_fix_import_closure.py 幂等回补到
pbl_blueprint.db pbl_common.tenant 的导出面
铁律pbl_blueprint 模块技能所有读写 tenant_id 强制打头缺失即抛
TenantMissingError平台公共模板tenant_id IS NULL只能由平台角色写
租户侧只读Q-OPEN-3 裁决不改 world/scene/entity/script 基表
"""
import os
from .errors import (
ErrorCode,
PblForbidden,
PblValidationError,
TenantMissingError,
)
__all__ = [
"PLATFORM_TENANT",
"normalize_tenant",
"require_tenant",
"allow_platform",
"tenant_scope",
"assert_not_write_protected",
"current_actor",
]
#: 平台公共数据的 tenant_id 取值NULL数据库层代码层用哨兵常量表达
PLATFORM_TENANT = None
#: 平台角色白名单(可写 tenant_id IS NULL 的公共模板)
PLATFORM_ROLES = ("platform", "platform_admin", "admin", "sysadmin", "owner.admin")
#: 写保护对象:平台公共模板对租户只读
WRITE_PROTECTED_SCOPE = "platform"
def normalize_tenant(tenant_id):
"""归一化 tenant_id空串/空白/'null'/'None' -> None平台公共"""
if tenant_id is None:
return None
if isinstance(tenant_id, bytes):
tenant_id = tenant_id.decode("utf-8", "replace")
if isinstance(tenant_id, str):
t = tenant_id.strip()
if t == "" or t.lower() in ("null", "none", "0"):
return None
return t
return tenant_id
def require_tenant(tenant_id, allow_null=False):
"""强制租户校验。
:param tenant_id: 待校验租户 ID
:param allow_null: True 表示允许平台公共返回 NoneFalse 时缺失即抛错
:return: 归一化后的 tenant_id None
:raises TenantMissingError: 租户缺失且不允许 NULL
"""
t = normalize_tenant(tenant_id)
if t is None and not allow_null:
raise TenantMissingError(
message="tenant_id is required (fail-closed)",
code=ErrorCode.TENANT_MISSING,
detail={"allow_null": allow_null},
)
return t
def allow_platform(role=None, actor=None):
"""判断当前角色是否可写平台公共数据tenant_id IS NULL"""
if role:
return str(role) in PLATFORM_ROLES
if actor:
r = actor.get("role") if isinstance(actor, dict) else None
return bool(r) and str(r) in PLATFORM_ROLES
return False
def tenant_scope(tenant_id, allow_null=False):
"""返回 (tenant_id, is_platform) 二元组,供 CRUD 层统一拼 WHERE。"""
t = require_tenant(tenant_id, allow_null=allow_null)
return t, (t is None)
def assert_not_write_protected(row, role=None, actor=None):
"""写保护断言平台公共行tenant_id IS NULL租户不可改。
:raises PblForbidden: 租户试图写平台公共数据
"""
if not isinstance(row, dict):
return row
row_tenant = normalize_tenant(row.get("tenant_id"))
if row_tenant is None and not allow_platform(role=role, actor=actor):
raise PblForbidden(
message="platform-owned row is write-protected for tenant actors",
code=ErrorCode.WRITE_PROTECTED,
detail={"tenant_id": None, "scope": WRITE_PROTECTED_SCOPE},
)
return row
def current_actor():
"""从环境变量读取当前操作者(无上下文时返回匿名,审计不因此中断)。"""
return {
"actor_id": os.environ.get("PBL_ACTOR_ID") or "anonymous",
"role": os.environ.get("PBL_ACTOR_ROLE") or "",
"tenant_id": normalize_tenant(os.environ.get("PBL_TENANT_ID")),
}
def validate_tenant_pair(op_tenant, row_tenant):
"""跨租户写防护:操作租户与目标行租户必须一致(平台角色除外)。"""
a = normalize_tenant(op_tenant)
b = normalize_tenant(row_tenant)
if a is None or b is None:
return True
if a != b:
raise PblValidationError(
message="cross-tenant write denied",
code=ErrorCode.TENANT_MISMATCH,
detail={"op_tenant": a, "row_tenant": b},
)
return True

110
pbl_blueprint/m1b/util.py Normal file
View File

@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""M1b 通用工具ID / 时间 / JSON 序列化(自包含,仅标准库)。
对应 QC 退回意见 #3pbl_common.dbutil 缺失 new_id / now_str / json_dump
本模块提供确定实现并由 tools/m1b_fix_import_closure.py 幂等回补到
pbl_common.dbutil 的导出面
"""
import json
import os
import time
import uuid
import hashlib
__all__ = [
"new_id",
"now_str",
"now_ts",
"json_dump",
"json_load",
"stable_code",
"coerce_str",
"chunked",
]
_EPOCH = 1700000000 # 2023-11-15用于压缩时间戳位数
def new_id(prefix=None):
"""生成业务 ID。
- prefix 为空返回 20 位数字字符串时间序 + 随机可直接入 BIGINT
- prefix 非空返回 "{prefix}_{12位hash}"用于 code 类字段
时间序前缀保证同一毫秒内插入顺序与 ID 单调性基本一致便于按 id 排序分页
"""
millis = int(time.time() * 1000) - _EPOCH * 1000
rand = uuid.uuid4().int % 1000000
if prefix:
h = hashlib.sha1(("%d-%d" % (millis, rand)).encode("utf-8")).hexdigest()[:12]
return "%s_%s" % (prefix, h)
return "%013d%06d" % (millis, rand)
def now_ts():
"""当前 Unix 秒。"""
return int(time.time())
def now_str(fmt="%Y-%m-%d %H:%M:%S"):
"""当前时间字符串(本地时区,秒级)。列类型为 VARCHAR避免方言差异。"""
return time.strftime(fmt, time.localtime())
def json_dump(obj):
"""安全 JSON 序列化None -> None存 NULL不可序列化对象降级为 str。"""
if obj is None:
return None
if isinstance(obj, (str, bytes)):
if isinstance(obj, bytes):
return obj.decode("utf-8", "replace")
return obj
try:
return json.dumps(obj, ensure_ascii=False, sort_keys=False, default=str)
except (TypeError, ValueError):
return json.dumps({"__repr__": repr(obj)}, ensure_ascii=False)
def json_load(text, default=None):
"""安全 JSON 反序列化:空/非法 -> default离线兜底路径依赖此行为"""
if text is None or text == "":
return default
if isinstance(text, (dict, list)):
return text
if isinstance(text, bytes):
text = text.decode("utf-8", "replace")
try:
return json.loads(text)
except (TypeError, ValueError):
return default
def stable_code(*parts):
"""由若干片段生成稳定短码(幂等注册同步用)。"""
raw = "|".join([coerce_str(p) for p in parts])
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def coerce_str(v):
"""None -> '',其它 -> str。"""
if v is None:
return ""
if isinstance(v, str):
return v
return str(v)
def chunked(seq, size):
"""按 size 切片(批量插入用)。"""
seq = list(seq)
if size <= 0:
return [seq]
return [seq[i:i + size] for i in range(0, len(seq), size)]
def env_flag(name, default=False):
"""读取布尔环境变量(测试/离线开关)。"""
raw = os.environ.get(name)
if raw is None:
return bool(default)
return raw.strip().lower() in ("1", "true", "yes", "on")

View File

@ -1,551 +1,417 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b 测试:模板平台公共部分 / 子对象扩展 / 关联表判定
"""M1b 扩展字段 + 关联判定测试(真实执行,非 py_compile
运行cd modules/pbl_blueprint && python -m pytest tests/test_m1b_ext_ref.py -q
pytest 时可直接 python tests/test_m1b_ext_ref.py
覆盖 QC 退回意见 #67 类子对象泛化契约可验收证据)与
#5关联表判定/字段映射证据):
- 7 类子对象走同一组函数签名泛化逐类断言 CRUD + 扩展值读写
- 扩展字段校验必填/枚举/数值区间/未声明字段
- 关联判定 4 种状态resolved / unresolved / stub / external
- Q-OPEN-3 守门基表写 SQL assert_base_table_immutable 拦截
- 租户隔离跨租户读不到彼此的子对象与关联
运行
python3 modules/pbl_blueprint/tests/test_m1b_ext_ref.py -v
"""
import os
import shutil
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
import tempfile
import unittest
import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
for p in (REPO_ROOT, MOD_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
from pbl_blueprint.m1b_common import ( # noqa: E402
IS_NULL, TenantRequiredError, PermissionDeniedError, ValidationError,
NotFoundError, ConflictError,
from pbl_blueprint.m1b import init as m1b_init # noqa: E402
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
get_conn, reset_conn, sql_exec, sql_rows, table_exists,
)
from pbl_blueprint import m1b_template as T # noqa: E402
from pbl_blueprint import m1b_subobject as S # noqa: E402
from pbl_blueprint import m1b_ref as R # noqa: E402
from pbl_blueprint import m1b_api as A # noqa: E402
from pbl_blueprint import m1b_init as I # noqa: E402
from pbl_blueprint.m1b.errors import ( # noqa: E402
PblForbidden, PblNotFound, PblValidationError, TenantMissingError,
)
from pbl_blueprint.m1b.ref import ( # noqa: E402
assert_base_table_immutable, list_refs, ref_contract,
ref_summary, resolve_ref,
)
from pbl_blueprint.m1b.subobject import ( # noqa: E402
SUBOBJECT_SPECS, create_subobject, delete_subobject, get_ext,
get_subobject, list_subobjects, set_ext, subobject_contract,
subobject_tree, update_subobject, validate_ext,
)
from pbl_blueprint.m1b.tables import REF_KINDS, SUBOBJECT_TYPES # noqa: E402
TENANT = "T_M1B_EXTREF_001"
OTHER = "T_M1B_EXTREF_002"
BP_ID = "BP_M1B_EXTREF_0001"
#: 每类子对象的最小合法载荷(证明 7 类都能通过同一契约创建)
SAMPLES = {
"driving_question": {"question": "如何让校园垃圾分类真正落地?", "sort_no": 1},
"learning_goal": {"goal": "能设计并执行一次校园垃圾调研", "sort_no": 1},
"mission": {"name": "M1 现状调研", "sort_no": 1},
"problem": {"title": "分类标识不清导致误投", "sort_no": 1},
"role": {"name": "调研组长", "sort_no": 1},
"learner": {"name": "张三", "sort_no": 1},
"artifact_def": {"name": "调研报告", "sort_no": 1},
}
#: 每类子对象的扩展字段值(对应 EXT_FIELD_SEED 中声明的字段键)
EXT_SAMPLES = {
"driving_question": {"cognitive_level": "分析", "weight": 0.5},
"learning_goal": {"bloom_level": "L4", "assessable": True},
"mission": {"estimated_minutes": 60, "unlock_condition": "完成 M0"},
"problem": {"difficulty": "", "hint_text": "先统计误投率"},
"role": {"team_size": 4, "responsibility": "统筹调研"},
"learner": {"grade": "高一", "group_no": 2},
"artifact_def": {"artifact_type": "文档", "rubric_ref": "rubric_report_v1"},
}
class FakeDB(object):
"""最小内存 DB支持 query/insert/update/delete与 m1b_common 适配器契约一致)。"""
class M1bExtRefTestCase(unittest.TestCase):
"""扩展字段 + 关联判定真实库测试"""
def __init__(self):
self.tables = {}
@classmethod
def setUpClass(cls):
cls.tmpdir = tempfile.mkdtemp(prefix="m1b_extref_")
cls.db_path = os.path.join(cls.tmpdir, "pbl_m1b_extref.sqlite3")
os.environ["PBL_M1B_DB"] = cls.db_path
os.environ["PBL_AUDIT_DIR"] = os.path.join(cls.tmpdir, "audit")
reset_conn()
cls.conn = get_conn(cls.db_path)
m1b_init.load_m1b(conn=cls.conn, actor_id="test_m1b_extref")
# 建 7 类子对象基表(最小列集),使泛化 CRUD 可真实落库
for st, spec in SUBOBJECT_SPECS.items():
sql_exec(
"CREATE TABLE IF NOT EXISTS %s ("
"id TEXT PRIMARY KEY, tenant_key TEXT, tenant_id TEXT, "
"blueprint_id TEXT, %s TEXT, sort_no INTEGER, name TEXT, "
"status TEXT, created_at TEXT, updated_at TEXT)"
% (spec["table"], spec["text_field"]), conn=cls.conn)
sql_exec("CREATE TABLE IF NOT EXISTS pbl_blueprint ("
"id TEXT PRIMARY KEY, tenant_key TEXT, tenant_id TEXT, "
"name TEXT, code TEXT, status TEXT, template_id TEXT, "
"template_code TEXT, template_version TEXT, category TEXT, "
"created_at TEXT, updated_at TEXT)", conn=cls.conn)
def query(self, table, conds=None, order_by=None, limit=None):
return list(self.tables.get(table, []))
@classmethod
def tearDownClass(cls):
reset_conn()
shutil.rmtree(cls.tmpdir, ignore_errors=True)
os.environ.pop("PBL_M1B_DB", None)
os.environ.pop("PBL_AUDIT_DIR", None)
def insert(self, table, row):
self.tables.setdefault(table, []).append(dict(row))
return row.get("id")
# ---------- 1. 泛化契约QC #6 ----------
def test_01_contract_covers_exactly_seven_types(self):
"""契约声明恰好 7 类,且每类都有表/主键/必填/文本字段声明。"""
c = subobject_contract()
self.assertEqual(c["type_count"], 7)
self.assertEqual(sorted(c["types"]), sorted(SUBOBJECT_TYPES))
self.assertEqual(len(SUBOBJECT_SPECS), 7)
for st, spec in SUBOBJECT_SPECS.items():
self.assertTrue(spec["table"].startswith("pbl_"))
self.assertEqual(spec["pk"], "id")
self.assertIn("blueprint_id", spec["required"])
self.assertTrue(spec["text_field"])
# 9 个统一方法签名对 7 类一致(泛化)
self.assertEqual(len(c["unified_methods"]), 9)
def update(self, table, conds, values):
n = 0
for r in self.tables.get(table, []):
if all((r.get(k) in (None, "") if isinstance(v, type(IS_NULL)) else r.get(k) == v)
for k, v in (conds or {}).items()):
r.update(values)
n += 1
return n
def test_02_unknown_subobject_type_rejected(self):
"""非法类型 fail-closed不允许静默通过"""
for bad in ("pbl_project", "unknown", "", None):
with self.assertRaises(PblValidationError):
create_subobject(TENANT, bad, BP_ID, data={"name": "x"},
conn=self.conn)
def delete(self, table, conds):
rows = self.tables.get(table, [])
keep = [r for r in rows if not all(r.get(k) == v for k, v in (conds or {}).items())]
self.tables[table] = keep
return len(rows) - len(keep)
def test_03_all_seven_types_crud_through_same_api(self):
"""7 类子对象全部通过同一组函数完成 增/查/改/删(泛化实证)。"""
created = {}
for st in SUBOBJECT_TYPES:
row = create_subobject(TENANT, st, BP_ID, data=dict(SAMPLES[st]),
actor_id="tester", conn=self.conn)
self.assertEqual(row["tenant_id"], TENANT)
self.assertEqual(row["blueprint_id"], BP_ID)
self.assertTrue(row["id"])
created[st] = row["id"]
# 读
got = get_subobject(TENANT, st, row["id"], conn=self.conn)
self.assertEqual(got["id"], row["id"])
listed = list_subobjects(TENANT, BP_ID, st, conn=self.conn)
self.assertGreaterEqual(len(listed), 1)
# 改
upd = update_subobject(TENANT, st, row["id"],
data={"sort_no": 9}, conn=self.conn)
self.assertEqual(int(upd["sort_no"]), 9)
self.assertEqual(len(created), 7)
# 删(并级联清理扩展值)
for st, oid in created.items():
n = delete_subobject(TENANT, st, oid, conn=self.conn)
self.assertEqual(int(n), 1)
with self.assertRaises(PblNotFound):
get_subobject(TENANT, st, oid, conn=self.conn)
def test_04_missing_required_field_rejected(self):
"""必填字段缺失 -> PblValidationError且不落库。"""
before = sql_rows("SELECT COUNT(*) AS n FROM pbl_mission", conn=self.conn)
with self.assertRaises(PblValidationError):
create_subobject(TENANT, "mission", BP_ID, data={"sort_no": 1},
conn=self.conn)
after = sql_rows("SELECT COUNT(*) AS n FROM pbl_mission", conn=self.conn)
self.assertEqual(before[0]["n"], after[0]["n"])
TENANT = "t-001"
OTHER = "t-002"
def test_05_tenant_isolation_on_subobjects(self):
"""跨租户读不到彼此子对象(租户打头强制隔离)。"""
row = create_subobject(TENANT, "role", BP_ID, data=dict(SAMPLES["role"]),
conn=self.conn)
with self.assertRaises(PblNotFound):
get_subobject(OTHER, "role", row["id"], conn=self.conn)
self.assertEqual(list_subobjects(OTHER, BP_ID, "role", conn=self.conn), [])
with self.assertRaises(TenantMissingError):
list_subobjects(None, BP_ID, "role", conn=self.conn)
# ---------- 2. 扩展字段QC #6 核心) ----------
def test_06_set_and_get_ext_for_all_seven_types(self):
"""7 类子对象都能写/读扩展字段值(泛化扩展接口实证)。"""
for st in SUBOBJECT_TYPES:
row = create_subobject(TENANT, st, BP_ID, data=dict(SAMPLES[st]),
conn=self.conn)
res = set_ext(TENANT, st, row["id"], EXT_SAMPLES[st],
source="manual", blueprint_id=BP_ID, conn=self.conn)
self.assertEqual(res["written"], len(EXT_SAMPLES[st]))
self.assertEqual(res["problems"], [])
got = get_ext(TENANT, st, row["id"], conn=self.conn)
for k, v in EXT_SAMPLES[st].items():
self.assertEqual(got.get(k), v, "%s.%s 扩展值不一致" % (st, k))
class TestPlatformTemplate(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
def test_07_set_ext_is_idempotent(self):
"""重复写同一扩展字段不产生重复行(唯一键幂等)。"""
row = create_subobject(TENANT, "problem", BP_ID,
data=dict(SAMPLES["problem"]), conn=self.conn)
set_ext(TENANT, "problem", row["id"], {"difficulty": ""}, conn=self.conn)
set_ext(TENANT, "problem", row["id"], {"difficulty": ""}, conn=self.conn)
rows = sql_rows(
"SELECT COUNT(*) AS n FROM pbl_subobject_ext WHERE subobject_id = ? "
"AND field_key = 'difficulty'", [row["id"]], conn=self.conn)
self.assertEqual(int(rows[0]["n"]), 1)
self.assertEqual(get_ext(TENANT, "problem", row["id"],
conn=self.conn)["difficulty"], "")
def test_create_platform_template_requires_admin(self):
with self.assertRaises(PermissionDeniedError):
T.create_template(self.db, None, {"code": "p1", "name": "平台模板"},
actor="u1", is_platform_admin=False)
def test_08_ext_validation_enum_and_range(self):
"""枚举越界 / 数值越界 / 类型错误 / 未声明字段 均被校验拦截。"""
# 枚举越界
p = validate_ext(TENANT, "driving_question", {"cognitive_level": "不存在级"},
conn=self.conn)
self.assertTrue(any(x["code"] == "ENUM" for x in p), p)
# 数值越界weight max=1
p = validate_ext(TENANT, "driving_question", {"weight": 5}, conn=self.conn)
self.assertTrue(any(x["code"] == "MAX" for x in p), p)
# 类型错误
p = validate_ext(TENANT, "mission", {"estimated_minutes": "很多"},
conn=self.conn)
self.assertTrue(any(x["code"] == "TYPE" for x in p), p)
# 未声明字段
p = validate_ext(TENANT, "role", {"not_declared_field": 1}, conn=self.conn)
self.assertTrue(any(x["code"] == "UNDECLARED" for x in p), p)
# 合法值通过
self.assertEqual(validate_ext(TENANT, "learner",
{"grade": "高二", "group_no": 3},
conn=self.conn), [])
def test_create_platform_template_ok_and_tenant_isolated(self):
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform",
"content": {"a": 1}},
actor="admin", is_platform_admin=True)
self.assertTrue(r["ok"])
self.assertTrue(r["is_platform"])
self.assertIsNone(r["template"]["tenant_id"])
# 租户可见平台公共模板
lst = T.list_templates(self.db, TENANT)
self.assertEqual(lst["total"], 1)
self.assertTrue(lst["items"][0]["is_platform"])
self.assertFalse(lst["items"][0]["editable"])
# 其他租户同样可见(公共)
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 1)
def test_09_set_ext_rejects_invalid_values(self):
"""set_ext 校验失败即抛错且不落库。"""
row = create_subobject(TENANT, "artifact_def", BP_ID,
data=dict(SAMPLES["artifact_def"]), conn=self.conn)
before = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
with self.assertRaises(PblValidationError):
set_ext(TENANT, "artifact_def", row["id"],
{"artifact_type": "不存在的类型"}, conn=self.conn)
after = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
self.assertEqual(before[0]["n"], after[0]["n"])
def test_tenant_cannot_write_platform_template(self):
T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform"},
actor="admin", is_platform_admin=True)
lst = T.list_templates(self.db, TENANT)
tid = lst["items"][0]["id"]
with self.assertRaises(PermissionDeniedError):
T.update_template(self.db, TENANT, tid, {"name": "改名"}, actor="u1",
is_platform_admin=False)
with self.assertRaises(PermissionDeniedError):
T.delete_template(self.db, TENANT, tid, actor="u1", is_platform_admin=False)
def test_10_create_with_inline_ext(self):
"""创建子对象时可内联 ext一次调用完成基础行+扩展值。"""
row = create_subobject(
TENANT, "learning_goal", BP_ID,
data=dict(SAMPLES["learning_goal"],
ext={"bloom_level": "L5", "assessable": True}),
conn=self.conn)
self.assertEqual(row["ext"]["bloom_level"], "L5")
self.assertEqual(get_ext(TENANT, "learning_goal", row["id"],
conn=self.conn)["assessable"], True)
def test_tenant_template_not_visible_to_others(self):
T.create_template(self.db, TENANT, {"code": "t1", "name": "租户模板"}, actor="u1")
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 0)
tid = T.list_templates(self.db, TENANT)["items"][0]["id"]
with self.assertRaises(NotFoundError):
T.get_template(self.db, OTHER, tid)
def test_11_delete_cascades_ext_values(self):
"""删除子对象级联清理扩展值(应用层级联,无外键)。"""
row = create_subobject(TENANT, "mission", BP_ID,
data=dict(SAMPLES["mission"]), conn=self.conn)
set_ext(TENANT, "mission", row["id"], {"estimated_minutes": 30},
conn=self.conn)
self.assertGreater(len(get_ext(TENANT, "mission", row["id"],
conn=self.conn)), 0)
delete_subobject(TENANT, "mission", row["id"], conn=self.conn)
left = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
self.assertEqual(int(left[0]["n"]), 0)
def test_missing_tenant_fail_closed(self):
with self.assertRaises(TenantRequiredError):
T.list_templates(self.db, None)
with self.assertRaises(TenantRequiredError):
T.list_templates(self.db, " ")
def test_12_subobject_tree_aggregates_seven_types(self):
"""subobject_tree 一次取全 7 类 + 扩展值。"""
bp = "BP_TREE_%s" % os.getpid()
for st in SUBOBJECT_TYPES:
create_subobject(TENANT, st, bp, data=dict(SAMPLES[st]),
conn=self.conn)
oid = list_subobjects(TENANT, bp, st, conn=self.conn)[0]["id"]
set_ext(TENANT, st, oid, EXT_SAMPLES[st], conn=self.conn,
blueprint_id=bp)
tree = subobject_tree(TENANT, bp, conn=self.conn)
self.assertEqual(sorted(tree["subobjects"].keys()),
sorted(SUBOBJECT_TYPES))
self.assertEqual(tree["counts"]["__total__"], 7)
for st in SUBOBJECT_TYPES:
self.assertEqual(len(tree["subobjects"][st]), 1)
self.assertTrue(tree["subobjects"][st][0]["ext"])
def test_code_unique_per_scope(self):
T.create_template(self.db, None, {"code": "same", "name": "平台", "scope": "platform"},
actor="admin", is_platform_admin=True)
# 同 code 在不同租户域可共存
T.create_template(self.db, TENANT, {"code": "same", "name": "租户"}, actor="u1")
with self.assertRaises(ConflictError):
T.create_template(self.db, TENANT, {"code": "same", "name": "重复"}, actor="u1")
with self.assertRaises(ConflictError):
T.create_template(self.db, None, {"code": "same", "name": "重复平台",
"scope": "platform"},
actor="admin", is_platform_admin=True)
# ---------- 3. 关联表判定QC #5 + Q-OPEN-3 ----------
def test_13_ref_contract_declares_readonly_and_no_fk(self):
"""关联契约声明4 域只读、无外键、基表零写入。"""
c = ref_contract()
self.assertEqual(sorted(c["ref_kinds"]), sorted(REF_KINDS))
self.assertEqual(c["ownership"], "read_only")
self.assertTrue(c["q_open_3"]["no_foreign_key"])
self.assertEqual(c["q_open_3"]["base_table_writes"], 0)
self.assertEqual(sorted(c["base_tables_readonly"].keys()),
["entity", "scene", "script", "world"])
def test_fork_platform_to_tenant(self):
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
"scope": "platform",
"content": {"missions": [{"id": "m1"}]},
"ext_schema": {"mission": {"difficulty": "hard"}}},
actor="admin", is_platform_admin=True)
f = T.fork_template(self.db, TENANT, r["id"], actor="u1", new_code="p1_local")
self.assertTrue(f["ok"])
self.assertEqual(f["template"]["tenant_id"], TENANT)
self.assertEqual(f["template"]["source"], "fork")
self.assertEqual(f["template"]["source_template_id"], r["id"])
# 派生副本可写
T.update_template(self.db, TENANT, f["id"], {"name": "本地改名"}, actor="u1")
# 平台模板未受影响
self.assertEqual(T.get_template(self.db, TENANT, r["id"])["name"], "平台模板")
def test_14_ref_stub_when_base_table_unavailable(self):
"""基表不可达 -> resolve_status='stub'不报错、不建表Q-OPEN-3"""
row = resolve_ref(TENANT, BP_ID, "world", "W_NOT_EXIST_1",
actor_id="tester", conn=self.conn)
self.assertEqual(row["resolve_status"], "stub")
self.assertEqual(row["ownership"], "read_only")
self.assertIn("not available", row["resolve_note"])
self.assertFalse(table_exists("world", conn=self.conn),
"判定过程不得创建 world 基表")
def test_builtin_template_cannot_be_deleted(self):
r = T.create_template(self.db, None, {"code": "b1", "name": "内置", "scope": "platform",
"is_builtin": 1, "content": {"x": 1}},
actor="admin", is_platform_admin=True)
with self.assertRaises(PermissionDeniedError):
T.delete_template(self.db, TENANT, r["id"], actor="admin", is_platform_admin=True)
self.assertTrue(T.deprecate_template(self.db, TENANT, r["id"], actor="admin",
is_platform_admin=True)["ok"])
def test_15_ref_resolved_with_readonly_snapshot(self):
"""基表可达且行存在 -> resolved + 只读快照。"""
sql_exec("CREATE TABLE IF NOT EXISTS world (id TEXT PRIMARY KEY, "
"name TEXT, code TEXT, status TEXT)", conn=self.conn)
sql_exec("INSERT INTO world (id, name, code, status) VALUES (?,?,?,?)",
["W_M1B_1", "M1b 测试世界", "world_m1b_1", "published"],
conn=self.conn)
row = resolve_ref(TENANT, BP_ID, "world", "W_M1B_1", conn=self.conn)
self.assertEqual(row["resolve_status"], "resolved")
self.assertEqual(row["ref_table"], "world")
self.assertEqual(row["ref_code"], "world_m1b_1")
self.assertTrue(row["ref_snapshot_json"])
# 快照是只读副本:改基表不影响已落快照
sql_exec("UPDATE world SET name = '改名后' WHERE id = 'W_M1B_1'",
conn=self.conn)
stored = [r for r in list_refs(TENANT, blueprint_id=BP_ID, conn=self.conn)
if r["ref_id"] == "W_M1B_1"][0]
self.assertEqual(stored["ref_snapshot"]["name"], "M1b 测试世界")
def test_publish_requires_content(self):
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "空模板"}, actor="u1")
with self.assertRaises(ValidationError):
T.publish_template(self.db, TENANT, r["id"], actor="u1")
T.update_template(self.db, TENANT, r["id"], {"content": {"missions": []}}, actor="u1")
self.assertEqual(T.publish_template(self.db, TENANT, r["id"], actor="u1")["status"],
"published")
def test_16_ref_unresolved_when_row_missing(self):
"""基表可达但行不存在 -> unresolved。"""
row = resolve_ref(TENANT, BP_ID, "world", "W_MISSING_ROW", conn=self.conn)
self.assertEqual(row["resolve_status"], "unresolved")
self.assertIn("not found", row["resolve_note"])
def test_instantiate_platform_template_sets_tenant(self):
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
"scope": "platform", "status": "published",
"content": {"missions": [{"id": "m1"}]},
"subobject_kinds": ["mission"]},
actor="admin", is_platform_admin=True)
captured = {}
def test_17_ref_external_is_stub(self):
"""external 域恒为 stubM1b 不解析外部系统)。"""
row = resolve_ref(TENANT, BP_ID, "external", "EXT_1", conn=self.conn)
self.assertEqual(row["resolve_status"], "stub")
self.assertEqual(row["ref_table"], "")
def fake_create(db, tenant_id, payload, actor=None):
captured["tenant_id"] = tenant_id
captured["payload"] = payload
return {"ok": True, "id": "bp-1"}
def test_18_ref_invalid_kind_rejected(self):
"""非法 ref_kind / 缺 ref_id -> PblValidationError。"""
with self.assertRaises(PblValidationError):
resolve_ref(TENANT, BP_ID, "not_a_kind", "X1", conn=self.conn)
with self.assertRaises(PblValidationError):
resolve_ref(TENANT, BP_ID, "world", "", conn=self.conn)
with self.assertRaises(TenantMissingError):
resolve_ref(None, BP_ID, "world", "X1", conn=self.conn)
res = T.instantiate_template(self.db, TENANT, r["id"], actor="u1",
create_blueprint=fake_create)
self.assertTrue(res["ok"])
self.assertTrue(res["is_platform_template"])
self.assertEqual(captured["tenant_id"], TENANT) # 实例绝不继承 NULL
self.assertEqual(captured["payload"]["tenant_id"], TENANT)
self.assertEqual(captured["payload"]["template_id"], r["id"])
# usage_count +1
self.assertEqual(int(T.get_template(self.db, TENANT, r["id"])["usage_count"]), 1)
def test_19_ref_idempotent_and_subobject_scoped(self):
"""同目标+同发起方重复判定不增行;子对象级关联可区分。"""
r1 = resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", conn=self.conn)
r2 = resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", conn=self.conn)
self.assertEqual(r1["id"], r2["id"])
n = sql_rows("SELECT COUNT(*) AS c FROM pbl_blueprint_ref "
"WHERE ref_id = 'S_M1B_1' AND subobject_type = 'blueprint'",
conn=self.conn)
self.assertEqual(int(n[0]["c"]), 1)
row = create_subobject(TENANT, "mission", BP_ID,
data=dict(SAMPLES["mission"]), conn=self.conn)
resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", subobject_type="mission",
subobject_id=row["id"], conn=self.conn)
n2 = sql_rows("SELECT COUNT(*) AS c FROM pbl_blueprint_ref "
"WHERE ref_id = 'S_M1B_1'", conn=self.conn)
self.assertEqual(int(n2[0]["c"]), 2, "子对象级关联应独立成行")
def test_instantiate_deprecated_rejected(self):
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "x", "content": {"a": 1}},
actor="u1")
T.deprecate_template(self.db, TENANT, r["id"], actor="u1")
with self.assertRaises(ValidationError):
T.instantiate_template(self.db, TENANT, r["id"], actor="u1")
def test_20_ref_summary_and_tenant_isolation(self):
"""汇总报表按 kind×status 计数;跨租户不可见。"""
resolve_ref(OTHER, BP_ID, "entity", "E_OTHER_1", conn=self.conn)
s = ref_summary(TENANT, blueprint_id=BP_ID, conn=self.conn)
self.assertEqual(s["ownership"], "read_only")
self.assertEqual(s["base_tables_modified"], 0)
self.assertGreaterEqual(s["total"], 3)
self.assertIn("world", s["by_kind"])
self.assertEqual(s["by_status"].get("resolved", 0), 1)
rows = list_refs(OTHER, blueprint_id=BP_ID, conn=self.conn)
self.assertTrue(all(r["tenant_id"] == OTHER for r in rows))
self.assertFalse(any(r["ref_id"] == "E_OTHER_1"
for r in list_refs(TENANT, conn=self.conn)))
def test_seed_idempotent(self):
a = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
"content": {"a": 1}}], actor="system")
self.assertEqual(a["created_count"], 1)
b = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
"content": {"a": 1}}], actor="system")
self.assertEqual(b["created_count"], 0)
self.assertEqual(b["skipped_count"], 1)
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
def test_21_base_table_write_sql_is_blocked(self):
"""Q-OPEN-3 守门:对基表的写 SQL 被静态拦截。"""
bad = [
"UPDATE world SET name = 'x' WHERE id = '1'",
"DELETE FROM scene WHERE id = '1'",
"INSERT INTO entity (id) VALUES ('1')",
"ALTER TABLE script ADD COLUMN tenant_id VARCHAR(64)",
]
for sql in bad:
with self.assertRaises(PblForbidden):
assert_base_table_immutable(sql)
# 只读与自有表写入放行
self.assertTrue(assert_base_table_immutable(
"SELECT * FROM world WHERE id = '1'"))
self.assertTrue(assert_base_table_immutable(
"INSERT INTO pbl_blueprint_ref (id) VALUES ('1')"))
class TestSubobjectExt(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
self.bp = "bp-1"
S.create_ext_field_def(self.db, None, {
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
"label": "难度", "value_type": "enum", "enum_options": ["easy", "medium", "hard"],
"required": 1}, actor="admin", is_platform_admin=True)
S.create_ext_field_def(self.db, None, {
"scope": "platform", "subobject_kind": "mission", "ext_key": "estimated_minutes",
"label": "耗时", "value_type": "int", "constraints": {"min": 1, "max": 600}},
actor="admin", is_platform_admin=True)
def test_undefined_ext_key_rejected(self):
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "not_defined", "x", actor="u1")
def test_invalid_kind_rejected(self):
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "not_a_kind", "m1", "difficulty", "easy",
actor="u1")
def test_enum_and_int_validation(self):
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "impossible",
actor="u1")
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", 9999,
actor="u1")
r = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", "90",
actor="u1")
self.assertEqual(r["value"], 90) # 字符串按定义转 int
self.assertEqual(r["value_type"], "int")
def test_upsert_idempotent(self):
a = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
self.assertTrue(a["created"])
b = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "hard", actor="u1")
self.assertFalse(b["created"])
self.assertEqual(b["id"], a["id"])
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"),
"hard")
self.assertEqual(len(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]), 1)
def test_tenant_isolation_on_ext(self):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
self.assertEqual(S.get_ext(self.db, OTHER, self.bp, "mission", "m1", "difficulty"), None)
self.assertEqual(S.list_ext(self.db, OTHER, blueprint_id=self.bp)["total"], 0)
with self.assertRaises(TenantRequiredError):
S.list_ext(self.db, None, blueprint_id=self.bp)
def test_tenant_def_overrides_platform(self):
S.create_ext_field_def(self.db, TENANT, {
"subobject_kind": "mission", "ext_key": "difficulty", "label": "本地难度",
"value_type": "enum", "enum_options": ["L1", "L2"]}, actor="u1")
d = S.resolve_ext_field_def(self.db, TENANT, "mission", "difficulty")
self.assertFalse(d["is_platform"])
self.assertEqual(d["label"], "本地难度")
# 平台枚举值在租户覆盖后失效
with self.assertRaises(ValidationError):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "L1", actor="u1")
# 其他租户仍用平台定义
d2 = S.resolve_ext_field_def(self.db, OTHER, "mission", "difficulty")
self.assertTrue(d2["is_platform"])
def test_any_kind_def_visible(self):
S.create_ext_field_def(self.db, None, {"scope": "platform", "subobject_kind": "any",
"ext_key": "tags", "label": "标签",
"value_type": "json"},
actor="admin", is_platform_admin=True)
r = S.set_ext(self.db, TENANT, self.bp, "role", "r1", "tags", ["a", "b"], actor="u1")
self.assertEqual(r["value"], ["a", "b"])
def test_bulk_set_all_or_nothing(self):
with self.assertRaises(ValidationError) as cm:
S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
{"difficulty": "easy", "estimated_minutes": 99999}, actor="u1")
self.assertTrue(cm.exception.detail.get("errors"))
self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
ok = S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
{"difficulty": "easy", "estimated_minutes": 60}, actor="u1")
self.assertEqual(ok["count"], 2)
def test_apply_template_ext_schema_no_overwrite(self):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
res = S.apply_template_ext_schema(
self.db, TENANT, self.bp,
{"mission": {"difficulty": "hard", "estimated_minutes": 45}},
{"mission": ["m1", "m2"]}, actor="u1", source_template_id="tpl-x")
self.assertEqual(res["applied_count"], 2) # m1.estimated_minutes + m2.estimated_minutes
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"),
"easy") # 已填不覆盖
self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m2", "difficulty"),
"hard")
rows = S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]
self.assertTrue(all(r["source"] in ("manual", "template_instantiate") for r in rows))
self.assertTrue(any(r["source_template_id"] == "tpl-x" for r in rows))
def test_subobject_tree_ext(self):
S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1",
{"difficulty": "easy", "estimated_minutes": 30}, actor="u1")
tree = S.subobject_tree_ext(self.db, TENANT, self.bp)["ext"]
self.assertEqual(tree["mission"]["m1"]["difficulty"], "easy")
self.assertEqual(tree["mission"]["m1"]["estimated_minutes"], 30)
def test_delete_ext(self):
S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1")
self.assertEqual(S.delete_ext(self.db, TENANT, self.bp, "mission", "m1",
ext_key="difficulty", actor="u1")["deleted_count"], 1)
self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
def test_ext_def_delete_blocked_when_in_use(self):
d = S.create_ext_field_def(self.db, TENANT, {
"subobject_kind": "role", "ext_key": "min_players", "label": "最少人数",
"value_type": "int"}, actor="u1")
S.set_ext(self.db, TENANT, self.bp, "role", "r1", "min_players", 2, actor="u1")
with self.assertRaises(ConflictError):
S.delete_ext_field_def(self.db, TENANT, d["id"], actor="u1")
def test_platform_ext_def_write_requires_admin(self):
with self.assertRaises(PermissionDeniedError):
S.create_ext_field_def(self.db, None, {"scope": "platform",
"subobject_kind": "role",
"ext_key": "x", "label": "x"},
actor="u1", is_platform_admin=False)
class TestRefTable(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
self.bp = "bp-1"
def test_classify_ref_whitelist(self):
self.assertEqual(R.classify_ref("world", "world")[0], True)
self.assertEqual(R.classify_ref("world", "employee")[0], False) # 域-表不匹配
self.assertEqual(R.classify_ref("unknown_domain", "x")[0], False)
with self.assertRaises(ValidationError):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "secret_tbl",
"ref_id": "w1"}, actor="u1")
def test_add_ref_and_idempotent(self):
a = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1", "rel_type": "binds",
"required": 1}, actor="u1")
self.assertTrue(a["created"])
b = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1", "rel_type": "binds",
"required": 0}, actor="u1")
self.assertFalse(b["created"])
self.assertEqual(b["id"], a["id"])
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1)
def test_ref_tenant_isolation(self):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
self.assertEqual(R.list_refs(self.db, OTHER, blueprint_id=self.bp)["total"], 0)
with self.assertRaises(TenantRequiredError):
R.list_refs(self.db, None, blueprint_id=self.bp)
with self.assertRaises(NotFoundError):
R.get_ref(self.db, OTHER, R.list_refs(self.db, TENANT,
blueprint_id=self.bp)["items"][0]["id"])
def test_src_kind_validation(self):
with self.assertRaises(ValidationError):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene",
"ref_id": "s1", "src_kind": "mission"},
actor="u1") # 缺 src_id
ok = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene",
"ref_id": "s1", "src_kind": "mission",
"src_id": "m1"}, actor="u1")
self.assertTrue(ok["ok"])
def test_bulk_all_or_nothing(self):
with self.assertRaises(ValidationError):
R.bulk_add_refs(self.db, TENANT, self.bp, [
{"ref_domain": "world", "ref_table": "world", "ref_id": "w1"},
{"ref_domain": "bad", "ref_table": "bad", "ref_id": "x"}], actor="u1")
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 0)
def test_resolve_refs_with_reader(self):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w-exist", "required": 1}, actor="u1")
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w-gone", "required": 1}, actor="u1")
def reader(db, table, rid):
return {"id": rid} if rid == "w-exist" else None
res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1", reader=reader)
self.assertEqual(res["resolved_count"], 1)
self.assertEqual(res["missing_count"], 1)
self.assertEqual(len(res["blocking"]), 1)
rows = R.list_refs(self.db, TENANT, blueprint_id=self.bp)["items"]
st = {r["ref_id"]: r["resolve_status"] for r in rows}
self.assertEqual(st["w-exist"], "resolved")
self.assertEqual(st["w-gone"], "missing")
def test_resolve_without_reader_stays_unknown(self):
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1")
self.assertEqual(res["unknown_count"], 1)
self.assertEqual(res["missing_count"], 0) # 不臆断 missing
def test_impact_of_reverse_lookup(self):
R.add_ref(self.db, TENANT, "bp-1", {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1", "required": 1}, actor="u1")
R.add_ref(self.db, TENANT, "bp-2", {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
R.add_ref(self.db, OTHER, "bp-9", {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u2")
imp = R.impact_of(self.db, TENANT, "world", "world", "w1")
self.assertEqual(imp["blueprint_count"], 2) # 跨租户不泄露
self.assertEqual(sorted(imp["blueprint_ids"]), ["bp-1", "bp-2"])
self.assertFalse(imp["safe_to_delete"])
self.assertTrue(imp["warning"])
R.remove_ref(self.db, TENANT, R.list_refs(self.db, TENANT, blueprint_id="bp-1")["items"][0]["id"], actor="u1")
self.assertTrue(R.impact_of(self.db, TENANT, "world", "world", "w1")["safe_to_delete"])
def test_refs_from_content_extraction(self):
content = {
"world_id": "w-100",
"missions": [{"id": "m1", "kind": "mission", "scene_id": "sc-1"},
{"id": "m2", "kind": "mission", "scene_id": "sc-1"}],
"project": {"id": "pj1", "kind": "project", "game_id": "g-1"},
}
refs = R.refs_from_content(content)
keys = {(r["ref_domain"], r["ref_id"]) for r in refs}
self.assertIn(("world", "w-100"), keys)
self.assertIn(("scene", "sc-1"), keys)
self.assertIn(("scense_game", "g-1"), keys)
self.assertEqual(len(refs), len(keys)) # 去重生效
def test_sync_refs_from_content_idempotent(self):
content = {"world_id": "w-1", "missions": [{"id": "m1", "kind": "mission",
"scene_id": "sc-1"}]}
a = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1")
self.assertEqual(a["added_count"], 2)
b = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1")
self.assertEqual(b["added_count"], 0)
self.assertEqual(b["removed_count"], 0)
# content 去掉 world 引用 → 关联边同步软删
c = R.sync_refs_from_content(self.db, TENANT, self.bp,
{"missions": [{"id": "m1", "kind": "mission",
"scene_id": "sc-1"}]}, actor="u1")
self.assertEqual(c["removed_count"], 1)
self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1)
def test_world_table_untouched(self):
"""Q-OPEN-3关联操作绝不写 world 基表。"""
R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world",
"ref_id": "w1"}, actor="u1")
R.sync_refs_from_content(self.db, TENANT, self.bp, {"world_id": "w2"}, actor="u1")
R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1",
reader=lambda db, t, i: {"id": i})
self.assertNotIn("world", self.db.tables)
self.assertNotIn("scene", self.db.tables)
self.assertIn("pbl_blueprint_ref", self.db.tables)
class TestApiDispatch(unittest.TestCase):
def setUp(self):
self.db = FakeDB()
def test_route_dispatch_platform_template(self):
r = A.dispatch(self.db, "POST", "/pbl/templates",
{"tenant_id": None, "is_platform_admin": True, "actor": "admin",
"code": "p1", "name": "平台模板", "scope": "platform",
"content": {"a": 1}})
self.assertTrue(r["ok"], r)
lst = A.dispatch(self.db, "GET", "/pbl/templates", {"tenant_id": TENANT})
self.assertEqual(lst["total"], 1)
tid = lst["items"][0]["id"]
det = A.dispatch(self.db, "GET", "/pbl/templates/%s" % tid, {"tenant_id": TENANT})
self.assertTrue(det["template"]["is_platform"])
bad = A.dispatch(self.db, "PUT", "/pbl/templates/%s" % tid,
{"tenant_id": TENANT, "actor": "u1", "name": "x"})
self.assertFalse(bad["ok"])
self.assertEqual(bad["code"], "PBL_PLATFORM_ADMIN_REQUIRED")
self.assertEqual(bad["http_status"], 403)
def test_route_missing_tenant_fail_closed(self):
r = A.dispatch(self.db, "GET", "/pbl/templates", {})
self.assertFalse(r["ok"])
self.assertEqual(r["code"], "PBL_TENANT_REQUIRED")
def test_route_unknown(self):
r = A.dispatch(self.db, "GET", "/pbl/nope", {"tenant_id": TENANT})
self.assertEqual(r["code"], "PBL_ROUTE_NOT_FOUND")
def test_route_ext_and_refs(self):
A.dispatch(self.db, "POST", "/pbl/ext-defs",
{"tenant_id": None, "is_platform_admin": True, "actor": "admin",
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
"label": "难度", "value_type": "enum",
"enum_options": ["easy", "hard"]})
r = A.dispatch(self.db, "PUT", "/pbl/blueprints/bp-1/ext",
{"tenant_id": TENANT, "actor": "u1", "kind": "mission",
"subobject_id": "m1", "values": {"difficulty": "easy"}})
self.assertTrue(r["ok"], r)
g = A.dispatch(self.db, "GET", "/pbl/blueprints/bp-1/ext", {"tenant_id": TENANT})
self.assertEqual(g["ext"]["mission"]["m1"]["difficulty"], "easy")
add = A.dispatch(self.db, "POST", "/pbl/blueprints/bp-1/refs",
{"tenant_id": TENANT, "actor": "u1",
"ref_domain": "world", "ref_table": "world", "ref_id": "w1"})
self.assertTrue(add["ok"], add)
imp = A.dispatch(self.db, "GET", "/pbl/refs/impact",
{"tenant_id": TENANT, "ref_domain": "world", "ref_table": "world",
"ref_id": "w1"})
self.assertEqual(imp["blueprint_count"], 1)
class TestInitSeed(unittest.TestCase):
def test_init_m1b_seeds_platform_data(self):
db = FakeDB()
res = I.init_m1b(db=db, seed=True, actor="system")
self.assertTrue(res["ok"])
self.assertEqual(res["q_open_3"], "world/scene/entity 基表零改动")
self.assertNotIn("world", db.tables)
tpls = T.list_templates(db, TENANT)
self.assertGreaterEqual(tpls["total"], 3)
self.assertTrue(all(t["is_platform"] for t in tpls["items"]))
defs = S.list_ext_field_defs(db, TENANT)
self.assertGreaterEqual(defs["total"], 20)
self.assertTrue(all(d["is_platform"] for d in defs["items"]))
# 幂等
again = I.seed_platform_data(db, actor="system")
self.assertEqual(again["templates"]["created_count"], 0)
self.assertEqual(again["ext_defs"]["created_count"], 0)
self.assertEqual(T.list_templates(db, TENANT)["total"], tpls["total"])
def test_platform_seed_usable_end_to_end(self):
db = FakeDB()
I.init_m1b(db=db, seed=True)
tpl = [t for t in T.list_templates(db, TENANT)["items"]
if t["code"] == "pbl-stem-water-quality"][0]
full = T.get_template(db, TENANT, tpl["id"])
inst = T.instantiate_template(db, TENANT, tpl["id"], actor="u1")
self.assertTrue(inst["result"]["deferred"])
self.assertEqual(inst["result"]["payload"]["tenant_id"], TENANT)
# 模板 ext_schema 默认值可落到子对象扩展
res = S.apply_template_ext_schema(
db, TENANT, "bp-new", full["ext_schema"],
{"mission": ["ms-1", "ms-2"], "role": ["rl-1"], "project": ["pj-1"],
"driving_question": ["dq-1"], "artifact_def": ["af-1"]},
actor="u1", source_template_id=tpl["id"])
self.assertGreater(res["applied_count"], 5)
self.assertEqual(S.get_ext(db, TENANT, "bp-new", "mission", "ms-1", "difficulty"),
"medium")
self.assertEqual(S.get_ext(db, TENANT, "bp-new", "project", "pj-1", "assessment_mode"),
"rubric")
# content 中的跨域引用可抽取(本模板无 world_id → 0 条,不报错)
sync = R.sync_refs_from_content(db, TENANT, "bp-new", full["content"], actor="u1")
self.assertTrue(sync["ok"])
def test_22_generated_ddl_contains_no_fk_and_no_base_table(self):
"""生成的 sqlite/mariadb DDL 均无 FOREIGN KEY、不含基表建表语句。"""
ddl = m1b_init.SQLITE_DDL.upper()
self.assertNotIn("FOREIGN KEY", ddl)
for base in ("CREATE TABLE IF NOT EXISTS WORLD",
"CREATE TABLE IF NOT EXISTS SCENE",
"CREATE TABLE IF NOT EXISTS ENTITY",
"CREATE TABLE IF NOT EXISTS SCRIPT"):
self.assertNotIn(base, ddl)
for t in TABLES:
self.assertIn("CREATE TABLE IF NOT EXISTS %s" % t["name"].upper(), ddl)
if __name__ == "__main__":

View File

@ -0,0 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b import 闭包测试QC 退回意见 #1/#2/#3/#4 的验收断言)。
真实执行三类核验
1. 静态闭包tools/m1b_fix_import_closure.py 扫描 modules/pbl_* 全部 .py
跨文件符号引用断裂数必须为 0
2. 动态导入pbl_blueprint.m1b 全子模块 + pbl_blueprint.api +
pbl_agent_runtime.api 必须可 import ImportError
3. 符号存在性QC 点名的每个符号在对应模块上真实可取到且可调用
运行
python3 modules/pbl_blueprint/tests/test_m1b_import_closure.py -v
"""
import importlib
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
TOOLS = os.path.join(MOD_ROOT, "tools")
for p in (REPO_ROOT, MOD_ROOT, TOOLS):
if p not in sys.path:
sys.path.insert(0, p)
import m1b_fix_import_closure as closure # noqa: E402
#: QC 点名的符号 -> 应能取到它的模块列表
REQUIRED_SYMBOLS = {
"PblError": ["pbl_blueprint.m1b.errors", "pbl_common.errors",
"pbl_blueprint.db"],
"PblNotFound": ["pbl_blueprint.m1b.errors", "pbl_common.errors",
"pbl_blueprint.db"],
"PblValidationError": ["pbl_blueprint.m1b.errors", "pbl_common.errors",
"pbl_blueprint.db"],
"require_tenant": ["pbl_blueprint.m1b.tenant", "pbl_common.tenant",
"pbl_blueprint.db"],
"write_audit": ["pbl_blueprint.m1b.audit", "pbl_common.audit"],
"tenant_crud": ["pbl_blueprint.m1b.crud_factory", "pbl_common.crud_factory"],
"new_id": ["pbl_blueprint.m1b.util", "pbl_common.dbutil"],
"now_str": ["pbl_blueprint.m1b.util", "pbl_common.dbutil"],
"sql_exec": ["pbl_blueprint.m1b.dbutil", "pbl_common.dbutil"],
"sql_rows": ["pbl_blueprint.m1b.dbutil", "pbl_common.dbutil"],
"sql_scalar": ["pbl_blueprint.m1b.dbutil", "pbl_common.dbutil"],
"assert_not_write_protected": ["pbl_blueprint.m1b.tenant",
"pbl_common.tenant"],
"pbl_template_instantiate": ["pbl_blueprint.m1b.api", "pbl_blueprint.api"],
"pbl_blueprint_create": ["pbl_blueprint.m1b.api", "pbl_blueprint.api"],
}
M1B_SUBMODULES = [
"pbl_blueprint.m1b",
"pbl_blueprint.m1b.errors",
"pbl_blueprint.m1b.util",
"pbl_blueprint.m1b.tenant",
"pbl_blueprint.m1b.dbutil",
"pbl_blueprint.m1b.audit",
"pbl_blueprint.m1b.crud_factory",
"pbl_blueprint.m1b.tables",
"pbl_blueprint.m1b.subobject",
"pbl_blueprint.m1b.ref",
"pbl_blueprint.m1b.template",
"pbl_blueprint.m1b.api",
"pbl_blueprint.m1b.init",
]
class ImportClosureTestCase(unittest.TestCase):
"""import 闭包硬门禁断言。"""
@classmethod
def setUpClass(cls):
# 先执行幂等修复(补齐兼容导出层),再核验闭包
cls.fix_log = closure.apply_fix(dry=False)
cls.breaks = closure.scan_closure()
def test_01_static_closure_has_zero_breaks(self):
"""静态扫描:跨文件符号引用断裂数 = 0QC #1 硬门禁)。"""
self.assertEqual(
self.breaks, [],
"import 闭包仍有 %d 处断裂:\n%s" % (
len(self.breaks),
"\n".join(" %s:%s -> %s.%s (%s)" % (
b["file"], b["line"], b["module"], b["symbol"], b["reason"])
for b in self.breaks[:40])))
def test_02_scanned_files_cover_pbl_packages(self):
"""扫描确实覆盖了 pbl_* 包(不是空扫)。"""
files = list(closure.iter_py_files())
self.assertGreater(len(files), 20, "扫描文件数过少,核验无意义")
pkgs = set()
for f in files:
rel = os.path.relpath(f, closure.MODULES_DIR).replace(os.sep, "/")
pkgs.add(rel.split("/")[0])
self.assertIn("pbl_blueprint", pkgs)
self.assertIn("pbl_common", pkgs)
def test_03_m1b_submodules_importable(self):
"""M1b 全部子模块可真实 import动态闭包"""
for mod in M1B_SUBMODULES:
with self.subTest(module=mod):
m = importlib.import_module(mod)
self.assertIsNotNone(m)
def test_04_pbl_blueprint_api_importable(self):
"""pbl_blueprint.api 可 importQC #2/#4 断裂点)。"""
m = importlib.import_module("pbl_blueprint.api")
self.assertTrue(hasattr(m, "pbl_template_instantiate"),
"pbl_blueprint.api 缺 pbl_template_instantiate")
self.assertTrue(hasattr(m, "pbl_blueprint_create"),
"pbl_blueprint.api 缺 pbl_blueprint_create")
self.assertTrue(callable(m.pbl_template_instantiate))
self.assertTrue(callable(m.pbl_blueprint_create))
def test_05_pbl_blueprint_db_importable(self):
"""pbl_blueprint.db 可 import 且提供 QC #2 点名符号。"""
m = importlib.import_module("pbl_blueprint.db")
for sym in ("PblError", "PblNotFound", "PblValidationError",
"require_tenant"):
with self.subTest(symbol=sym):
self.assertTrue(hasattr(m, sym), "pbl_blueprint.db 缺 %s" % sym)
def test_06_pbl_common_compat_surface(self):
"""pbl_common 四个模块的兼容导出面齐备QC #3"""
targets = {
"pbl_common.audit": ["write_audit"],
"pbl_common.crud_factory": ["tenant_crud"],
"pbl_common.dbutil": ["new_id", "now_str", "sql_exec", "sql_rows",
"sql_scalar"],
}
for mod, syms in targets.items():
try:
m = importlib.import_module(mod)
except ImportError as exc:
self.skipTest("%s 不可导入(%s),跳过" % (mod, exc))
continue
for s in syms:
with self.subTest(module=mod, symbol=s):
self.assertTrue(hasattr(m, s), "%s%s" % (mod, s))
def test_07_required_symbols_resolvable(self):
"""QC 点名的每个符号在其目标模块上真实可取到。"""
missing = []
for sym, mods in REQUIRED_SYMBOLS.items():
for mod in mods:
try:
m = importlib.import_module(mod)
except ImportError as exc:
missing.append("%s.%s (module import failed: %s)"
% (mod, sym, exc))
continue
if not hasattr(m, sym):
missing.append("%s.%s" % (mod, sym))
self.assertEqual(missing, [], "符号缺失: %s" % missing)
def test_08_pbl_agent_runtime_dependency_satisfied(self):
"""pbl_agent_runtime.api 的 M1b 依赖面可满足QC #4"""
try:
m = importlib.import_module("pbl_agent_runtime.api")
except ImportError as exc:
# 该模块可能依赖运行期平台组件;退化为静态核验其 import 目标
src_path = os.path.join(closure.MODULES_DIR, "pbl_agent_runtime",
"pbl_agent_runtime", "api.py")
if not os.path.exists(src_path):
self.skipTest("pbl_agent_runtime 不在工作空间: %s" % exc)
bp_api = importlib.import_module("pbl_blueprint.api")
for sym in ("pbl_template_instantiate", "pbl_blueprint_create"):
self.assertTrue(hasattr(bp_api, sym),
"pbl_blueprint.api 缺 %sagent_runtime 依赖)" % sym)
return
self.assertIsNotNone(m)
def test_09_fix_is_idempotent(self):
"""兼容层修复可重复执行,不重复追加块(幂等)。"""
before = {}
for spec in closure.COMPAT_TARGETS:
p = os.path.join(REPO_ROOT, spec["file"])
before[p] = os.path.getsize(p) if os.path.exists(p) else 0
closure.apply_fix(dry=False)
closure.apply_fix(dry=False)
for spec in closure.COMPAT_TARGETS:
p = os.path.join(REPO_ROOT, spec["file"])
size = os.path.getsize(p) if os.path.exists(p) else 0
with self.subTest(file=spec["file"]):
self.assertLessEqual(size, before[p] + 4096,
"%s 兼容块疑似重复追加" % spec["file"])
if os.path.exists(p):
with open(p, "r", encoding="utf-8") as fh:
txt = fh.read()
self.assertLessEqual(txt.count(closure.MARK_BEGIN), 1,
"%s 存在多个兼容块" % spec["file"])
def test_10_registry_matches_exports(self):
"""API 注册表条目全部真实可调用(声明=实现)。"""
from pbl_blueprint.m1b.api import M1B_API_REGISTRY
self.assertGreaterEqual(len(M1B_API_REGISTRY), 20)
for name, fn in M1B_API_REGISTRY.items():
with self.subTest(api=name):
self.assertTrue(callable(fn), "%s 不可调用" % name)
self.assertEqual(getattr(fn, "__name__", name), name)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -1,447 +1,349 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b 真实库集成测试QC#5 整改)
"""M1b 真实库测试:建表/注册同步/模板平台公共/实例化/离线兜底/幂等
设计原则对应 QC 退回意见 #5
* 不再只有纯内存 FakeDB 单测本文件在**可连库环境真实执行** DDL + CRUD
覆盖 4 张表的建表幂等唯一键 tenant_key 打头去重平台公共模板 tenant_id IS NULL
子对象扩展 KV 读写关联表软引用判定Q-OPEN-3零基表写入
* 环境受限时 MySQL/MariaDB 或未提供连接参数**显式 SKIP 并打印移交说明**
绝不把 SKIP 伪装成 PASS移交部署验证的清单见 docs/M1b-qc-rework-evidence.md §5
对应 QC 退回意见 #5/#7/#8/#9本文件**真实执行**(非 py_compile
使用 sqlite 文件库落盘断言库内行数与字段值执行日志由
tools/m1b_run_tests.py 落盘到 projects/pbls/deliverables/m1b/test_logs/
运行方式三选一
1) 环境变量
PBL_TEST_DB_HOST=127.0.0.1 PBL_TEST_DB_PORT=3306 \
PBL_TEST_DB_USER=root PBL_TEST_DB_PASSWORD=xxx PBL_TEST_DB_NAME=pbl_test \
python3 tests/test_m1b_realdb.py
2) 配置文件PBL_TEST_DB_DSN=mysql://user:pass@host:3306/dbname
3) 无参数自动探测 127.0.0.1:3306连不上则 SKIP退出码 0但打印 SKIPPED
退出码0 = 全部通过 环境受限 SKIP1 = 有真实 FAIL2 = 依赖缺失且未 SKIP 判定失败
运行
python3 modules/pbl_blueprint/tests/test_m1b_realdb.py -v
"""
import json
import os
import re
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _m1b_loader # noqa: F401
import traceback
from datetime import datetime
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DDL_PATH = os.path.join(REPO, "sql", "m1b_ddl.sql")
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
TENANT_A = "T_M1B_A"
TENANT_B = "T_M1B_B"
PLATFORM_KEY = "" # tenant_id NULL -> tenant_key ''
NOW = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
import tempfile
import unittest
RESULTS = []
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
for p in (REPO_ROOT, MOD_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
from pbl_blueprint.m1b import init as m1b_init # noqa: E402
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
get_conn, reset_conn, sql_rows, sql_scalar, table_exists,
)
from pbl_blueprint.m1b.errors import ( # noqa: E402
ErrorCode, PblConflict, PblForbidden, PblNotFound, PblValidationError,
TenantMissingError,
)
from pbl_blueprint.m1b.tables import SUBOBJECT_TYPES, TABLES # noqa: E402
from pbl_blueprint.m1b.tenant import require_tenant # noqa: E402
TENANT = "T_M1B_REALDB_001"
OTHER_TENANT = "T_M1B_REALDB_002"
def log(ok, name, detail=""):
RESULTS.append((name, bool(ok), detail))
print("[%s] %s :: %s" % ("PASS" if ok else "FAIL", name, detail))
class M1bRealDbTestCase(unittest.TestCase):
"""真实 sqlite 文件库上的 M1b 端到端断言。"""
@classmethod
def setUpClass(cls):
cls.tmpdir = tempfile.mkdtemp(prefix="m1b_realdb_")
cls.db_path = os.path.join(cls.tmpdir, "pbl_m1b_test.sqlite3")
os.environ["PBL_M1B_DB"] = cls.db_path
os.environ["PBL_AUDIT_DIR"] = os.path.join(cls.tmpdir, "audit")
reset_conn()
cls.conn = get_conn(cls.db_path)
cls.load_result = m1b_init.load_m1b(conn=cls.conn,
actor_id="test_m1b_realdb")
def skip(name, detail):
RESULTS.append((name, None, detail))
print("[SKIP] %s :: %s" % (name, detail))
@classmethod
def tearDownClass(cls):
reset_conn()
shutil.rmtree(cls.tmpdir, ignore_errors=True)
os.environ.pop("PBL_M1B_DB", None)
os.environ.pop("PBL_AUDIT_DIR", None)
# ---------- 1. DDL 落库证据 ----------
def test_01_all_four_tables_created(self):
"""4 张 M1b 表全部真实建出QC #7表结构落库证据"""
ready = [t["name"] for t in TABLES if table_exists(t["name"], conn=self.conn)]
self.assertEqual(sorted(ready), sorted([t["name"] for t in TABLES]),
"建表缺失: %s" % set(t["name"] for t in TABLES) - set(ready))
self.assertEqual(self.load_result["ddl"]["missing"], [])
self.assertGreater(self.load_result["ddl"]["statements"], 0)
# ------------------------------------------------------------------ 连接
def resolve_dsn():
dsn = os.environ.get("PBL_TEST_DB_DSN")
if dsn:
m = re.match(r"mysql(?:\+\w+)?://([^:]+):([^@]*)@([^:/]+):?(\d+)?/(.+)", dsn)
if not m:
raise ValueError("PBL_TEST_DB_DSN 格式非法: %s" % dsn)
return dict(host=m.group(3), port=int(m.group(4) or 3306),
user=m.group(1), password=m.group(2), database=m.group(5))
if os.environ.get("PBL_TEST_DB_HOST"):
return dict(host=os.environ["PBL_TEST_DB_HOST"],
port=int(os.environ.get("PBL_TEST_DB_PORT", 3306)),
user=os.environ.get("PBL_TEST_DB_USER", "root"),
password=os.environ.get("PBL_TEST_DB_PASSWORD", ""),
database=os.environ.get("PBL_TEST_DB_NAME", "pbl_test"))
return dict(host="127.0.0.1", port=3306, user="root", password="",
database="pbl_test")
def test_02_ddl_is_idempotent(self):
"""重复执行 load_m1b 不报错、不产生重复注册行QC #8幂等"""
before = {t["name"]: sql_scalar("SELECT COUNT(*) FROM %s" % t["name"],
conn=self.conn)
for t in TABLES}
again = m1b_init.load_m1b(conn=self.conn, actor_id="test_idempotent")
after = {t["name"]: sql_scalar("SELECT COUNT(*) FROM %s" % t["name"],
conn=self.conn)
for t in TABLES}
self.assertEqual(before, after, "重复执行导致行数变化,非幂等")
self.assertEqual(again["ext_field_defs"]["created"], 0,
"第二次注册不应新增扩展字段定义")
self.assertGreater(again["ext_field_defs"]["updated"], 0)
def test_03_indexes_exist_and_unique_keys_tenant_first(self):
"""索引真实建出,且唯一键以 tenant_key 打头(规避 NULL 重复行)。"""
for t in TABLES:
rows = sql_rows("PRAGMA index_list(%s)" % t["name"], conn=self.conn)
names = [r["name"] for r in rows]
for ix in t["indexes"]:
self.assertIn(ix["name"], names,
"%s 缺索引 %s" % (t["name"], ix["name"]))
if ix.get("unique"):
self.assertEqual(ix["columns"][0], "tenant_key",
"%s 唯一键未以 tenant_key 打头" % ix["name"])
def connect(cfg):
"""返回 (conn, driver_name);驱动缺失或连不上抛异常。"""
try:
import pymysql # type: ignore
conn = pymysql.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
password=cfg["password"], database=cfg["database"],
charset="utf8mb4", autocommit=True,
connect_timeout=5)
return conn, "pymysql"
except ImportError:
pass
try:
import MySQLdb # type: ignore
conn = MySQLdb.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
passwd=cfg["password"], db=cfg["database"],
charset="utf8mb4")
conn.autocommit(True)
return conn, "MySQLdb"
except ImportError:
pass
try:
import mariadb # type: ignore
conn = mariadb.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
password=cfg["password"], database=cfg["database"])
conn.autocommit = True
return conn, "mariadb"
except ImportError:
raise RuntimeError("NO_DRIVER: 未安装 pymysql / MySQLdb / mariadb 任一驱动")
def test_04_no_foreign_key_declared(self):
"""Q-OPEN-34 张表均无 FOREIGN KEY。"""
for t in TABLES:
fks = sql_rows("PRAGMA foreign_key_list(%s)" % t["name"], conn=self.conn)
self.assertEqual(fks, [], "%s 不应声明外键" % t["name"])
def test_05_columns_match_table_definition(self):
"""库内列与表定义字段一一对应(模型-DDL-库三方一致QC #5"""
for t in TABLES:
rows = sql_rows("PRAGMA table_info(%s)" % t["name"], conn=self.conn)
db_cols = [r["name"] for r in rows]
expect = [f["name"] for f in t["fields"]]
self.assertEqual(sorted(db_cols), sorted(expect),
"%s 列与定义不一致" % t["name"])
def split_ddl(sql_text):
body = re.sub(r"/\*.*?\*/", " ", sql_text, flags=re.S)
stmts, buf = [], []
for line in body.splitlines():
i = line.find("--")
if i >= 0:
line = line[:i]
buf.append(line)
if line.rstrip().endswith(";"):
s = "\n".join(buf).strip()
if s and s != ";":
stmts.append(s.rstrip(";"))
buf = []
return [s for s in stmts if s.strip()]
# ---------- 2. 注册同步证据 ----------
def test_06_ext_field_defs_registered_for_all_seven_types(self):
"""7 类子对象都注册到扩展字段定义QC #6/#8库内注册数据"""
rows = sql_rows(
"SELECT DISTINCT owner_type FROM pbl_ext_field_def "
"WHERE tenant_id IS NULL ORDER BY owner_type", conn=self.conn)
owners = [r["owner_type"] for r in rows]
for st in SUBOBJECT_TYPES:
self.assertIn(st, owners, "子对象类型 %s 未注册扩展字段定义" % st)
self.assertIn("template", owners)
self.assertIn("blueprint", owners)
n = sql_scalar("SELECT COUNT(*) FROM pbl_ext_field_def", conn=self.conn)
self.assertEqual(int(n), len(m1b_init.EXT_FIELD_SEED))
def test_07_platform_templates_have_null_tenant_id(self):
"""平台公共模板 tenant_id 为 NULL、tenant_key='__platform__'QC #5 核心)。"""
rows = sql_rows(
"SELECT id, code, version, scope, tenant_id, tenant_key, status "
"FROM pbl_blueprint_template WHERE tenant_id IS NULL "
"ORDER BY code", conn=self.conn)
self.assertGreaterEqual(len(rows), 1, "未注册任何平台公共模板")
for r in rows:
self.assertIsNone(r["tenant_id"])
self.assertEqual(r["tenant_key"], "__platform__")
self.assertEqual(r["scope"], "platform")
self.assertEqual(r["status"], "published")
# ------------------------------------------------------------------ 用例
def t_ddl_idempotent(cur):
"""T1: DDL 执行两遍均成功CREATE TABLE IF NOT EXISTS 幂等)。"""
with open(DDL_PATH, "r", encoding="utf-8") as f:
stmts = split_ddl(f.read())
for rnd in (1, 2):
for s in stmts:
cur.execute(s)
log(len(stmts) == 4, "T1_DDL幂等",
"执行 %d 条语句 × 2 轮无异常" % len(stmts))
def test_08_audit_trail_written(self):
"""注册同步产生审计轨迹append-only 落盘)。"""
path = os.path.join(os.environ["PBL_AUDIT_DIR"],
"pbl_blueprint_audit.jsonl")
self.assertTrue(os.path.exists(path), "审计 JSONL 未落盘: %s" % path)
with open(path, "r", encoding="utf-8") as fh:
lines = [ln for ln in fh.read().splitlines() if ln.strip()]
self.assertGreaterEqual(len(lines), 1)
actions = set()
for ln in lines:
rec = json.loads(ln)
actions.add(rec["action"])
self.assertIn("id", rec)
self.assertIn("created_at", rec)
self.assertIn("ddl_apply", actions)
self.assertIn("register_sync", actions)
# ---------- 3. 模板平台公共部分 ----------
def test_09_tenant_can_read_platform_template_but_not_write(self):
"""租户可读平台公共模板;租户写平台模板被拒(写保护)。"""
from pbl_blueprint.m1b.template import (
get_template, list_templates, update_template,
)
items = list_templates(tenant_id=TENANT, conn=self.conn)
plat = [i for i in items if i.get("is_platform")]
self.assertGreaterEqual(len(plat), 1, "租户看不到平台公共模板")
tpl = get_template(tenant_id=TENANT, template_id=plat[0]["id"],
conn=self.conn, allow_offline=False)
self.assertIsNone(tpl["tenant_id"])
self.assertTrue(tpl["is_platform"])
with self.assertRaises(PblForbidden):
update_template(tenant_id=TENANT, template_id=tpl["id"],
data={"name": "租户篡改"}, conn=self.conn)
# 平台角色可写
ok_row = update_template(tenant_id=None, template_id=tpl["id"],
data={"description": "平台侧更新"},
role="platform", conn=self.conn)
self.assertEqual(ok_row["description"], "平台侧更新")
def t_tables_exist(cur):
"""T2: 4 张表全部存在,且引擎/字符集符合 mariadb 方言。"""
cur.execute("""SELECT TABLE_NAME, ENGINE, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('pbl_blueprint_template','pbl_blueprint_ref',
'pbl_subobject_ext','pbl_ext_field_def')""")
rows = cur.fetchall()
names = sorted(r[0] for r in rows)
expect = sorted(['pbl_blueprint_template', 'pbl_blueprint_ref',
'pbl_subobject_ext', 'pbl_ext_field_def'])
engines = set(r[1] for r in rows)
log(names == expect and engines == {"InnoDB"}, "T2_四表存在",
"表=%s 引擎=%s" % (names, engines))
def test_10_tenant_cannot_create_platform_template(self):
"""非平台角色创建 scope=platform 模板 -> PblForbidden。"""
from pbl_blueprint.m1b.template import create_template
with self.assertRaises(PblForbidden):
create_template(tenant_id=TENANT,
data={"code": "hack.platform", "name": "越权",
"scope": "platform"}, conn=self.conn)
def test_11_tenant_template_requires_tenant_id(self):
"""租户模板缺 tenant_id -> TenantMissingErrorfail-closed"""
from pbl_blueprint.m1b.template import create_template
with self.assertRaises(TenantMissingError):
create_template(tenant_id=None,
data={"code": "no.tenant", "name": "无租户",
"scope": "tenant"}, conn=self.conn)
def t_no_foreign_key(cur):
"""T3: 4 张表零 FOREIGN KEYQ-OPEN-3"""
cur.execute("""SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_NAME LIKE 'pbl\\_%'""")
n = cur.fetchone()[0]
log(n == 0, "T3_零外键", "information_schema 外键约束数=%d" % n)
def test_12_template_unique_key_conflict(self):
"""同 (tenant_key, code, version) 重复创建 -> PblConflict"""
from pbl_blueprint.m1b.template import create_template
payload = {"code": "tenant.dup", "name": "重复模板", "scope": "tenant",
"version": "1.0.0"}
create_template(tenant_id=TENANT, data=dict(payload), conn=self.conn)
with self.assertRaises(PblConflict):
create_template(tenant_id=TENANT, data=dict(payload), conn=self.conn)
def test_13_cross_tenant_template_isolation(self):
"""A 租户模板对 B 租户不可见(隔离)。"""
from pbl_blueprint.m1b.template import create_template, get_template
row = create_template(tenant_id=TENANT,
data={"code": "tenant.private", "name": "私有",
"scope": "tenant", "version": "1.0.0"},
conn=self.conn)
with self.assertRaises(PblNotFound):
get_template(tenant_id=OTHER_TENANT, template_id=row["id"],
conn=self.conn, allow_offline=False)
def t_unique_key_tenant_first(cur):
"""T4: 每个 UNIQUE 索引首列均为 tenant_key。"""
cur.execute("""SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'pbl\\_%'
AND NON_UNIQUE = 0 AND INDEX_NAME <> 'PRIMARY'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX""")
bad = []
seen = {}
for t, ix, col, seq in cur.fetchall():
if int(seq) == 1:
seen[(t, ix)] = col
for (t, ix), col in seen.items():
if col != "tenant_key":
bad.append("%s.%s 首列=%s" % (t, ix, col))
log(seen and not bad, "T4_唯一键tenant打头",
"唯一索引 %d 个,违规 %s" % (len(seen), bad or ""))
# ---------- 4. 实例化 + 离线兜底 ----------
def test_14_instantiate_creates_blueprint_and_subobjects(self):
"""模板实例化真实产出蓝图 + 7 类子对象 + 扩展值QC #4/#6"""
from pbl_blueprint.m1b.template import instantiate_template
plat = sql_rows(
"SELECT id, code FROM pbl_blueprint_template WHERE tenant_id IS NULL "
"ORDER BY code LIMIT 1", conn=self.conn)[0]
res = instantiate_template(
TENANT, template_id=plat["id"], actor_id="tester", conn=self.conn)
self.assertTrue(res["ok"])
self.assertFalse(res["fallback"], "库内有模板,不应走离线兜底")
self.assertTrue(res["blueprint"]["id"])
self.assertEqual(res["template"]["scope"], "platform")
self.assertGreaterEqual(res["counts"]["__total__"], 1,
"未实例化出任何子对象")
self.assertGreaterEqual(res["counts"]["ext_written"], 1,
"未写入任何扩展字段值")
# 子对象行真实落库
for st in SUBOBJECT_TYPES:
if res["counts"][st]:
n = sql_scalar(
"SELECT COUNT(*) FROM pbl_subobject_ext WHERE tenant_id = ? "
"AND blueprint_id = ? AND subobject_type = ?",
[TENANT, res["blueprint"]["id"], st], conn=self.conn)
self.assertIsNotNone(n)
def test_15_instantiate_requires_tenant(self):
"""实例化缺租户 -> TenantMissingError。"""
from pbl_blueprint.m1b.template import instantiate_template
with self.assertRaises(TenantMissingError):
instantiate_template(None, code="platform.pbl.stem", conn=self.conn)
def t_platform_template_null_tenant(cur):
"""T5: 平台公共模板 tenant_id IS NULL 可插入,且 tenant_key='' 唯一去重生效。"""
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T5%%'")
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status,
version, is_default, usage_count, created_at, updated_at)
VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""",
(PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板", NOW, NOW))
cur.execute(
"""SELECT id, tenant_id, scope FROM pbl_blueprint_template
WHERE tenant_key = %s AND template_code = 'M1B_T5_PLATFORM'""",
(PLATFORM_KEY,))
row = cur.fetchone()
ok_null = row is not None and row[1] is None and row[2] == "platform"
def test_16_offline_fallback_marks_reason(self):
"""库中不存在的模板走离线兜底,且如实标注 source/fallback_reasonQC 要求)。"""
from pbl_blueprint.m1b.template import get_template, load_offline_templates
items = load_offline_templates(reason="unit-test forced")
if not items:
self.skipTest("离线模板包不存在,跳过兜底断言")
self.assertEqual(items[0]["source"], "offline_fallback")
self.assertIn("unit-test forced", items[0]["fallback_reason"])
self.assertFalse(items[0]["writable"], "离线兜底模板必须只读")
hit = get_template(tenant_id=TENANT, code=items[0]["code"],
version=items[0]["version"], conn=self.conn)
self.assertEqual(hit["source"], "offline_fallback")
dup = False
try:
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status,
version, is_default, usage_count, created_at, updated_at)
VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""",
(PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板重复", NOW, NOW))
except Exception:
dup = True
log(ok_null and dup, "T5_平台模板NULL租户",
"tenant_id IS NULL 插入=%s;同 tenant_key+code 重复插入被唯一键拒绝=%s"
% (ok_null, dup))
def test_17_instantiate_from_offline_template(self):
"""离线模板也能完成实例化DB 模板缺失时的兜底可用性)。"""
from pbl_blueprint.m1b.template import (
instantiate_template, load_offline_templates,
)
items = load_offline_templates(reason="unit-test forced")
if not items:
self.skipTest("离线模板包不存在")
code = items[-1]["code"]
# 确保库里没有该 code 的租户可见模板
res = instantiate_template(TENANT, code=code,
version=items[-1]["version"],
actor_id="tester", conn=self.conn)
self.assertTrue(res["ok"])
self.assertTrue(res["fallback"])
self.assertTrue(res["fallback_reason"])
# ---------- 5. 租户上下文 ----------
def test_18_require_tenant_fail_closed(self):
"""require_tenant 对空/空白/'null' 一律抛错fail-closed"""
for bad in (None, "", " ", "null", "None"):
with self.assertRaises(TenantMissingError):
require_tenant(bad)
self.assertEqual(require_tenant(TENANT), TENANT)
self.assertIsNone(require_tenant(None, allow_null=True))
def t_tenant_isolation(cur):
"""T6: 同 template_code 在不同租户下可共存tenant_key 打头唯一键)。"""
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T6_SAME'")
for tk, tid in ((TENANT_A, TENANT_A), (TENANT_B, TENANT_B)):
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status,
version, is_default, usage_count, created_at, updated_at)
VALUES (%s, %s, 'M1B_T6_SAME', %s, 'tenant', 'draft', '1.0.0', 0, 0, %s, %s)""",
(tk, tid, "租户模板-" + tk, NOW, NOW))
cur.execute("SELECT tenant_key FROM pbl_blueprint_template "
"WHERE template_code = 'M1B_T6_SAME' ORDER BY tenant_key")
got = [r[0] for r in cur.fetchall()]
log(got == [TENANT_A, TENANT_B], "T6_租户隔离",
"同 code 跨租户共存tenant_key=%s" % got)
def test_19_m1b_status_reports_counts(self):
"""m1b_status 输出可核验的落库状态快照。"""
st = m1b_init.m1b_status(conn=self.conn)
self.assertTrue(all(st["tables"].values()), "存在未建出的表")
self.assertGreater(st["counts"]["pbl_ext_field_def"], 0)
self.assertGreater(st["counts"]["pbl_blueprint_template"], 0)
self.assertEqual(st["subobject_types"], list(SUBOBJECT_TYPES))
# ---------- 6. API 层pbl_agent_runtime 依赖面) ----------
def test_20_api_surface_available(self):
"""pbl_blueprint.api 暴露 pbl_template_instantiate / pbl_blueprint_create。"""
from pbl_blueprint.m1b.api import (
M1B_API_REGISTRY, pbl_blueprint_create, pbl_template_instantiate,
)
self.assertTrue(callable(pbl_template_instantiate))
self.assertTrue(callable(pbl_blueprint_create))
self.assertIn("pbl_template_instantiate", M1B_API_REGISTRY)
self.assertIn("pbl_blueprint_create", M1B_API_REGISTRY)
self.assertGreaterEqual(len(M1B_API_REGISTRY), 20)
def t_subobject_ext_kv(cur):
"""T7: 子对象扩展 KV 写入/读取/唯一键覆盖(基表零改动)。"""
cur.execute("DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T7%%'")
cur.execute(
"""INSERT INTO pbl_subobject_ext
(tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id,
ext_key, ext_value, ext_value_type, source, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ',
'difficulty', '3', 'number', 'manual', 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
cur.execute(
"""SELECT ext_value, ext_value_type FROM pbl_subobject_ext
WHERE tenant_key = %s AND subobject_type = 'driving_question'
AND subobject_id = 'M1B_T7_OBJ' AND ext_key = 'difficulty'""",
(TENANT_A,))
row = cur.fetchone()
ok_read = row is not None and row[0] == "3" and row[1] == "number"
def test_21_api_returns_unified_envelope(self):
"""API 返回统一响应包异常不外泄ok/code/message/data"""
from pbl_blueprint.m1b.api import pbl_template_instantiate
res = pbl_template_instantiate(tenant_id=None)
self.assertFalse(res["ok"])
self.assertEqual(res["code"], ErrorCode.TENANT_MISSING)
self.assertIn("http_status", res)
res2 = pbl_template_instantiate(tenant_id=TENANT, code="platform.pbl.stem")
self.assertTrue(res2["ok"], res2)
self.assertIn("counts", res2["data"])
dup = False
try:
cur.execute(
"""INSERT INTO pbl_subobject_ext
(tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id,
ext_key, ext_value, ext_value_type, source, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ',
'difficulty', '5', 'number', 'manual', 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
except Exception:
dup = True
log(ok_read and dup, "T7_子对象扩展KV",
"读回=%s;同 (tenant,type,obj,key) 重复插入被拒=%s" % (ok_read, dup))
def t_ext_field_def_platform_override(cur):
"""T8: 平台公共字段定义 + 租户级覆盖(同 field_key 两条并存,读取顺序 tenant->platform"""
cur.execute("DELETE FROM pbl_ext_field_def WHERE field_key LIKE 'm1b_t8%%'")
cur.execute(
"""INSERT INTO pbl_ext_field_def
(tenant_key, tenant_id, subobject_type, field_key, field_label, field_type,
required, scope, sort_no, status, created_at, updated_at)
VALUES ('', NULL, 'driving_question', 'm1b_t8_difficulty', '难度(平台)',
'number', 0, 'platform', 10, 'active', %s, %s)""", (NOW, NOW))
cur.execute(
"""INSERT INTO pbl_ext_field_def
(tenant_key, tenant_id, subobject_type, field_key, field_label, field_type,
required, scope, sort_no, status, created_at, updated_at)
VALUES (%s, %s, 'driving_question', 'm1b_t8_difficulty', '难度(租户覆盖)',
'number', 1, 'tenant', 10, 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
cur.execute(
"""SELECT tenant_key, field_label, required FROM pbl_ext_field_def
WHERE subobject_type = 'driving_question' AND field_key = 'm1b_t8_difficulty'
AND status = 'active'
ORDER BY CASE WHEN tenant_key = %s THEN 0 ELSE 1 END""", (TENANT_A,))
rows = cur.fetchall()
ok = len(rows) == 2 and rows[0][0] == TENANT_A and int(rows[0][2]) == 1
log(ok, "T8_字段定义平台+租户覆盖",
"命中 %d 条,优先级首条 tenant_key=%s required=%s"
% (len(rows), rows[0][0] if rows else None, rows[0][2] if rows else None))
def t_blueprint_ref_softlink(cur):
"""T9: 关联表软引用ref_table/ref_id 字符串),且对基表零写入。"""
cur.execute("DELETE FROM pbl_blueprint_ref WHERE ref_id LIKE 'M1B_T9%%'")
before = _base_table_fingerprint(cur)
cur.execute(
"""INSERT INTO pbl_blueprint_ref
(tenant_key, tenant_id, blueprint_id, ref_type, ref_table, ref_id, ref_name,
relation_kind, sort_no, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'world', 'world', 'M1B_T9_WORLD', '测试世界',
'reference', 1, 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
cur.execute(
"""SELECT ref_type, ref_table, ref_id, relation_kind FROM pbl_blueprint_ref
WHERE tenant_key = %s AND ref_id = 'M1B_T9_WORLD'""", (TENANT_A,))
row = cur.fetchone()
after = _base_table_fingerprint(cur)
ok = (row is not None and row[0] == "world" and row[1] == "world"
and row[3] == "reference" and before == after)
log(ok, "T9_关联表软引用零基表写入",
"软引用行=%s;基表指纹前后一致=%s" % (row is not None, before == after))
def _base_table_fingerprint(cur):
"""基表存在性+行数指纹(若基表不存在则记为 absent不影响判定"""
fp = {}
for t in ("world", "scene", "entity", "script"):
try:
cur.execute(
"SELECT COUNT(*) FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s", (t,))
if cur.fetchone()[0] == 0:
fp[t] = "absent"
continue
cur.execute("SELECT COUNT(*) FROM `%s`" % t)
fp[t] = cur.fetchone()[0]
except Exception:
fp[t] = "error"
return json.dumps(fp, sort_keys=True)
def t_ref_unique_dedup(cur):
"""T10: 同 (tenant, blueprint, ref_type, ref_id) 重复关联被唯一键拒绝。"""
dup = False
try:
cur.execute(
"""INSERT INTO pbl_blueprint_ref
(tenant_key, tenant_id, blueprint_id, ref_type, ref_table, ref_id,
ref_name, relation_kind, sort_no, status, created_at, updated_at)
VALUES (%s, %s, 900001, 'world', 'world', 'M1B_T9_WORLD', '重复',
'reference', 2, 'active', %s, %s)""",
(TENANT_A, TENANT_A, NOW, NOW))
except Exception:
dup = True
log(dup, "T10_关联唯一去重", "重复关联被唯一键拒绝=%s" % dup)
def t_template_instantiate_payload(cur):
"""T11: 模板实例化——payload 深拷贝到新蓝图关联条目JSON 可解析)。"""
payload = json.dumps({"driving_question": [{"title": "如何减少碳排", "difficulty": 3}],
"artifact_def": [{"code": "A1"}]}, ensure_ascii=False)
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T11_TPL'")
cur.execute(
"""INSERT INTO pbl_blueprint_template
(tenant_key, tenant_id, template_code, template_name, scope, status, version,
is_default, usage_count, blueprint_payload, subobject_payload, ref_payload,
created_at, updated_at)
VALUES (%s, %s, 'M1B_T11_TPL', '实例化模板', 'tenant', 'published', '1.0.0',
0, 0, %s, %s, %s, %s, %s)""",
(TENANT_A, TENANT_A, payload, payload, "[]", NOW, NOW))
cur.execute("SELECT blueprint_payload, subobject_payload, usage_count "
"FROM pbl_blueprint_template WHERE tenant_key = %s "
"AND template_code = 'M1B_T11_TPL'", (TENANT_A,))
bp, sp, usage = cur.fetchone()
ok_json = isinstance(json.loads(bp), dict) and isinstance(json.loads(sp), dict)
cur.execute("UPDATE pbl_blueprint_template SET usage_count = usage_count + 1, "
"updated_at = %s WHERE tenant_key = %s AND template_code = 'M1B_T11_TPL'",
(NOW, TENANT_A))
cur.execute("SELECT usage_count FROM pbl_blueprint_template WHERE tenant_key = %s "
"AND template_code = 'M1B_T11_TPL'", (TENANT_A,))
new_usage = cur.fetchone()[0]
log(ok_json and int(usage) == 0 and int(new_usage) == 1, "T11_模板实例化payload",
"payload JSON 可解析=%susage_count %s -> %s" % (ok_json, usage, new_usage))
def t_cleanup(cur):
"""T12: 清理测试数据(只删本测试写入的 pbl_ 行,绝不触碰基表)。"""
n = 0
for sql in (
"DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T%'",
"DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T%'",
"DELETE FROM pbl_ext_field_def WHERE field_key LIKE 'm1b_t8%'",
"DELETE FROM pbl_blueprint_ref WHERE ref_id LIKE 'M1B_T%'",
):
rc = cur.execute(sql) # 只执行一次,返回受影响行数
n += int(rc or 0)
log(True, "T12_测试数据清理", "清理 %d 行(仅 pbl_ 自有表,零基表操作)" % n)
CASES = [
t_ddl_idempotent, t_tables_exist, t_no_foreign_key, t_unique_key_tenant_first,
t_platform_template_null_tenant, t_tenant_isolation, t_subobject_ext_kv,
t_ext_field_def_platform_override, t_blueprint_ref_softlink, t_ref_unique_dedup,
t_template_instantiate_payload, t_cleanup,
]
def main():
print("=" * 72)
print("M1b 真实库集成测试 DDL=%s" % DDL_PATH)
print("=" * 72)
if not os.path.exists(DDL_PATH):
print("FATAL: 未找到 sql/m1b_ddl.sql")
return 2
try:
cfg = resolve_dsn()
conn, driver = connect(cfg)
except Exception as e:
msg = str(e)
print("-" * 72)
print("SKIPPED环境受限未连真实库%s" % msg)
print("连接参数尝试:%s" % json.dumps(
{k: v for k, v in (resolve_dsn() or {}).items() if k != "password"},
ensure_ascii=False))
print("移交部署验证:本文件 12 个用例T1~T12须在部署环境执行"
"清单见 docs/M1b-qc-rework-evidence.md §5。")
print("复跑命令PBL_TEST_DB_DSN=mysql://user:pass@host:3306/db "
"python3 tests/test_m1b_realdb.py")
for c in CASES:
skip(c.__name__, "环境受限未执行(不视为已完成自测)")
print("-" * 72)
print("结果0 PASS / 0 FAIL / %d SKIP" % len(CASES))
return 0
print("已连接真实库 driver=%s host=%s:%s db=%s"
% (driver, cfg["host"], cfg["port"], cfg["database"]))
print("-" * 72)
cur = conn.cursor()
failed = 0
for case in CASES:
try:
case(cur)
except Exception as e:
failed += 1
log(False, case.__name__, "异常 %s\n%s" % (e, traceback.format_exc()))
try:
cur.close()
conn.close()
except Exception:
pass
total = len(RESULTS)
passed = sum(1 for _, ok, _ in RESULTS if ok is True)
fails = sum(1 for _, ok, _ in RESULTS if ok is False)
print("-" * 72)
print("结果:%d PASS / %d FAIL / 共 %d" % (passed, fails, total))
for name, ok, detail in RESULTS:
if ok is False:
print(" FAIL %s :: %s" % (name, detail))
return 1 if fails else 0
def test_22_api_blueprint_create_direct_and_from_template(self):
"""pbl_blueprint_create 两种形态:直接建 / 由模板派生。"""
from pbl_blueprint.m1b.api import pbl_blueprint_create
r1 = pbl_blueprint_create(tenant_id=TENANT,
data={"name": "直接创建的蓝图"})
self.assertTrue(r1["ok"], r1)
self.assertTrue(r1["data"]["id"])
r2 = pbl_blueprint_create(tenant_id=TENANT,
data={"template_code": "platform.pbl.stem",
"name": "由模板派生"})
self.assertTrue(r2["ok"], r2)
self.assertIn("subobjects", r2["data"])
if __name__ == "__main__":
sys.exit(main())
unittest.main(verbosity=2)

View File

@ -0,0 +1,462 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b import 闭包修复器 + 核验器QC 退回意见 #1/#2/#3/#4 的根治工具)。
问题背景pbl_common 半迁移重写删掉了 errors.py/tenant.py 既有符号面
pbl_blueprint/api.pyblueprint_crud.pyapi_blueprint.py import 旧名
PblError/PblNotFound/PblValidationError/require_tenant/write_audit/
tenant_crud/new_id/now_str导致 from pbl_blueprint.api import * 全链
ImportErrorpbl_agent_runtime/api.py 依赖的 pbl_template_instantiate /
pbl_blueprint_create 也因此不可达
本工具做两件事
1) **幂等补齐兼容导出层** pbl_common.api / pbl_common.audit /
pbl_common.crud_factory / pbl_common.dbutil / pbl_common.errors /
pbl_common.tenant / pbl_blueprint.db / pbl_blueprint.api 末尾追加
M1b 兼容供给段 pbl_blueprint.m1b 再导出缺失符号
已存在同名定义则跳过不覆盖既有实现
2) **静态 import 闭包核验**扫描 modules/pbl_* 全部 .py解析
`from X import a, b` / `import X` 的跨文件符号引用逐个确认目标模块
确实定义了该符号ast 层面的 def/class/赋值/__all__/再导出
输出断裂清单断裂数 > 0 时退出码非 0
用法
python3 tools/m1b_fix_import_closure.py # 修复 + 核验
python3 tools/m1b_fix_import_closure.py --check # 只核验,不写盘
python3 tools/m1b_fix_import_closure.py --json out.json
"""
import argparse
import ast
import json
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
MODULES_DIR = os.path.join(REPO_ROOT, "modules")
SCAN_PKGS = ("pbl_blueprint", "pbl_common", "pbl_agent_runtime",
"pbl_appcodes", "pbl_validation", "pbl_template")
MARK_BEGIN = "# >>> M1b compat exports (auto-generated, idempotent) >>>"
MARK_END = "# <<< M1b compat exports <<<"
#: 目标模块 -> 需要保证存在的符号(来源均为 pbl_blueprint.m1b
COMPAT_TARGETS = [
{
"module": "pbl_common.errors",
"file": "modules/pbl_common/pbl_common/errors.py",
"source": "pbl_blueprint.m1b.errors",
"symbols": ["ErrorCode", "CODE_TO_HTTP", "PblError", "PblValidationError",
"PblNotFound", "PblConflict", "PblForbidden",
"TenantMissingError", "raise_error", "error_envelope"],
},
{
"module": "pbl_common.tenant",
"file": "modules/pbl_common/pbl_common/tenant.py",
"source": "pbl_blueprint.m1b.tenant",
"symbols": ["normalize_tenant", "require_tenant", "allow_platform",
"tenant_scope", "assert_not_write_protected",
"PLATFORM_TENANT", "current_actor"],
},
{
"module": "pbl_common.dbutil",
"file": "modules/pbl_common/pbl_common/dbutil.py",
"source": "pbl_blueprint.m1b.dbutil",
"symbols": ["sql_exec", "sql_rows", "sql_scalar", "get_conn",
"table_exists", "list_tables", "db_info"],
"extra_source": [("pbl_blueprint.m1b.util", ["new_id", "now_str", "now_ts",
"json_dump", "json_load"])],
},
{
"module": "pbl_common.audit",
"file": "modules/pbl_common/pbl_common/audit.py",
"source": "pbl_blueprint.m1b.audit",
"symbols": ["write_audit", "write_audit_batch", "audit_trail",
"flush_memory_audit", "AUDIT_TABLE"],
},
{
"module": "pbl_common.crud_factory",
"file": "modules/pbl_common/pbl_common/crud_factory.py",
"source": "pbl_blueprint.m1b.crud_factory",
"symbols": ["tenant_crud", "crud_factory", "TenantCrud", "build_where"],
},
{
"module": "pbl_common.api",
"file": "modules/pbl_common/pbl_common/api.py",
"source": "pbl_blueprint.m1b",
"symbols": ["PblError", "PblValidationError", "PblNotFound", "PblConflict",
"PblForbidden", "TenantMissingError", "ErrorCode", "CODE_TO_HTTP",
"require_tenant", "normalize_tenant", "assert_not_write_protected",
"write_audit", "tenant_crud", "crud_factory", "new_id", "now_str",
"json_dump", "json_load", "sql_exec", "sql_rows", "sql_scalar",
"get_conn", "table_exists"],
},
{
"module": "pbl_blueprint.db",
"file": "modules/pbl_blueprint/pbl_blueprint/db.py",
"source": "pbl_blueprint.m1b",
"symbols": ["PblError", "PblValidationError", "PblNotFound", "PblConflict",
"PblForbidden", "TenantMissingError", "ErrorCode",
"require_tenant", "normalize_tenant", "assert_not_write_protected",
"write_audit", "tenant_crud", "crud_factory", "new_id", "now_str",
"json_dump", "json_load", "sql_exec", "sql_rows", "sql_scalar",
"get_conn", "table_exists", "get_env"],
"create_if_missing": True,
},
{
"module": "pbl_blueprint.api",
"file": "modules/pbl_blueprint/pbl_blueprint/api.py",
"source": "pbl_blueprint.m1b.api",
"symbols": ["pbl_template_instantiate", "pbl_blueprint_create",
"pbl_template_list", "pbl_template_get", "pbl_template_create",
"pbl_template_update", "pbl_template_publish",
"pbl_template_offline", "pbl_blueprint_get",
"pbl_blueprint_subobject_tree", "pbl_subobject_list",
"pbl_subobject_get", "pbl_subobject_create",
"pbl_subobject_update", "pbl_subobject_delete",
"pbl_subobject_ext_get", "pbl_subobject_ext_set",
"pbl_subobject_ext_validate", "pbl_subobject_contract",
"pbl_ref_resolve", "pbl_ref_list", "pbl_ref_summary",
"pbl_ref_contract", "pbl_m1b_info", "M1B_API_REGISTRY"],
"create_if_missing": True,
},
]
# ---------------- 静态分析 ----------------
def iter_py_files():
"""遍历待扫描包下全部 .py跳过 __pycache__"""
for pkg in SCAN_PKGS:
root = os.path.join(MODULES_DIR, pkg)
if not os.path.isdir(root):
continue
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for fn in filenames:
if fn.endswith(".py"):
yield os.path.join(dirpath, fn)
def module_name_of(path):
"""文件路径 -> 点号模块名。
注意modules/{仓库名}/ git 仓库根Python 包目录是 modules/{仓库名}/{包名}/
因此模块名必须**从仓库根起算**丢掉第一段仓库名否则
pbl_blueprint/pbl_blueprint/x.py 会被误算成 pbl_blueprint.pbl_blueprint.x
导致相对导入解析出错误目标模块名QC #1 误报根因)。
"""
rel = os.path.relpath(path, MODULES_DIR)
parts = rel.replace(os.sep, "/").split("/")
if len(parts) > 1:
parts = parts[1:] # 丢掉仓库名段
if parts and parts[-1] == "__init__.py":
parts = parts[:-1]
elif parts:
parts[-1] = parts[-1][:-3]
return ".".join([p for p in parts if p])
def defined_symbols(path):
"""ast 解析出模块内**可见符号名**集合。
覆盖def/class/函数内嵌套 def模块级赋值import 别名
from ... import ...再导出__all__ 字面量try/except 内的定义
解析失败返回 None调用方按不可判定处理不误报
"""
try:
with open(path, "r", encoding="utf-8") as fh:
src = fh.read()
tree = ast.parse(src, filename=path)
except (SyntaxError, UnicodeDecodeError, IOError, OSError):
return None
names = set()
def walk(node):
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(child.name)
walk(child)
elif isinstance(child, ast.Assign):
for tgt in child.targets:
_collect_targets(tgt, names)
walk(child)
elif isinstance(child, ast.AnnAssign) and child.target is not None:
_collect_targets(child.target, names)
elif isinstance(child, ast.Import):
for al in child.names:
names.add((al.asname or al.name.split(".")[0]))
elif isinstance(child, ast.ImportFrom):
for al in child.names:
if al.name == "*":
names.add("*%s" % (child.module or ""))
else:
names.add(al.asname or al.name)
elif isinstance(child, (ast.If, ast.Try, ast.For, ast.While,
ast.With)):
walk(child)
walk(tree)
# __all__ 字面量
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == "__all__":
if isinstance(node.value, (ast.List, ast.Tuple)):
for el in node.value.elts:
if isinstance(el, ast.Constant) and isinstance(el.value, str):
names.add(el.value)
return names
def _collect_targets(tgt, names):
if isinstance(tgt, ast.Name):
names.add(tgt.id)
elif isinstance(tgt, (ast.Tuple, ast.List)):
for e in tgt.elts:
_collect_targets(e, names)
elif isinstance(tgt, ast.Starred):
_collect_targets(tgt.value, names)
def imports_of(path):
"""解析文件的 import 语句,返回 [(lineno, module, [symbols], level)]。"""
try:
with open(path, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
except (SyntaxError, UnicodeDecodeError, IOError, OSError):
return []
out = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
syms = [al.name for al in node.names if al.name != "*"]
out.append((node.lineno, node.module or "", syms, node.level or 0))
elif isinstance(node, ast.Import):
for al in node.names:
out.append((node.lineno, al.name, [], 0))
return out
def resolve_module(cur_path, module, level):
"""把 (当前文件, from-module, level) 解析为绝对模块名。"""
cur_mod = module_name_of(cur_path)
cur_pkg_parts = cur_mod.split(".")
# 当前文件若是 __init__.pymodule_name_of 已去掉,包即自身
is_pkg_init = os.path.basename(cur_path) == "__init__.py"
if level == 0:
return module
base = cur_pkg_parts[:]
if not is_pkg_init:
base = base[:-1]
if level > 1:
base = base[:-(level - 1)]
if module:
base = base + module.split(".")
return ".".join([b for b in base if b])
def module_to_path(module):
"""绝对模块名 -> 文件路径(不存在返回 None
两种落点都要试
modules/{a}/{b}.py 仓库名与包名不同的历史布局
modules/{a}/{a}/{b}.py 标准布局仓库根下同名包目录
"""
if not module:
return None
parts = [p for p in module.split(".") if p]
if not parts:
return None
cands = []
for base in ([parts] if len(parts) == 1 else
[parts, [parts[0]] + parts]):
cands.append(os.path.join(MODULES_DIR, *base) + ".py")
cands.append(os.path.join(MODULES_DIR, *base, "__init__.py"))
# 标准布局优先modules/{repo}/{repo}/...
for c in (cands[2], cands[3], cands[0], cands[1]) if len(cands) == 4 else cands:
if os.path.exists(c):
return c
return None
def is_stdlib_or_thirdparty(module):
"""判定是否非本仓模块(标准库/第三方),这些不做闭包核验。"""
if not module:
return True
head = module.split(".")[0]
if head in sys.stdlib_module_names:
return True
if module_to_path(module) is None:
return True
return False
def scan_closure():
"""全量扫描,返回断裂清单 [{file, line, module, symbol, reason}]。"""
breaks = []
sym_cache = {}
files = list(iter_py_files())
for path in files:
for lineno, module, syms, level in imports_of(path):
target = resolve_module(path, module, level)
if is_stdlib_or_thirdparty(target):
continue
tpath = module_to_path(target)
if tpath is None:
breaks.append({"file": os.path.relpath(path, REPO_ROOT),
"line": lineno, "module": target, "symbol": "*",
"reason": "MODULE_NOT_FOUND"})
continue
if tpath not in sym_cache:
sym_cache[tpath] = defined_symbols(tpath)
have = sym_cache[tpath]
if have is None:
continue # 目标文件语法错误,交由 py_compile 门禁处理
if "*" in have or any(k.startswith("*") for k in have):
continue # 目标含 star re-export无法静态判定 -> 不误报
for s in syms:
if s not in have:
breaks.append({"file": os.path.relpath(path, REPO_ROOT),
"line": lineno, "module": target, "symbol": s,
"reason": "SYMBOL_MISSING"})
return breaks
# ---------------- 修复 ----------------
def existing_block(path):
"""读取文件中已有的兼容段(返回 (start_idx, end_idx) 行号或 None"""
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as fh:
lines = fh.readlines()
s = e = None
for i, ln in enumerate(lines):
if MARK_BEGIN in ln:
s = i
if MARK_END in ln:
e = i
if s is None or e is None or e < s:
return None
return lines, s, e
def build_block(spec, already):
"""生成兼容段文本(只补已缺失的符号)。"""
missing = [s for s in spec["symbols"] if s not in already]
lines = [MARK_BEGIN,
"# 由 tools/m1b_fix_import_closure.py 幂等生成;不覆盖既有同名定义。",
"# 目的:修复 pbl_common 半迁移造成的 import 闭包断裂QC #1/#2/#3/#4"]
groups = [(spec["source"], missing)]
for extra_src, extra_syms in (spec.get("extra_source") or []):
gm = [s for s in extra_syms if s not in already and s not in missing]
if gm:
groups.append((extra_src, gm))
emitted = False
for src, syms in groups:
if not syms:
continue
emitted = True
lines.append("try:")
lines.append(" from %s import ( # noqa: F401" % src)
for s in syms:
lines.append(" %s," % s)
lines.append(" )")
lines.append("except ImportError: # pragma: no cover - 供给层缺失时不阻断导入")
lines.append(" pass")
if not emitted:
return None
lines.append(MARK_END)
return "\n".join(lines) + "\n"
def apply_fix(dry=False):
"""幂等补齐兼容导出层,返回修改清单。"""
changed = []
for spec in COMPAT_TARGETS:
path = os.path.join(REPO_ROOT, spec["file"])
if not os.path.exists(path):
if not spec.get("create_if_missing"):
changed.append({"file": spec["file"], "action": "skip_missing"})
continue
if not dry:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write('# -*- coding: utf-8 -*-\n"""%sM1b 兼容供给层)"""\n'
% spec["module"])
changed.append({"file": spec["file"], "action": "created"})
have = defined_symbols(path) or set()
blk = existing_block(path)
if blk:
lines, s, e = blk
# 已有兼容段:先移除,再按当前缺失重算(保证幂等收敛)
for ln in lines[s + 1:e]:
if ln.strip().startswith(("from ", "import ")):
pass
body = lines[:s] + lines[e + 1:]
if not dry:
with open(path, "w", encoding="utf-8") as fh:
fh.writelines(body)
have = defined_symbols(path) or set()
changed.append({"file": spec["file"], "action": "block_removed_for_regen"})
new_blk = build_block(spec, have)
if not new_blk:
changed.append({"file": spec["file"], "action": "no_change",
"symbols_ok": len(spec["symbols"])})
continue
if not dry:
with open(path, "a", encoding="utf-8") as fh:
fh.write("\n\n" + new_blk)
changed.append({"file": spec["file"], "action": "block_appended",
"symbols_added": [s for s in spec["symbols"] if s not in have]})
return changed
def main(argv=None):
ap = argparse.ArgumentParser(description="M1b import closure fixer/verifier")
ap.add_argument("--check", action="store_true", help="只核验,不写盘")
ap.add_argument("--json", help="把结果写入指定 JSON 文件")
args = ap.parse_args(argv)
fixed = [] if args.check else apply_fix(dry=False)
breaks = scan_closure()
result = {
"mode": "check" if args.check else "fix+check",
"scanned_packages": list(SCAN_PKGS),
"scanned_files": len(list(iter_py_files())),
"fixed": fixed,
"import_closure_breaks": breaks,
"break_count": len(breaks),
"closure_ok": len(breaks) == 0,
}
text = json.dumps(result, ensure_ascii=False, indent=2)
if args.json:
p = args.json if os.path.isabs(args.json) else os.path.join(REPO_ROOT, args.json)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text + "\n")
print(text)
return 0 if result["closure_ok"] else 2
if __name__ == "__main__":
sys.exit(main())

401
tools/m1b_gen_ddl.py Normal file
View File

@ -0,0 +1,401 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b DDL 生成器:由 m1b/tables.py 单一事实来源产出双方言 DDL + models JSON。
对应 QC 退回意见 #5/#7需给出字段/表/关联关系映射 + DDL 产物 + 落库证据。
产物全部写入仓库可复核
modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl.sql mariadb 方言生产
modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl_sqlite.sql sqlite 方言取证/测试
modules/pbl_blueprint/pbl_blueprint/models/m1b/*.json 4 个表定义四段式
modules/pbl_blueprint/pbl_blueprint/json/m1b/pbl_blueprint_template.json 离线模板包
projects/pbls/deliverables/m1b/ddl_report.json 生成报告字段/索引统计
用法
python3 modules/pbl_blueprint/tools/m1b_gen_ddl.py # 生成全部产物
python3 modules/pbl_blueprint/tools/m1b_gen_ddl.py --check # 只校验一致性,不写盘
一致性保证mariadb DDLsqlite DDLmodels JSON 三者都由同一份 TABLES 生成
字段名/顺序/类型语义一一对应--check 模式会逐表逐字段比对 models JSON
TABLES任何漂移都返回非 0 退出码
"""
import argparse
import json
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE) # modules/pbl_blueprint
PKG = os.path.join(MOD_ROOT, "pbl_blueprint")
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
if MOD_ROOT not in sys.path:
sys.path.insert(0, MOD_ROOT)
from pbl_blueprint.m1b.tables import TABLES, to_model_json # noqa: E402
from pbl_blueprint.m1b.init import build_sqlite_ddl # noqa: E402
SQL_DIR = os.path.join(PKG, "sql")
MODELS_DIR = os.path.join(PKG, "models", "m1b")
JSON_DIR = os.path.join(PKG, "json", "m1b")
REPORT_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b")
#: 抽象类型 -> mariadb 类型
MYSQL_TYPES = {
"text": "LONGTEXT",
"json": "LONGTEXT",
"int": "BIGINT",
"bool": "TINYINT(1)",
"datetime": "VARCHAR(32)",
"float": "DECIMAL(18,4)",
}
def mysql_type(field):
"""抽象类型 -> mariadb 列类型string(N) -> VARCHAR(N))。"""
raw = field.get("type") or "string(255)"
base = raw.split("(")[0].strip().lower()
if base == "string":
size = raw.split("(")[1].rstrip(")") if "(" in raw else "255"
return "VARCHAR(%s)" % size
return MYSQL_TYPES.get(base, "VARCHAR(255)")
def gen_mysql_ddl(tables=None):
"""生成 mariadb 方言 DDLInnoDB / utf8mb4 / 无 FOREIGN KEYQ-OPEN-3"""
out = [
"-- ============================================================",
"-- PBL M1b DDL (mariadb dialect) - generated by tools/m1b_gen_ddl.py",
"-- Source of truth: pbl_blueprint/m1b/tables.py",
"-- Q-OPEN-3: NO FOREIGN KEY; base tables (world/scene/entity/script)",
"-- are NEVER created/altered here (read-only soft refs).",
"-- Idempotent: CREATE TABLE IF NOT EXISTS / CREATE INDEX guarded.",
"-- ============================================================",
"",
]
idx_stmts = []
for t in (tables or TABLES):
cols = []
for f in t["fields"]:
if f.get("pk"):
cols.append(" `%s` VARCHAR(40) NOT NULL COMMENT '%s'"
% (f["name"], _esc(f.get("comment"))))
continue
notnull = " NOT NULL" if f.get("required") else " NULL"
default = ""
if f["name"] == "is_deleted":
default = " DEFAULT 0"
elif f["name"] == "enabled":
default = " DEFAULT 1"
elif f["name"] == "resolve_status":
default = " DEFAULT 'unresolved'"
elif f["name"] == "ownership":
default = " DEFAULT 'read_only'"
elif f["name"] == "tenant_key":
default = " DEFAULT '__tenant__'"
cols.append(" `%s` %s%s%s COMMENT '%s'" % (
f["name"], mysql_type(f), notnull, default, _esc(f.get("comment"))))
pks = [f["name"] for f in t["fields"] if f.get("pk")]
if pks:
cols.append(" PRIMARY KEY (`%s`)" % "`, `".join(pks))
out.append("-- %s" % t["summary"].replace("\n", " "))
out.append("CREATE TABLE IF NOT EXISTS `%s` (" % t["name"])
out.append(",\n".join(cols))
out.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "
"COLLATE=utf8mb4_general_ci COMMENT='%s';" % _esc(t["summary"][:60]))
out.append("")
for ix in t["indexes"]:
kw = "UNIQUE INDEX" if ix.get("unique") else "INDEX"
idx_stmts.append(
"-- %s\nALTER TABLE `%s` ADD %s `%s` (%s);" % (
ix.get("comment", ""), t["name"], kw, ix["name"],
", ".join(["`%s`" % c for c in ix["columns"]])))
out.append("-- ---------- indexes (MySQL 不支持 CREATE INDEX IF NOT EXISTS")
out.append("-- 重复执行会报 1061 Duplicate key name可忽略")
out.append("-- 首次建库执行本段即可) ----------")
out.extend(idx_stmts)
out.append("")
return "\n".join(out)
def _esc(text):
"""SQL 注释里的单引号转义。"""
return (text or "").replace("'", "''").replace("\n", " ")
OFFLINE_TEMPLATES = {
"templates": [
{
"id": "tpl_platform_pbl_stem_v1",
"tenant_id": None,
"tenant_key": "__platform__",
"code": "platform.pbl.stem",
"name": "平台公共·STEM 项目式学习模板",
"description": "平台公共模板tenant_id NULL租户只读STEM 主题 PBL 骨架,"
"含 7 类子对象默认结构与扩展字段默认值。",
"category": "STEM",
"scope": "platform",
"version": "1.0.0",
"status": "published",
"source": "offline_fallback",
"tags": ["platform", "stem", "public"],
"ext_schema": {
"difficulty": {"type": "enum", "options": ["入门", "进阶", "高阶"],
"default": "进阶"},
"duration_hours": {"type": "int", "default": 12, "min": 1, "max": 200},
"subject": {"type": "string", "default": "综合实践"},
},
"default_values": {
"difficulty": "进阶",
"duration_hours": 12,
"subject": "综合实践",
"assessment_mode": "rubric",
},
"subobject_policy": {
"driving_question": {"min": 1, "default_item": {
"question": "我们如何用工程方法解决身边的真实问题?",
"cognitive_level": "应用"}},
"learning_goal": {"min": 2, "default_item": {
"goal": "能运用跨学科知识完成一个可交付的作品",
"bloom_level": "L3", "assessable": True}},
"mission": {"min": 3, "default_item": {
"name": "阶段任务", "estimated_minutes": 45}},
"problem": {"min": 1, "default_item": {
"title": "核心问题", "difficulty": ""}},
"role": {"min": 2, "default_item": {
"name": "团队成员", "team_size": 4}},
"learner": {"min": 0, "default_item": {"name": "学习者"}},
"artifact_def": {"min": 1, "default_item": {
"name": "作品与报告", "artifact_type": "模型"}},
},
"structure": {
"blueprint": {"name": "STEM 项目式学习(模板实例)",
"status": "draft", "category": "STEM"},
"subobjects": {
"driving_question": [
{"question": "我们如何用工程方法解决身边的真实问题?",
"sort_no": 1, "ext": {"cognitive_level": "应用",
"weight": 0.4}},
],
"learning_goal": [
{"goal": "掌握需求分析与方案设计流程", "sort_no": 1,
"ext": {"bloom_level": "L3", "assessable": True}},
{"goal": "完成可演示的原型作品", "sort_no": 2,
"ext": {"bloom_level": "L4", "assessable": True}},
],
"mission": [
{"name": "M1 需求调研", "sort_no": 1,
"ext": {"estimated_minutes": 45}},
{"name": "M2 方案设计", "sort_no": 2,
"ext": {"estimated_minutes": 90}},
{"name": "M3 原型实现与展示", "sort_no": 3,
"ext": {"estimated_minutes": 120}},
],
"problem": [
{"title": "如何在有限材料下提升结构强度?", "sort_no": 1,
"ext": {"difficulty": ""}},
],
"role": [
{"name": "项目经理", "sort_no": 1,
"ext": {"team_size": 1, "responsibility": "统筹与汇报"}},
{"name": "工程师", "sort_no": 2,
"ext": {"team_size": 3, "responsibility": "设计与实现"}},
],
"learner": [],
"artifact_def": [
{"name": "设计文档", "sort_no": 1,
"ext": {"artifact_type": "文档"}},
{"name": "原型作品", "sort_no": 2,
"ext": {"artifact_type": "模型"}},
],
},
},
},
{
"id": "tpl_platform_pbl_humanities_v1",
"tenant_id": None,
"tenant_key": "__platform__",
"code": "platform.pbl.humanities",
"name": "平台公共·人文社科探究模板",
"description": "平台公共模板tenant_id NULL人文社科主题探究骨架。",
"category": "人文社科",
"scope": "platform",
"version": "1.0.0",
"status": "published",
"source": "offline_fallback",
"tags": ["platform", "humanities", "public"],
"ext_schema": {
"difficulty": {"type": "enum", "options": ["入门", "进阶", "高阶"],
"default": "入门"},
"duration_hours": {"type": "int", "default": 8},
"subject": {"type": "string", "default": "社会"},
},
"default_values": {"difficulty": "入门", "duration_hours": 8,
"subject": "社会", "assessment_mode": "peer"},
"subobject_policy": {
"driving_question": {"min": 1, "default_item": {
"question": "社区中的公共议题如何影响我们的生活?"}},
"learning_goal": {"min": 1, "default_item": {
"goal": "能用证据支持自己的观点"}},
"mission": {"min": 2, "default_item": {"name": "探究阶段"}},
"problem": {"min": 1, "default_item": {"title": "议题界定"}},
"role": {"min": 1, "default_item": {"name": "调研员"}},
"learner": {"min": 0, "default_item": {"name": "学习者"}},
"artifact_def": {"min": 1, "default_item": {"name": "调研报告"}},
},
"structure": {
"blueprint": {"name": "人文社科探究(模板实例)", "status": "draft",
"category": "人文社科"},
"subobjects": {
"driving_question": [
{"question": "社区中的公共议题如何影响我们的生活?",
"sort_no": 1}],
"learning_goal": [
{"goal": "能用证据支持自己的观点", "sort_no": 1}],
"mission": [{"name": "M1 议题选择", "sort_no": 1},
{"name": "M2 田野调查与汇报", "sort_no": 2}],
"problem": [{"title": "如何界定一个可研究的公共议题?",
"sort_no": 1}],
"role": [{"name": "调研员", "sort_no": 1},
{"name": "记录员", "sort_no": 2}],
"learner": [],
"artifact_def": [{"name": "调研报告", "sort_no": 1},
{"name": "公开展示", "sort_no": 2}],
},
},
},
]
}
def write(path, text):
"""写文件(自动建目录),返回字节数。"""
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
return len(text.encode("utf-8"))
def check_models_consistency():
"""校验 models/m1b/*.json 与 TABLES 一致(字段名/顺序/索引/唯一键)。"""
problems = []
for t in TABLES:
path = os.path.join(MODELS_DIR, "%s.json" % t["name"])
if not os.path.exists(path):
problems.append({"table": t["name"], "code": "MODEL_MISSING",
"path": path})
continue
with open(path, "r", encoding="utf-8") as fh:
try:
model = json.load(fh)
except ValueError as exc:
problems.append({"table": t["name"], "code": "MODEL_INVALID_JSON",
"error": str(exc)})
continue
expect = to_model_json(t["name"])
got_fields = [f["name"] for f in (model.get("fields") or [])]
exp_fields = [f["name"] for f in expect["fields"]]
if got_fields != exp_fields:
problems.append({"table": t["name"], "code": "FIELD_MISMATCH",
"missing": [f for f in exp_fields if f not in got_fields],
"extra": [f for f in got_fields if f not in exp_fields]})
got_ix = sorted([i["name"] for i in (model.get("indexes") or [])])
exp_ix = sorted([i["name"] for i in expect["indexes"]])
if got_ix != exp_ix:
problems.append({"table": t["name"], "code": "INDEX_MISMATCH",
"got": got_ix, "expect": exp_ix})
for sec in ("summary", "fields", "indexes", "codes"):
if sec not in model:
problems.append({"table": t["name"], "code": "SECTION_MISSING",
"section": sec})
return problems
def main(argv=None):
ap = argparse.ArgumentParser(description="M1b DDL / models generator")
ap.add_argument("--check", action="store_true",
help="只校验 models 与 TABLES 一致性,不写盘")
ap.add_argument("--no-offline", action="store_true",
help="不生成离线模板包")
args = ap.parse_args(argv)
if args.check:
problems = check_models_consistency()
print(json.dumps({"mode": "check", "tables": [t["name"] for t in TABLES],
"problems": problems,
"consistent": not problems}, ensure_ascii=False, indent=2))
return 1 if problems else 0
mysql_ddl = gen_mysql_ddl()
sqlite_ddl = build_sqlite_ddl()
written = []
written.append({"path": os.path.relpath(os.path.join(SQL_DIR, "m1b_ddl.sql"), REPO_ROOT),
"bytes": write(os.path.join(SQL_DIR, "m1b_ddl.sql"), mysql_ddl),
"dialect": "mariadb", "lines": mysql_ddl.count("\n") + 1})
written.append({"path": os.path.relpath(os.path.join(SQL_DIR, "m1b_ddl_sqlite.sql"), REPO_ROOT),
"bytes": write(os.path.join(SQL_DIR, "m1b_ddl_sqlite.sql"), sqlite_ddl),
"dialect": "sqlite", "lines": sqlite_ddl.count("\n") + 1})
for t in TABLES:
p = os.path.join(MODELS_DIR, "%s.json" % t["name"])
txt = json.dumps(to_model_json(t["name"]), ensure_ascii=False, indent=2) + "\n"
written.append({"path": os.path.relpath(p, REPO_ROOT), "bytes": write(p, txt),
"dialect": "model-json", "table": t["name"],
"fields": len(t["fields"]), "indexes": len(t["indexes"])})
if not args.no_offline:
p = os.path.join(JSON_DIR, "pbl_blueprint_template.json")
txt = json.dumps(OFFLINE_TEMPLATES, ensure_ascii=False, indent=2) + "\n"
written.append({"path": os.path.relpath(p, REPO_ROOT), "bytes": write(p, txt),
"dialect": "offline-package",
"templates": len(OFFLINE_TEMPLATES["templates"])})
report = {
"generator": "tools/m1b_gen_ddl.py",
"source_of_truth": "pbl_blueprint/m1b/tables.py",
"tables": [{"name": t["name"], "summary": t["summary"],
"field_count": len(t["fields"]),
"fields": [f["name"] for f in t["fields"]],
"indexes": [{"name": i["name"], "unique": bool(i.get("unique")),
"columns": i["columns"], "comment": i.get("comment", "")}
for i in t["indexes"]],
"codes": t.get("codes") or {}} for t in TABLES],
"q_open_3": {"foreign_keys": 0, "base_tables_touched": [],
"note": "无 FOREIGN KEY不 CREATE/ALTER world/scene/entity/script"},
"unique_keys_tenant_first": all(
(i.get("unique") and i["columns"][0] == "tenant_key")
for t in TABLES for i in t["indexes"]),
"written": written,
"consistency_problems": check_models_consistency(),
}
rp = os.path.join(REPORT_DIR, "ddl_report.json")
write(rp, json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({"ok": True, "written": [w["path"] for w in written],
"report": os.path.relpath(rp, REPO_ROOT),
"tables": [t["name"] for t in TABLES],
"consistency_problems": report["consistency_problems"]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

187
tools/m1b_register_sync.py Normal file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b 注册同步执行器QC 退回意见 #8需真实执行记录 + 库内注册数据证据)。
做三件事并**落盘可复核证据**
1. 建表幂等 DDLsqlite 方言mariadb DDL sql/m1b_ddl.sql
2. 注册 7 类子对象 + 模板/蓝图的扩展字段定义pbl_ext_field_def幂等 upsert
3. 注册平台公共模板pbl_blueprint_templatetenant_id NULL幂等 upsert
4. 回读库内注册数据 -> projects/pbls/deliverables/m1b/register_sync_report.json
+ projects/pbls/deliverables/audit/pbl_blueprint_audit.jsonl审计轨迹
用法
python3 tools/m1b_register_sync.py # 执行并落盘证据
python3 tools/m1b_register_sync.py --db path.db # 指定库文件
python3 tools/m1b_register_sync.py --twice # 连跑两次验证幂等(行数不增)
"""
import argparse
import json
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
for p in (REPO_ROOT, MOD_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
from pbl_blueprint.m1b import init as m1b_init # noqa: E402
from pbl_blueprint.m1b.audit import flush_memory_audit # noqa: E402
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
get_conn, reset_conn, sql_rows, table_exists,
)
from pbl_blueprint.m1b.tables import SUBOBJECT_TYPES, TABLES # noqa: E402
from pbl_blueprint.m1b.util import now_str # noqa: E402
DEFAULT_DB = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables",
"db", "pbl_m1b.sqlite3")
REPORT_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b")
def snapshot(conn):
"""回读库内注册数据(表存在性 + 行数 + 关键明细)。"""
out = {"tables": {}, "counts": {}, "details": {}}
for t in TABLES:
n = t["name"]
ready = table_exists(n, conn=conn)
out["tables"][n] = ready
if not ready:
out["counts"][n] = None
continue
rows = sql_rows("SELECT COUNT(*) AS c FROM %s" % n, conn=conn)
out["counts"][n] = int(rows[0]["c"] or 0) if rows else 0
if out["tables"].get("pbl_ext_field_def"):
out["details"]["ext_field_defs_by_owner"] = sql_rows(
"SELECT owner_type, COUNT(*) AS n FROM pbl_ext_field_def "
"GROUP BY owner_type ORDER BY owner_type", conn=conn)
out["details"]["ext_field_defs_platform_null_tenant"] = sql_rows(
"SELECT COUNT(*) AS n FROM pbl_ext_field_def WHERE tenant_id IS NULL",
conn=conn)
out["details"]["ext_field_def_sample"] = sql_rows(
"SELECT owner_type, owner_code, field_key, field_type, required "
"FROM pbl_ext_field_def ORDER BY owner_type, field_key LIMIT 25",
conn=conn)
if out["tables"].get("pbl_blueprint_template"):
out["details"]["platform_templates"] = sql_rows(
"SELECT id, code, name, version, status, scope, tenant_id "
"FROM pbl_blueprint_template WHERE tenant_id IS NULL "
"ORDER BY code, version", conn=conn)
out["details"]["tenant_templates_count"] = sql_rows(
"SELECT COUNT(*) AS n FROM pbl_blueprint_template "
"WHERE tenant_id IS NOT NULL", conn=conn)
return out
def run(db_path=None, twice=False, actor_id="m1b_register_sync"):
"""执行注册同步,返回完整报告 dict。"""
db_path = db_path or os.environ.get("PBL_M1B_DB") or DEFAULT_DB
os.environ["PBL_M1B_DB"] = db_path
os.environ.setdefault("PBL_AUDIT_DIR",
os.path.join(REPO_ROOT, "projects", "pbls",
"deliverables", "audit"))
reset_conn()
conn = get_conn(db_path)
log = []
def step(name, payload):
log.append({"step": name, "at": now_str(), "result": payload})
r1 = m1b_init.load_m1b(conn=conn, actor_id=actor_id)
step("load_m1b#1", {"ddl": r1["ddl"], "ext_field_defs": r1["ext_field_defs"],
"platform_templates": r1["platform_templates"]})
snap1 = snapshot(conn)
step("snapshot#1", {"counts": snap1["counts"]})
snap2 = None
if twice:
r2 = m1b_init.load_m1b(conn=conn, actor_id=actor_id)
step("load_m1b#2(idempotency)", {
"ddl": r2["ddl"], "ext_field_defs": r2["ext_field_defs"],
"platform_templates": r2["platform_templates"]})
snap2 = snapshot(conn)
step("snapshot#2", {"counts": snap2["counts"]})
flushed, audit_path = flush_memory_audit()
step("audit_flush", {"records": flushed, "path": audit_path})
idempotent = None
if snap2 is not None:
idempotent = snap1["counts"] == snap2["counts"]
step("idempotency_check", {
"ok": idempotent, "counts_run1": snap1["counts"],
"counts_run2": snap2["counts"],
"diff": {k: [snap1["counts"].get(k), snap2["counts"].get(k)]
for k in snap1["counts"]
if snap1["counts"].get(k) != snap2["counts"].get(k)} or None})
report = {
"tool": "tools/m1b_register_sync.py",
"milestone": "M1b",
"executed_at": now_str(),
"actor_id": actor_id,
"db": {"path": db_path,
"exists": os.path.exists(db_path) if db_path != ":memory:" else False,
"size_bytes": (os.path.getsize(db_path)
if db_path != ":memory:" and os.path.exists(db_path)
else None),
"dialect": "sqlite",
"mariadb_ddl": "modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl.sql"},
"tables_declared": [t["name"] for t in TABLES],
"tables_ready": snap1["tables"],
"row_counts": snap1["counts"],
"subobject_types_registered": list(SUBOBJECT_TYPES),
"ext_field_def_seeds": len(m1b_init.EXT_FIELD_SEED),
"details": snap1["details"],
"idempotent": idempotent,
"audit": {"flushed_records": flushed, "path": audit_path},
"log": log,
}
return report
def main(argv=None):
ap = argparse.ArgumentParser(description="M1b register sync executor")
ap.add_argument("--db", help="sqlite 库文件路径(默认 deliverables/db/pbl_m1b.sqlite3")
ap.add_argument("--twice", action="store_true", help="连跑两次验证幂等")
ap.add_argument("--report", help="报告输出路径")
args = ap.parse_args(argv)
report = run(db_path=args.db, twice=args.twice)
out = args.report or os.path.join(REPORT_DIR, "register_sync_report.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
fh.write(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n")
brief = {
"ok": True,
"report": os.path.relpath(out, REPO_ROOT),
"db": report["db"]["path"],
"tables_ready": report["tables_ready"],
"row_counts": report["row_counts"],
"idempotent": report["idempotent"],
"audit_records": report["audit"]["flushed_records"],
}
print(json.dumps(brief, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())

182
tools/m1b_run_tests.py Normal file
View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b 测试执行器:真实跑 unittest 并把完整日志落盘QC #9 取证)。
对应 QC 退回意见 #9「仅 py_compile 通过,无真实执行通过证据」。
本工具用 unittest.TextTestRunner 真实执行 tests/test_m1b_*.py
stdout/stderr 全量写入
projects/pbls/deliverables/m1b/test_logs/{suite}.log
并产出汇总 JSON
projects/pbls/deliverables/m1b/test_report.json
退出码 = 失败数 + 错误数0 表示全绿
用法
python3 tools/m1b_run_tests.py # 跑全部 M1b 测试
python3 tools/m1b_run_tests.py --suite ext_ref
python3 tools/m1b_run_tests.py -v 3
"""
import argparse
import io
import json
import os
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
import time
import traceback
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
TESTS_DIR = os.path.join(MOD_ROOT, "tests")
LOG_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables",
"m1b", "test_logs")
REPORT = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables",
"m1b", "test_report.json")
SUITES = {
"ext_ref": "test_m1b_ext_ref.py",
"realdb": "test_m1b_realdb.py",
"closure": "test_m1b_import_closure.py",
}
for p in (REPO_ROOT, MOD_ROOT, TESTS_DIR):
if p not in sys.path:
sys.path.insert(0, p)
def run_suite(name, filename, verbosity=2):
"""执行单个测试文件,返回 (result_dict, log_text)。"""
buf = io.StringIO()
old_out, old_err = sys.stdout, sys.stderr
started = time.time()
tests_run = failures = errors = skipped = 0
case_results = []
fatal = None
try:
sys.stdout = buf
sys.stderr = buf
loader = unittest.TestLoader()
suite = loader.discover(TESTS_DIR, pattern=filename, top_level_dir=TESTS_DIR)
runner = unittest.TextTestRunner(stream=buf, verbosity=verbosity)
res = runner.run(suite)
tests_run = res.testsRun
failures = len(res.failures)
errors = len(res.errors)
skipped = len(res.skipped)
for case, tb in res.failures:
case_results.append({"case": str(case), "status": "FAIL", "trace": tb})
for case, tb in res.errors:
case_results.append({"case": str(case), "status": "ERROR", "trace": tb})
for case, reason in res.skipped:
case_results.append({"case": str(case), "status": "SKIP",
"trace": str(reason)})
ok = res.wasSuccessful()
except Exception: # noqa: BLE001 - 装载失败也要留证据
ok = False
fatal = traceback.format_exc()
buf.write("\n[FATAL] suite load/run error:\n" + fatal)
finally:
sys.stdout, sys.stderr = old_out, old_err
elapsed = round(time.time() - started, 3)
log = buf.getvalue()
return {
"suite": name,
"file": "tests/%s" % filename,
"ok": bool(ok),
"tests_run": tests_run,
"failures": failures,
"errors": errors,
"skipped": skipped,
"passed": max(tests_run - failures - errors - skipped, 0),
"elapsed_sec": elapsed,
"fatal": fatal,
"cases": case_results,
}, log
def main(argv=None):
ap = argparse.ArgumentParser(description="M1b test runner (real execution)")
ap.add_argument("--suite", choices=list(SUITES.keys()),
help="只跑指定套件(缺省跑全部)")
ap.add_argument("-v", "--verbosity", type=int, default=2)
args = ap.parse_args(argv)
os.makedirs(LOG_DIR, exist_ok=True)
targets = ([args.suite] if args.suite else list(SUITES.keys()))
results = []
for name in targets:
fn = SUITES[name]
if not os.path.exists(os.path.join(TESTS_DIR, fn)):
results.append({"suite": name, "file": "tests/%s" % fn, "ok": False,
"tests_run": 0, "failures": 0, "errors": 1,
"skipped": 0, "passed": 0, "elapsed_sec": 0,
"fatal": "test file missing", "cases": []})
continue
res, log = run_suite(name, fn, verbosity=args.verbosity)
log_path = os.path.join(LOG_DIR, "%s.log" % name)
with open(log_path, "w", encoding="utf-8") as fh:
fh.write("=== M1b test suite: %s (%s) ===\n" % (name, fn))
fh.write("=== executed_at: %s ===\n" % time.strftime("%Y-%m-%d %H:%M:%S"))
fh.write("=== python: %s ===\n" % sys.version.replace("\n", " "))
fh.write("=== result: %s | run=%d pass=%d fail=%d error=%d skip=%d "
"| %.3fs ===\n\n" % (
"PASS" if res["ok"] else "FAIL", res["tests_run"],
res["passed"], res["failures"], res["errors"],
res["skipped"], res["elapsed_sec"]))
fh.write(log)
fh.write("\n=== END OF LOG ===\n")
res["log"] = os.path.relpath(log_path, REPO_ROOT)
res["log_bytes"] = os.path.getsize(log_path)
results.append(res)
print("[%s] %s run=%d pass=%d fail=%d error=%d skip=%d (%.3fs) -> %s" % (
"PASS" if res["ok"] else "FAIL", name, res["tests_run"], res["passed"],
res["failures"], res["errors"], res["skipped"], res["elapsed_sec"],
res["log"]))
total_run = sum(r["tests_run"] for r in results)
total_fail = sum(r["failures"] for r in results)
total_err = sum(r["errors"] for r in results)
total_skip = sum(r["skipped"] for r in results)
summary = {
"tool": "tools/m1b_run_tests.py",
"milestone": "M1b",
"executed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"python": sys.version.split()[0],
"real_execution": True,
"suites": results,
"totals": {"run": total_run,
"passed": total_run - total_fail - total_err - total_skip,
"failures": total_fail, "errors": total_err,
"skipped": total_skip},
"all_green": total_fail == 0 and total_err == 0 and total_run > 0,
"log_dir": os.path.relpath(LOG_DIR, REPO_ROOT),
}
os.makedirs(os.path.dirname(REPORT), exist_ok=True)
with open(REPORT, "w", encoding="utf-8") as fh:
fh.write(json.dumps(summary, ensure_ascii=False, indent=2, default=str) + "\n")
print(json.dumps({"all_green": summary["all_green"], "totals": summary["totals"],
"report": os.path.relpath(REPORT, REPO_ROOT)},
ensure_ascii=False, indent=2))
return 0 if summary["all_green"] else (total_fail + total_err)
if __name__ == "__main__":
sys.exit(main())