deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
d52c201dd4
commit
dfc840b24a
@ -357,3 +357,41 @@ HANDLERS = {
|
|||||||
'/pbl_blueprint/template/instantiate': api_template_instantiate,
|
'/pbl_blueprint/template/instantiate': api_template_instantiate,
|
||||||
'/pbl_blueprint/template/offline_fallback': api_template_offline_fallback,
|
'/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 <<<
|
||||||
|
|||||||
@ -120,3 +120,39 @@ def loads(txt, default=None):
|
|||||||
return _json.loads(txt)
|
return _json.loads(txt)
|
||||||
except Exception:
|
except Exception:
|
||||||
return default
|
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 <<<
|
||||||
|
|||||||
175
pbl_blueprint/m1b/__init__.py
Normal file
175
pbl_blueprint/m1b/__init__.py
Normal 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_audit(append-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
408
pbl_blueprint/m1b/api.py
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 对外 API 层:接口注册表 + 统一响应包。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #4:pbl_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
139
pbl_blueprint/m1b/audit.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 审计写入(append-only,自包含)。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #3:pbl_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
389
pbl_blueprint/m1b/compat.py
Normal 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: 租户 ID;None 表示平台公共数据
|
||||||
|
: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),
|
||||||
|
}
|
||||||
253
pbl_blueprint/m1b/crud_factory.py
Normal file
253
pbl_blueprint/m1b/crud_factory.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b CRUD 工厂(自包含):tenant_crud / crud_factory。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #3:pbl_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
241
pbl_blueprint/m1b/dbutil.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b DB 适配层(自包含):sqlite3 真实落库 + 可选 mariadb 方言 DDL。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #1/#3:pbl_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-like(sqlite3.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
153
pbl_blueprint/m1b/errors.py
Normal 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
316
pbl_blueprint/m1b/init.py
Normal 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 EXISTS(sqlite 方言,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
246
pbl_blueprint/m1b/ref.py
Normal 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)",
|
||||||
|
},
|
||||||
|
}
|
||||||
445
pbl_blueprint/m1b/subobject.py
Normal file
445
pbl_blueprint/m1b/subobject.py
Normal 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_def(pbl_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):
|
||||||
|
"""取子对象规格,类型非法即抛 PblValidationError(fail-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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 泛化 CRUD(7 类同签名) ----------------
|
||||||
|
|
||||||
|
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 KEY(Q-OPEN-3)",
|
||||||
|
],
|
||||||
|
}
|
||||||
306
pbl_blueprint/m1b/tables.py
Normal file
306
pbl_blueprint/m1b/tables.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 表定义(单一事实来源):模板扩展 / 扩展字段 / 子对象扩展 / 关联判定。
|
||||||
|
|
||||||
|
四段式格式(database-table-definition-spec):summary / 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": "模板ID(new_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_only(M1b 固定只读)"},
|
||||||
|
{"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()},
|
||||||
|
}
|
||||||
557
pbl_blueprint/m1b/template.py
Normal file
557
pbl_blueprint/m1b/template.py
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 模板:平台公共部分(tenant_id NULL)+ 租户模板 + 实例化 + 离线兜底。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #4:pbl_agent_runtime.api 依赖 pbl_blueprint.api 的
|
||||||
|
pbl_template_instantiate / pbl_blueprint_create,本模块实现真实逻辑并由
|
||||||
|
api.py 注册为同名接口函数。
|
||||||
|
|
||||||
|
关键规则:
|
||||||
|
- scope='platform' 的模板 tenant_id 为 NULL(tenant_key='__platform__'),
|
||||||
|
租户只读;scope='tenant' 必须带 tenant_id;
|
||||||
|
- 实例化:模板 structure_json + default_values_json + ext_schema_json
|
||||||
|
-> 生成蓝图行 + 7 类子对象行 + 扩展值行(单事务语义,逐步落库);
|
||||||
|
- 离线兜底:DB 不可达/模板缺失时从 json/m1b 离线包取模板,
|
||||||
|
source='offline_fallback',并在返回体标注 fallback_reason(QC 要求如实说明)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
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_reason(QC 要求
|
||||||
|
如实说明 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
128
pbl_blueprint/m1b/tenant.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 租户上下文与 require_tenant(自包含)。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #2:pbl_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 表示允许平台公共(返回 None),False 时缺失即抛错
|
||||||
|
: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
110
pbl_blueprint/m1b/util.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 通用工具:ID / 时间 / JSON 序列化(自包含,仅标准库)。
|
||||||
|
|
||||||
|
对应 QC 退回意见 #3:pbl_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")
|
||||||
@ -1,551 +1,417 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""M1b 测试:模板平台公共部分 / 子对象扩展 / 关联表判定。
|
"""M1b 扩展字段 + 关联判定测试(真实执行,非 py_compile)。
|
||||||
|
|
||||||
运行:cd modules/pbl_blueprint && python -m pytest tests/test_m1b_ext_ref.py -q
|
覆盖 QC 退回意见 #6(7 类子对象泛化契约可验收证据)与
|
||||||
(无 pytest 时可直接 python tests/test_m1b_ext_ref.py)
|
#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 os
|
||||||
|
import shutil
|
||||||
import sys
|
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 unittest
|
||||||
|
|
||||||
import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import)
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, os.path.dirname(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
|
from pbl_blueprint.m1b import init as m1b_init # noqa: E402
|
||||||
IS_NULL, TenantRequiredError, PermissionDeniedError, ValidationError,
|
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
|
||||||
NotFoundError, ConflictError,
|
get_conn, reset_conn, sql_exec, sql_rows, table_exists,
|
||||||
)
|
)
|
||||||
from pbl_blueprint import m1b_template as T # noqa: E402
|
from pbl_blueprint.m1b.errors import ( # noqa: E402
|
||||||
from pbl_blueprint import m1b_subobject as S # noqa: E402
|
PblForbidden, PblNotFound, PblValidationError, TenantMissingError,
|
||||||
from pbl_blueprint import m1b_ref as R # noqa: E402
|
)
|
||||||
from pbl_blueprint import m1b_api as A # noqa: E402
|
from pbl_blueprint.m1b.ref import ( # noqa: E402
|
||||||
from pbl_blueprint import m1b_init as I # 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):
|
class M1bExtRefTestCase(unittest.TestCase):
|
||||||
"""最小内存 DB:支持 query/insert/update/delete(与 m1b_common 适配器契约一致)。"""
|
"""扩展字段 + 关联判定真实库测试。"""
|
||||||
|
|
||||||
def __init__(self):
|
@classmethod
|
||||||
self.tables = {}
|
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):
|
@classmethod
|
||||||
return list(self.tables.get(table, []))
|
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):
|
# ---------- 1. 泛化契约(QC #6) ----------
|
||||||
self.tables.setdefault(table, []).append(dict(row))
|
def test_01_contract_covers_exactly_seven_types(self):
|
||||||
return row.get("id")
|
"""契约声明恰好 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):
|
def test_02_unknown_subobject_type_rejected(self):
|
||||||
n = 0
|
"""非法类型 fail-closed(不允许静默通过)。"""
|
||||||
for r in self.tables.get(table, []):
|
for bad in ("pbl_project", "unknown", "", None):
|
||||||
if all((r.get(k) in (None, "") if isinstance(v, type(IS_NULL)) else r.get(k) == v)
|
with self.assertRaises(PblValidationError):
|
||||||
for k, v in (conds or {}).items()):
|
create_subobject(TENANT, bad, BP_ID, data={"name": "x"},
|
||||||
r.update(values)
|
conn=self.conn)
|
||||||
n += 1
|
|
||||||
return n
|
|
||||||
|
|
||||||
def delete(self, table, conds):
|
def test_03_all_seven_types_crud_through_same_api(self):
|
||||||
rows = self.tables.get(table, [])
|
"""7 类子对象全部通过同一组函数完成 增/查/改/删(泛化实证)。"""
|
||||||
keep = [r for r in rows if not all(r.get(k) == v for k, v in (conds or {}).items())]
|
created = {}
|
||||||
self.tables[table] = keep
|
for st in SUBOBJECT_TYPES:
|
||||||
return len(rows) - len(keep)
|
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"
|
def test_05_tenant_isolation_on_subobjects(self):
|
||||||
OTHER = "t-002"
|
"""跨租户读不到彼此子对象(租户打头强制隔离)。"""
|
||||||
|
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 test_07_set_ext_is_idempotent(self):
|
||||||
def setUp(self):
|
"""重复写同一扩展字段不产生重复行(唯一键幂等)。"""
|
||||||
self.db = FakeDB()
|
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):
|
def test_08_ext_validation_enum_and_range(self):
|
||||||
with self.assertRaises(PermissionDeniedError):
|
"""枚举越界 / 数值越界 / 类型错误 / 未声明字段 均被校验拦截。"""
|
||||||
T.create_template(self.db, None, {"code": "p1", "name": "平台模板"},
|
# 枚举越界
|
||||||
actor="u1", is_platform_admin=False)
|
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):
|
def test_09_set_ext_rejects_invalid_values(self):
|
||||||
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform",
|
"""set_ext 校验失败即抛错且不落库。"""
|
||||||
"content": {"a": 1}},
|
row = create_subobject(TENANT, "artifact_def", BP_ID,
|
||||||
actor="admin", is_platform_admin=True)
|
data=dict(SAMPLES["artifact_def"]), conn=self.conn)
|
||||||
self.assertTrue(r["ok"])
|
before = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
|
||||||
self.assertTrue(r["is_platform"])
|
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
|
||||||
self.assertIsNone(r["template"]["tenant_id"])
|
with self.assertRaises(PblValidationError):
|
||||||
# 租户可见平台公共模板
|
set_ext(TENANT, "artifact_def", row["id"],
|
||||||
lst = T.list_templates(self.db, TENANT)
|
{"artifact_type": "不存在的类型"}, conn=self.conn)
|
||||||
self.assertEqual(lst["total"], 1)
|
after = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
|
||||||
self.assertTrue(lst["items"][0]["is_platform"])
|
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
|
||||||
self.assertFalse(lst["items"][0]["editable"])
|
self.assertEqual(before[0]["n"], after[0]["n"])
|
||||||
# 其他租户同样可见(公共)
|
|
||||||
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 1)
|
|
||||||
|
|
||||||
def test_tenant_cannot_write_platform_template(self):
|
def test_10_create_with_inline_ext(self):
|
||||||
T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform"},
|
"""创建子对象时可内联 ext,一次调用完成基础行+扩展值。"""
|
||||||
actor="admin", is_platform_admin=True)
|
row = create_subobject(
|
||||||
lst = T.list_templates(self.db, TENANT)
|
TENANT, "learning_goal", BP_ID,
|
||||||
tid = lst["items"][0]["id"]
|
data=dict(SAMPLES["learning_goal"],
|
||||||
with self.assertRaises(PermissionDeniedError):
|
ext={"bloom_level": "L5", "assessable": True}),
|
||||||
T.update_template(self.db, TENANT, tid, {"name": "改名"}, actor="u1",
|
conn=self.conn)
|
||||||
is_platform_admin=False)
|
self.assertEqual(row["ext"]["bloom_level"], "L5")
|
||||||
with self.assertRaises(PermissionDeniedError):
|
self.assertEqual(get_ext(TENANT, "learning_goal", row["id"],
|
||||||
T.delete_template(self.db, TENANT, tid, actor="u1", is_platform_admin=False)
|
conn=self.conn)["assessable"], True)
|
||||||
|
|
||||||
def test_tenant_template_not_visible_to_others(self):
|
def test_11_delete_cascades_ext_values(self):
|
||||||
T.create_template(self.db, TENANT, {"code": "t1", "name": "租户模板"}, actor="u1")
|
"""删除子对象级联清理扩展值(应用层级联,无外键)。"""
|
||||||
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
|
row = create_subobject(TENANT, "mission", BP_ID,
|
||||||
self.assertEqual(T.list_templates(self.db, OTHER)["total"], 0)
|
data=dict(SAMPLES["mission"]), conn=self.conn)
|
||||||
tid = T.list_templates(self.db, TENANT)["items"][0]["id"]
|
set_ext(TENANT, "mission", row["id"], {"estimated_minutes": 30},
|
||||||
with self.assertRaises(NotFoundError):
|
conn=self.conn)
|
||||||
T.get_template(self.db, OTHER, tid)
|
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):
|
def test_12_subobject_tree_aggregates_seven_types(self):
|
||||||
with self.assertRaises(TenantRequiredError):
|
"""subobject_tree 一次取全 7 类 + 扩展值。"""
|
||||||
T.list_templates(self.db, None)
|
bp = "BP_TREE_%s" % os.getpid()
|
||||||
with self.assertRaises(TenantRequiredError):
|
for st in SUBOBJECT_TYPES:
|
||||||
T.list_templates(self.db, " ")
|
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):
|
# ---------- 3. 关联表判定(QC #5 + Q-OPEN-3) ----------
|
||||||
T.create_template(self.db, None, {"code": "same", "name": "平台", "scope": "platform"},
|
def test_13_ref_contract_declares_readonly_and_no_fk(self):
|
||||||
actor="admin", is_platform_admin=True)
|
"""关联契约声明:4 域只读、无外键、基表零写入。"""
|
||||||
# 同 code 在不同租户域可共存
|
c = ref_contract()
|
||||||
T.create_template(self.db, TENANT, {"code": "same", "name": "租户"}, actor="u1")
|
self.assertEqual(sorted(c["ref_kinds"]), sorted(REF_KINDS))
|
||||||
with self.assertRaises(ConflictError):
|
self.assertEqual(c["ownership"], "read_only")
|
||||||
T.create_template(self.db, TENANT, {"code": "same", "name": "重复"}, actor="u1")
|
self.assertTrue(c["q_open_3"]["no_foreign_key"])
|
||||||
with self.assertRaises(ConflictError):
|
self.assertEqual(c["q_open_3"]["base_table_writes"], 0)
|
||||||
T.create_template(self.db, None, {"code": "same", "name": "重复平台",
|
self.assertEqual(sorted(c["base_tables_readonly"].keys()),
|
||||||
"scope": "platform"},
|
["entity", "scene", "script", "world"])
|
||||||
actor="admin", is_platform_admin=True)
|
|
||||||
|
|
||||||
def test_fork_platform_to_tenant(self):
|
def test_14_ref_stub_when_base_table_unavailable(self):
|
||||||
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
|
"""基表不可达 -> resolve_status='stub',不报错、不建表(Q-OPEN-3)。"""
|
||||||
"scope": "platform",
|
row = resolve_ref(TENANT, BP_ID, "world", "W_NOT_EXIST_1",
|
||||||
"content": {"missions": [{"id": "m1"}]},
|
actor_id="tester", conn=self.conn)
|
||||||
"ext_schema": {"mission": {"difficulty": "hard"}}},
|
self.assertEqual(row["resolve_status"], "stub")
|
||||||
actor="admin", is_platform_admin=True)
|
self.assertEqual(row["ownership"], "read_only")
|
||||||
f = T.fork_template(self.db, TENANT, r["id"], actor="u1", new_code="p1_local")
|
self.assertIn("not available", row["resolve_note"])
|
||||||
self.assertTrue(f["ok"])
|
self.assertFalse(table_exists("world", conn=self.conn),
|
||||||
self.assertEqual(f["template"]["tenant_id"], TENANT)
|
"判定过程不得创建 world 基表")
|
||||||
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_builtin_template_cannot_be_deleted(self):
|
def test_15_ref_resolved_with_readonly_snapshot(self):
|
||||||
r = T.create_template(self.db, None, {"code": "b1", "name": "内置", "scope": "platform",
|
"""基表可达且行存在 -> resolved + 只读快照。"""
|
||||||
"is_builtin": 1, "content": {"x": 1}},
|
sql_exec("CREATE TABLE IF NOT EXISTS world (id TEXT PRIMARY KEY, "
|
||||||
actor="admin", is_platform_admin=True)
|
"name TEXT, code TEXT, status TEXT)", conn=self.conn)
|
||||||
with self.assertRaises(PermissionDeniedError):
|
sql_exec("INSERT INTO world (id, name, code, status) VALUES (?,?,?,?)",
|
||||||
T.delete_template(self.db, TENANT, r["id"], actor="admin", is_platform_admin=True)
|
["W_M1B_1", "M1b 测试世界", "world_m1b_1", "published"],
|
||||||
self.assertTrue(T.deprecate_template(self.db, TENANT, r["id"], actor="admin",
|
conn=self.conn)
|
||||||
is_platform_admin=True)["ok"])
|
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):
|
def test_16_ref_unresolved_when_row_missing(self):
|
||||||
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "空模板"}, actor="u1")
|
"""基表可达但行不存在 -> unresolved。"""
|
||||||
with self.assertRaises(ValidationError):
|
row = resolve_ref(TENANT, BP_ID, "world", "W_MISSING_ROW", conn=self.conn)
|
||||||
T.publish_template(self.db, TENANT, r["id"], actor="u1")
|
self.assertEqual(row["resolve_status"], "unresolved")
|
||||||
T.update_template(self.db, TENANT, r["id"], {"content": {"missions": []}}, actor="u1")
|
self.assertIn("not found", row["resolve_note"])
|
||||||
self.assertEqual(T.publish_template(self.db, TENANT, r["id"], actor="u1")["status"],
|
|
||||||
"published")
|
|
||||||
|
|
||||||
def test_instantiate_platform_template_sets_tenant(self):
|
def test_17_ref_external_is_stub(self):
|
||||||
r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板",
|
"""external 域恒为 stub(M1b 不解析外部系统)。"""
|
||||||
"scope": "platform", "status": "published",
|
row = resolve_ref(TENANT, BP_ID, "external", "EXT_1", conn=self.conn)
|
||||||
"content": {"missions": [{"id": "m1"}]},
|
self.assertEqual(row["resolve_status"], "stub")
|
||||||
"subobject_kinds": ["mission"]},
|
self.assertEqual(row["ref_table"], "")
|
||||||
actor="admin", is_platform_admin=True)
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_create(db, tenant_id, payload, actor=None):
|
def test_18_ref_invalid_kind_rejected(self):
|
||||||
captured["tenant_id"] = tenant_id
|
"""非法 ref_kind / 缺 ref_id -> PblValidationError。"""
|
||||||
captured["payload"] = payload
|
with self.assertRaises(PblValidationError):
|
||||||
return {"ok": True, "id": "bp-1"}
|
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",
|
def test_19_ref_idempotent_and_subobject_scoped(self):
|
||||||
create_blueprint=fake_create)
|
"""同目标+同发起方重复判定不增行;子对象级关联可区分。"""
|
||||||
self.assertTrue(res["ok"])
|
r1 = resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", conn=self.conn)
|
||||||
self.assertTrue(res["is_platform_template"])
|
r2 = resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", conn=self.conn)
|
||||||
self.assertEqual(captured["tenant_id"], TENANT) # 实例绝不继承 NULL
|
self.assertEqual(r1["id"], r2["id"])
|
||||||
self.assertEqual(captured["payload"]["tenant_id"], TENANT)
|
n = sql_rows("SELECT COUNT(*) AS c FROM pbl_blueprint_ref "
|
||||||
self.assertEqual(captured["payload"]["template_id"], r["id"])
|
"WHERE ref_id = 'S_M1B_1' AND subobject_type = 'blueprint'",
|
||||||
# usage_count +1
|
conn=self.conn)
|
||||||
self.assertEqual(int(T.get_template(self.db, TENANT, r["id"])["usage_count"]), 1)
|
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):
|
def test_20_ref_summary_and_tenant_isolation(self):
|
||||||
r = T.create_template(self.db, TENANT, {"code": "t1", "name": "x", "content": {"a": 1}},
|
"""汇总报表按 kind×status 计数;跨租户不可见。"""
|
||||||
actor="u1")
|
resolve_ref(OTHER, BP_ID, "entity", "E_OTHER_1", conn=self.conn)
|
||||||
T.deprecate_template(self.db, TENANT, r["id"], actor="u1")
|
s = ref_summary(TENANT, blueprint_id=BP_ID, conn=self.conn)
|
||||||
with self.assertRaises(ValidationError):
|
self.assertEqual(s["ownership"], "read_only")
|
||||||
T.instantiate_template(self.db, TENANT, r["id"], actor="u1")
|
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):
|
def test_21_base_table_write_sql_is_blocked(self):
|
||||||
a = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
|
"""Q-OPEN-3 守门:对基表的写 SQL 被静态拦截。"""
|
||||||
"content": {"a": 1}}], actor="system")
|
bad = [
|
||||||
self.assertEqual(a["created_count"], 1)
|
"UPDATE world SET name = 'x' WHERE id = '1'",
|
||||||
b = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子",
|
"DELETE FROM scene WHERE id = '1'",
|
||||||
"content": {"a": 1}}], actor="system")
|
"INSERT INTO entity (id) VALUES ('1')",
|
||||||
self.assertEqual(b["created_count"], 0)
|
"ALTER TABLE script ADD COLUMN tenant_id VARCHAR(64)",
|
||||||
self.assertEqual(b["skipped_count"], 1)
|
]
|
||||||
self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1)
|
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')"))
|
||||||
|
|
||||||
|
def test_22_generated_ddl_contains_no_fk_and_no_base_table(self):
|
||||||
class TestSubobjectExt(unittest.TestCase):
|
"""生成的 sqlite/mariadb DDL 均无 FOREIGN KEY、不含基表建表语句。"""
|
||||||
def setUp(self):
|
ddl = m1b_init.SQLITE_DDL.upper()
|
||||||
self.db = FakeDB()
|
self.assertNotIn("FOREIGN KEY", ddl)
|
||||||
self.bp = "bp-1"
|
for base in ("CREATE TABLE IF NOT EXISTS WORLD",
|
||||||
S.create_ext_field_def(self.db, None, {
|
"CREATE TABLE IF NOT EXISTS SCENE",
|
||||||
"scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty",
|
"CREATE TABLE IF NOT EXISTS ENTITY",
|
||||||
"label": "难度", "value_type": "enum", "enum_options": ["easy", "medium", "hard"],
|
"CREATE TABLE IF NOT EXISTS SCRIPT"):
|
||||||
"required": 1}, actor="admin", is_platform_admin=True)
|
self.assertNotIn(base, ddl)
|
||||||
S.create_ext_field_def(self.db, None, {
|
for t in TABLES:
|
||||||
"scope": "platform", "subobject_kind": "mission", "ext_key": "estimated_minutes",
|
self.assertIn("CREATE TABLE IF NOT EXISTS %s" % t["name"].upper(), ddl)
|
||||||
"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"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
224
tests/test_m1b_import_closure.py
Normal file
224
tests/test_m1b_import_closure.py
Normal 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):
|
||||||
|
"""静态扫描:跨文件符号引用断裂数 = 0(QC #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 可 import(QC #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 缺 %s(agent_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)
|
||||||
@ -1,447 +1,349 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""M1b 真实库集成测试(QC#5 整改)。
|
"""M1b 真实库测试:建表/注册同步/模板平台公共/实例化/离线兜底/幂等。
|
||||||
|
|
||||||
设计原则(对应 QC 退回意见 #5):
|
对应 QC 退回意见 #5/#7/#8/#9:本文件**真实执行**(非 py_compile),
|
||||||
* 不再只有「纯内存 FakeDB 单测」。本文件在**可连库环境真实执行** DDL + CRUD,
|
使用 sqlite 文件库落盘,断言库内行数与字段值,执行日志由
|
||||||
覆盖 4 张表的建表幂等、唯一键 tenant_key 打头去重、平台公共模板 tenant_id IS NULL、
|
tools/m1b_run_tests.py 落盘到 projects/pbls/deliverables/m1b/test_logs/。
|
||||||
子对象扩展 KV 读写、关联表软引用判定(Q-OPEN-3:零基表写入)。
|
|
||||||
* 环境受限时(无 MySQL/MariaDB 或未提供连接参数)**显式 SKIP 并打印移交说明**,
|
|
||||||
绝不把 SKIP 伪装成 PASS;移交部署验证的清单见 docs/M1b-qc-rework-evidence.md §5。
|
|
||||||
|
|
||||||
运行方式(三选一):
|
运行:
|
||||||
1) 环境变量:
|
python3 modules/pbl_blueprint/tests/test_m1b_realdb.py -v
|
||||||
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 = 全部通过 或 环境受限 SKIP;1 = 有真实 FAIL;2 = 依赖缺失且未 SKIP 判定失败。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import shutil
|
||||||
import sys
|
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__)))
|
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
|
||||||
DDL_PATH = os.path.join(REPO, "sql", "m1b_ddl.sql")
|
_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"
|
import tempfile
|
||||||
TENANT_B = "T_M1B_B"
|
import unittest
|
||||||
PLATFORM_KEY = "" # tenant_id NULL -> tenant_key ''
|
|
||||||
NOW = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
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=""):
|
class M1bRealDbTestCase(unittest.TestCase):
|
||||||
RESULTS.append((name, bool(ok), detail))
|
"""真实 sqlite 文件库上的 M1b 端到端断言。"""
|
||||||
print("[%s] %s :: %s" % ("PASS" if ok else "FAIL", name, detail))
|
|
||||||
|
|
||||||
|
@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):
|
@classmethod
|
||||||
RESULTS.append((name, None, detail))
|
def tearDownClass(cls):
|
||||||
print("[SKIP] %s :: %s" % (name, detail))
|
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 test_02_ddl_is_idempotent(self):
|
||||||
def resolve_dsn():
|
"""重复执行 load_m1b 不报错、不产生重复注册行(QC #8:幂等)。"""
|
||||||
dsn = os.environ.get("PBL_TEST_DB_DSN")
|
before = {t["name"]: sql_scalar("SELECT COUNT(*) FROM %s" % t["name"],
|
||||||
if dsn:
|
conn=self.conn)
|
||||||
m = re.match(r"mysql(?:\+\w+)?://([^:]+):([^@]*)@([^:/]+):?(\d+)?/(.+)", dsn)
|
for t in TABLES}
|
||||||
if not m:
|
again = m1b_init.load_m1b(conn=self.conn, actor_id="test_idempotent")
|
||||||
raise ValueError("PBL_TEST_DB_DSN 格式非法: %s" % dsn)
|
after = {t["name"]: sql_scalar("SELECT COUNT(*) FROM %s" % t["name"],
|
||||||
return dict(host=m.group(3), port=int(m.group(4) or 3306),
|
conn=self.conn)
|
||||||
user=m.group(1), password=m.group(2), database=m.group(5))
|
for t in TABLES}
|
||||||
if os.environ.get("PBL_TEST_DB_HOST"):
|
self.assertEqual(before, after, "重复执行导致行数变化,非幂等")
|
||||||
return dict(host=os.environ["PBL_TEST_DB_HOST"],
|
self.assertEqual(again["ext_field_defs"]["created"], 0,
|
||||||
port=int(os.environ.get("PBL_TEST_DB_PORT", 3306)),
|
"第二次注册不应新增扩展字段定义")
|
||||||
user=os.environ.get("PBL_TEST_DB_USER", "root"),
|
self.assertGreater(again["ext_field_defs"]["updated"], 0)
|
||||||
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_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):
|
def test_04_no_foreign_key_declared(self):
|
||||||
"""返回 (conn, driver_name);驱动缺失或连不上抛异常。"""
|
"""Q-OPEN-3:4 张表均无 FOREIGN KEY。"""
|
||||||
try:
|
for t in TABLES:
|
||||||
import pymysql # type: ignore
|
fks = sql_rows("PRAGMA foreign_key_list(%s)" % t["name"], conn=self.conn)
|
||||||
conn = pymysql.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"],
|
self.assertEqual(fks, [], "%s 不应声明外键" % t["name"])
|
||||||
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_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):
|
# ---------- 2. 注册同步证据 ----------
|
||||||
body = re.sub(r"/\*.*?\*/", " ", sql_text, flags=re.S)
|
def test_06_ext_field_defs_registered_for_all_seven_types(self):
|
||||||
stmts, buf = [], []
|
"""7 类子对象都注册到扩展字段定义(QC #6/#8:库内注册数据)。"""
|
||||||
for line in body.splitlines():
|
rows = sql_rows(
|
||||||
i = line.find("--")
|
"SELECT DISTINCT owner_type FROM pbl_ext_field_def "
|
||||||
if i >= 0:
|
"WHERE tenant_id IS NULL ORDER BY owner_type", conn=self.conn)
|
||||||
line = line[:i]
|
owners = [r["owner_type"] for r in rows]
|
||||||
buf.append(line)
|
for st in SUBOBJECT_TYPES:
|
||||||
if line.rstrip().endswith(";"):
|
self.assertIn(st, owners, "子对象类型 %s 未注册扩展字段定义" % st)
|
||||||
s = "\n".join(buf).strip()
|
self.assertIn("template", owners)
|
||||||
if s and s != ";":
|
self.assertIn("blueprint", owners)
|
||||||
stmts.append(s.rstrip(";"))
|
n = sql_scalar("SELECT COUNT(*) FROM pbl_ext_field_def", conn=self.conn)
|
||||||
buf = []
|
self.assertEqual(int(n), len(m1b_init.EXT_FIELD_SEED))
|
||||||
return [s for s in stmts if s.strip()]
|
|
||||||
|
|
||||||
|
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 test_08_audit_trail_written(self):
|
||||||
def t_ddl_idempotent(cur):
|
"""注册同步产生审计轨迹(append-only 落盘)。"""
|
||||||
"""T1: DDL 执行两遍均成功(CREATE TABLE IF NOT EXISTS 幂等)。"""
|
path = os.path.join(os.environ["PBL_AUDIT_DIR"],
|
||||||
with open(DDL_PATH, "r", encoding="utf-8") as f:
|
"pbl_blueprint_audit.jsonl")
|
||||||
stmts = split_ddl(f.read())
|
self.assertTrue(os.path.exists(path), "审计 JSONL 未落盘: %s" % path)
|
||||||
for rnd in (1, 2):
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
for s in stmts:
|
lines = [ln for ln in fh.read().splitlines() if ln.strip()]
|
||||||
cur.execute(s)
|
self.assertGreaterEqual(len(lines), 1)
|
||||||
log(len(stmts) == 4, "T1_DDL幂等",
|
actions = set()
|
||||||
"执行 %d 条语句 × 2 轮无异常" % len(stmts))
|
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):
|
def test_10_tenant_cannot_create_platform_template(self):
|
||||||
"""T2: 4 张表全部存在,且引擎/字符集符合 mariadb 方言。"""
|
"""非平台角色创建 scope=platform 模板 -> PblForbidden。"""
|
||||||
cur.execute("""SELECT TABLE_NAME, ENGINE, TABLE_COLLATION
|
from pbl_blueprint.m1b.template import create_template
|
||||||
FROM information_schema.TABLES
|
with self.assertRaises(PblForbidden):
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
create_template(tenant_id=TENANT,
|
||||||
AND TABLE_NAME IN ('pbl_blueprint_template','pbl_blueprint_ref',
|
data={"code": "hack.platform", "name": "越权",
|
||||||
'pbl_subobject_ext','pbl_ext_field_def')""")
|
"scope": "platform"}, conn=self.conn)
|
||||||
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_11_tenant_template_requires_tenant_id(self):
|
||||||
|
"""租户模板缺 tenant_id -> TenantMissingError(fail-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):
|
def test_12_template_unique_key_conflict(self):
|
||||||
"""T3: 4 张表零 FOREIGN KEY(Q-OPEN-3)。"""
|
"""同 (tenant_key, code, version) 重复创建 -> PblConflict。"""
|
||||||
cur.execute("""SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE
|
from pbl_blueprint.m1b.template import create_template
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
payload = {"code": "tenant.dup", "name": "重复模板", "scope": "tenant",
|
||||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
"version": "1.0.0"}
|
||||||
AND TABLE_NAME LIKE 'pbl\\_%'""")
|
create_template(tenant_id=TENANT, data=dict(payload), conn=self.conn)
|
||||||
n = cur.fetchone()[0]
|
with self.assertRaises(PblConflict):
|
||||||
log(n == 0, "T3_零外键", "information_schema 外键约束数=%d" % n)
|
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):
|
# ---------- 4. 实例化 + 离线兜底 ----------
|
||||||
"""T4: 每个 UNIQUE 索引首列均为 tenant_key。"""
|
def test_14_instantiate_creates_blueprint_and_subobjects(self):
|
||||||
cur.execute("""SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
"""模板实例化真实产出蓝图 + 7 类子对象 + 扩展值(QC #4/#6)。"""
|
||||||
FROM information_schema.STATISTICS
|
from pbl_blueprint.m1b.template import instantiate_template
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'pbl\\_%'
|
plat = sql_rows(
|
||||||
AND NON_UNIQUE = 0 AND INDEX_NAME <> 'PRIMARY'
|
"SELECT id, code FROM pbl_blueprint_template WHERE tenant_id IS NULL "
|
||||||
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX""")
|
"ORDER BY code LIMIT 1", conn=self.conn)[0]
|
||||||
bad = []
|
res = instantiate_template(
|
||||||
seen = {}
|
TENANT, template_id=plat["id"], actor_id="tester", conn=self.conn)
|
||||||
for t, ix, col, seq in cur.fetchall():
|
self.assertTrue(res["ok"])
|
||||||
if int(seq) == 1:
|
self.assertFalse(res["fallback"], "库内有模板,不应走离线兜底")
|
||||||
seen[(t, ix)] = col
|
self.assertTrue(res["blueprint"]["id"])
|
||||||
for (t, ix), col in seen.items():
|
self.assertEqual(res["template"]["scope"], "platform")
|
||||||
if col != "tenant_key":
|
self.assertGreaterEqual(res["counts"]["__total__"], 1,
|
||||||
bad.append("%s.%s 首列=%s" % (t, ix, col))
|
"未实例化出任何子对象")
|
||||||
log(seen and not bad, "T4_唯一键tenant打头",
|
self.assertGreaterEqual(res["counts"]["ext_written"], 1,
|
||||||
"唯一索引 %d 个,违规 %s" % (len(seen), bad or "无"))
|
"未写入任何扩展字段值")
|
||||||
|
# 子对象行真实落库
|
||||||
|
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):
|
def test_16_offline_fallback_marks_reason(self):
|
||||||
"""T5: 平台公共模板 tenant_id IS NULL 可插入,且 tenant_key='' 唯一去重生效。"""
|
"""库中不存在的模板走离线兜底,且如实标注 source/fallback_reason(QC 要求)。"""
|
||||||
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code LIKE 'M1B_T5%%'")
|
from pbl_blueprint.m1b.template import get_template, load_offline_templates
|
||||||
cur.execute(
|
items = load_offline_templates(reason="unit-test forced")
|
||||||
"""INSERT INTO pbl_blueprint_template
|
if not items:
|
||||||
(tenant_key, tenant_id, template_code, template_name, scope, status,
|
self.skipTest("离线模板包不存在,跳过兜底断言")
|
||||||
version, is_default, usage_count, created_at, updated_at)
|
self.assertEqual(items[0]["source"], "offline_fallback")
|
||||||
VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""",
|
self.assertIn("unit-test forced", items[0]["fallback_reason"])
|
||||||
(PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板", NOW, NOW))
|
self.assertFalse(items[0]["writable"], "离线兜底模板必须只读")
|
||||||
cur.execute(
|
hit = get_template(tenant_id=TENANT, code=items[0]["code"],
|
||||||
"""SELECT id, tenant_id, scope FROM pbl_blueprint_template
|
version=items[0]["version"], conn=self.conn)
|
||||||
WHERE tenant_key = %s AND template_code = 'M1B_T5_PLATFORM'""",
|
self.assertEqual(hit["source"], "offline_fallback")
|
||||||
(PLATFORM_KEY,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
ok_null = row is not None and row[1] is None and row[2] == "platform"
|
|
||||||
|
|
||||||
dup = False
|
def test_17_instantiate_from_offline_template(self):
|
||||||
try:
|
"""离线模板也能完成实例化(DB 模板缺失时的兜底可用性)。"""
|
||||||
cur.execute(
|
from pbl_blueprint.m1b.template import (
|
||||||
"""INSERT INTO pbl_blueprint_template
|
instantiate_template, load_offline_templates,
|
||||||
(tenant_key, tenant_id, template_code, template_name, scope, status,
|
)
|
||||||
version, is_default, usage_count, created_at, updated_at)
|
items = load_offline_templates(reason="unit-test forced")
|
||||||
VALUES (%s, NULL, %s, %s, 'platform', 'published', '1.0.0', 0, 0, %s, %s)""",
|
if not items:
|
||||||
(PLATFORM_KEY, "M1B_T5_PLATFORM", "平台公共模板重复", NOW, NOW))
|
self.skipTest("离线模板包不存在")
|
||||||
except Exception:
|
code = items[-1]["code"]
|
||||||
dup = True
|
# 确保库里没有该 code 的租户可见模板
|
||||||
log(ok_null and dup, "T5_平台模板NULL租户",
|
res = instantiate_template(TENANT, code=code,
|
||||||
"tenant_id IS NULL 插入=%s;同 tenant_key+code 重复插入被唯一键拒绝=%s"
|
version=items[-1]["version"],
|
||||||
% (ok_null, dup))
|
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):
|
def test_19_m1b_status_reports_counts(self):
|
||||||
"""T6: 同 template_code 在不同租户下可共存(tenant_key 打头唯一键)。"""
|
"""m1b_status 输出可核验的落库状态快照。"""
|
||||||
cur.execute("DELETE FROM pbl_blueprint_template WHERE template_code = 'M1B_T6_SAME'")
|
st = m1b_init.m1b_status(conn=self.conn)
|
||||||
for tk, tid in ((TENANT_A, TENANT_A), (TENANT_B, TENANT_B)):
|
self.assertTrue(all(st["tables"].values()), "存在未建出的表")
|
||||||
cur.execute(
|
self.assertGreater(st["counts"]["pbl_ext_field_def"], 0)
|
||||||
"""INSERT INTO pbl_blueprint_template
|
self.assertGreater(st["counts"]["pbl_blueprint_template"], 0)
|
||||||
(tenant_key, tenant_id, template_code, template_name, scope, status,
|
self.assertEqual(st["subobject_types"], list(SUBOBJECT_TYPES))
|
||||||
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)
|
|
||||||
|
|
||||||
|
# ---------- 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):
|
def test_21_api_returns_unified_envelope(self):
|
||||||
"""T7: 子对象扩展 KV 写入/读取/唯一键覆盖(基表零改动)。"""
|
"""API 返回统一响应包,异常不外泄(ok/code/message/data)。"""
|
||||||
cur.execute("DELETE FROM pbl_subobject_ext WHERE subobject_id LIKE 'M1B_T7%%'")
|
from pbl_blueprint.m1b.api import pbl_template_instantiate
|
||||||
cur.execute(
|
res = pbl_template_instantiate(tenant_id=None)
|
||||||
"""INSERT INTO pbl_subobject_ext
|
self.assertFalse(res["ok"])
|
||||||
(tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id,
|
self.assertEqual(res["code"], ErrorCode.TENANT_MISSING)
|
||||||
ext_key, ext_value, ext_value_type, source, status, created_at, updated_at)
|
self.assertIn("http_status", res)
|
||||||
VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ',
|
res2 = pbl_template_instantiate(tenant_id=TENANT, code="platform.pbl.stem")
|
||||||
'difficulty', '3', 'number', 'manual', 'active', %s, %s)""",
|
self.assertTrue(res2["ok"], res2)
|
||||||
(TENANT_A, TENANT_A, NOW, NOW))
|
self.assertIn("counts", res2["data"])
|
||||||
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"
|
|
||||||
|
|
||||||
dup = False
|
def test_22_api_blueprint_create_direct_and_from_template(self):
|
||||||
try:
|
"""pbl_blueprint_create 两种形态:直接建 / 由模板派生。"""
|
||||||
cur.execute(
|
from pbl_blueprint.m1b.api import pbl_blueprint_create
|
||||||
"""INSERT INTO pbl_subobject_ext
|
r1 = pbl_blueprint_create(tenant_id=TENANT,
|
||||||
(tenant_key, tenant_id, blueprint_id, subobject_type, subobject_id,
|
data={"name": "直接创建的蓝图"})
|
||||||
ext_key, ext_value, ext_value_type, source, status, created_at, updated_at)
|
self.assertTrue(r1["ok"], r1)
|
||||||
VALUES (%s, %s, 900001, 'driving_question', 'M1B_T7_OBJ',
|
self.assertTrue(r1["data"]["id"])
|
||||||
'difficulty', '5', 'number', 'manual', 'active', %s, %s)""",
|
r2 = pbl_blueprint_create(tenant_id=TENANT,
|
||||||
(TENANT_A, TENANT_A, NOW, NOW))
|
data={"template_code": "platform.pbl.stem",
|
||||||
except Exception:
|
"name": "由模板派生"})
|
||||||
dup = True
|
self.assertTrue(r2["ok"], r2)
|
||||||
log(ok_read and dup, "T7_子对象扩展KV",
|
self.assertIn("subobjects", r2["data"])
|
||||||
"读回=%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 可解析=%s;usage_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
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
462
tools/m1b_fix_import_closure.py
Normal file
462
tools/m1b_fix_import_closure.py
Normal 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.py、blueprint_crud.py、api_blueprint.py 仍 import 旧名
|
||||||
|
(PblError/PblNotFound/PblValidationError/require_tenant/write_audit/
|
||||||
|
tenant_crud/new_id/now_str),导致 from pbl_blueprint.api import * 全链
|
||||||
|
ImportError;pbl_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__.py,module_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"""%s(M1b 兼容供给层)"""\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
401
tools/m1b_gen_ddl.py
Normal 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 DDL、sqlite DDL、models 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 方言 DDL(InnoDB / utf8mb4 / 无 FOREIGN KEY,Q-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
187
tools/m1b_register_sync.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""M1b 注册同步执行器(QC 退回意见 #8:需真实执行记录 + 库内注册数据证据)。
|
||||||
|
|
||||||
|
做三件事并**落盘可复核证据**:
|
||||||
|
1. 建表(幂等 DDL,sqlite 方言;mariadb DDL 见 sql/m1b_ddl.sql)
|
||||||
|
2. 注册 7 类子对象 + 模板/蓝图的扩展字段定义(pbl_ext_field_def,幂等 upsert)
|
||||||
|
3. 注册平台公共模板(pbl_blueprint_template,tenant_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
182
tools/m1b_run_tests.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user