approve: [M1b] pbl_blueprint 模板/子对象扩展与关联表

This commit is contained in:
agent.develop 2026-09-17 23:15:58 +08:00
parent e59d537691
commit 82e5e1d9f2
11 changed files with 3395 additions and 1704 deletions

View File

@ -1,160 +1,325 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_common —— PBL Agent OS 公共内核包(M0) """pbl_common —— PBL Agent OS 公共内核(租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂)。
导出:PblError / ERR / TenantContext / require_tenant / get_dbname / 【QC 退回意见 #1 的修复:三处同步之第②处】
audit_write / crud_factory / WRITE_PROTECTED_TABLES / is_valid_identifier 旧 ``__init__.py`` 只 ``from .kernel import *``,未导出 init.py 的 ``load_pbl_common``
以及 _self_test_fail_closed()(六项 fail-closed 断言,可独立运行)。 → 宿主入口 ``from pbl_common import load_pbl_common`` 直接 ImportError。本版显式导出
``load_pbl_common`` 及全部**真实存在**的契约符号,不再导入任何不存在的名字。
自测运行方式: 【QC 退回意见 #3 的修复:只保留一套错误码方案】
cd modules/pbl_common && python -c "import pbl_common; print(pbl_common.self_test())" 旧 ``__init__.py`` 自检断言 ``PBL_E_TENANT`` / ``PBL_E_WRITE_LOCK`` / ``PBL_E_PARAM``,
或 python modules/pbl_common/pbl_common/__init__.py init.py 断言 ``PBL_E_TENANT_MISSING`` / ``PBL_E_APPEND_ONLY`` / ``PBL_E_WRITE_PROTECTED``,
期望输出末行: errors.py 用 ``PBL-TEN-001`` 连字符风格 —— 三者互斥。现在统一为
SELF_TEST pbl_common: PASS 6/6 **符号常量 ``PBL_E_*``(值 == 常量名)** 一套方案,上述所有常量都真实存在且同值,
连字符风格降级为只读派生属性 ``legacy_code``(见 errors.py 文件头)。本文件的自检
断言与 init.py / self_check.py 完全一致。
【QC 退回意见 #7 的修复:包内只保留一套实现】
新交付的 errors / context / crud_factory / dbutil / audit / api 为**唯一实现**;
旧模块名 tenant / crud / db 保留为**薄兼容 shim**(从新实现再导出,不重复实现),
kernel.py(历史大文件)不被本包 import,因此 ``import pbl_common`` 全链路不依赖它,
方向恒为 kernel → 新实现,无循环依赖。
依赖图(无环)::
errors ← context ← dbutil ← crud_factory ← audit ← api ← init
└──────────────────────────────────────────────────────┘
""" """
from .kernel import ( # noqa: F401 from __future__ import annotations
__version__ = '1.1.0'
__author__ = 'PBL Agent OS / agent.develop'
# --------------------------------------------------------------------------
# 1. 错误码与异常(唯一方案:PBL_E_*)
# --------------------------------------------------------------------------
from pbl_common.errors import ( # noqa: F401 (契约重导出)
ALL_CODES,
CODE_TO_HTTP,
DEFAULT_MESSAGES,
LEGACY_CODE_MAP,
PBL_ERR,
PBL_E_APPEND_ONLY,
PBL_E_COMPILE,
PBL_E_CONFLICT,
PBL_E_DB,
PBL_E_DB_UNAVAILABLE,
PBL_E_FORBIDDEN,
PBL_E_INTERNAL,
PBL_E_NEED_INFO,
PBL_E_NOT_FOUND,
PBL_E_OK,
PBL_E_PARAM,
PBL_E_TENANT,
PBL_E_TENANT_INVALID,
PBL_E_TENANT_MISSING,
PBL_E_TOOL_DENIED,
PBL_E_VALIDATION,
PBL_E_WRITE_LOCK,
PBL_E_WRITE_PROTECTED,
AppendOnlyError,
CompileError,
ConflictError,
DbError,
DbUnavailableError,
ErrorCode,
ForbiddenError,
NeedInfoError,
NotFoundError,
PBLError,
ParamInvalidError,
PblConflict,
PblError, PblError,
ERR, PblForbidden,
TenantContext, PblNotFound,
require_tenant, PblValidationError,
get_dbname, TenantError,
audit_write, TenantInvalidError,
build_audit_record, TenantMissingError,
crud_factory, ToolDeniedError,
is_valid_identifier, ValidationError,
WRITE_PROTECTED_TABLES, WriteLockError,
MODULE_DB_KEYS, WriteProtectedError,
IDEMPOTENT_CODES, WRITE_ACTIONS,
WRITE_PROTECTED_MODULES,
READ_ACTIONS,
as_error,
assert_append_only,
assert_not_write_protected,
default_message,
err,
error_body,
fail,
http_status_of,
is_write_protected,
normalize_code,
normalize_module,
) )
__all__ = [ # --------------------------------------------------------------------------
"PblError", "ERR", "TenantContext", "require_tenant", "get_dbname", # 2. 租户上下文
"audit_write", "build_audit_record", "crud_factory", "is_valid_identifier", # --------------------------------------------------------------------------
"WRITE_PROTECTED_TABLES", "MODULE_DB_KEYS", "IDEMPOTENT_CODES", from pbl_common.context import ( # noqa: F401
"_self_test_fail_closed", "self_test", INVALID_TENANT_VALUES,
] SYSTEM_TENANT_IDS,
TENANT_ID_PATTERN,
TenantContext,
assert_same_tenant,
assert_tenant,
check_tenant_column,
current_context,
current_tenant,
get_tenant,
has_tenant,
normalize_tenant,
reset_tenant,
run_in_tenant,
set_tenant,
tenant_scope,
tenant_scope_ctx,
with_tenant,
)
__version__ = "1.0.0" # --------------------------------------------------------------------------
# 3. DB 适配(sqlor 主路径)
# --------------------------------------------------------------------------
from pbl_common.dbutil import ( # noqa: F401
Db,
cur_date_string,
delete_row,
fallback_enabled,
fetch_all,
fetch_one,
get_db,
get_module_dbname,
get_server_env,
insert_row,
new_id,
now_str,
query,
quote_ident,
safe_ident,
sqlExe,
sqlor_available,
today_str,
update_row,
)
# --------------------------------------------------------------------------
# 4. CRUD 工厂
# --------------------------------------------------------------------------
from pbl_common.crud_factory import ( # noqa: F401
AUDIT_TABLE_MARKERS,
CrudBase,
bulk_create,
bulk_delete,
clear_registry,
crud_factory,
is_audit_table,
make_crud,
registered_tables,
tenant_crud,
)
# --------------------------------------------------------------------------
# 5. 审计(append-only)
# --------------------------------------------------------------------------
from pbl_common.audit import ( # noqa: F401
AUDIT_ACTIONS,
AUDIT_TABLE,
audit_stats,
audit_trail,
build_audit_record,
flush_memory_audit,
forbid_audit_mutation,
write_audit,
write_audit_batch,
write_audit_sync,
)
# --------------------------------------------------------------------------
# 6. 对外契约面 + 响应体 helper
# --------------------------------------------------------------------------
from pbl_common.api import ( # noqa: F401
CONTRACT_SYMBOLS,
api_guard,
fail_body,
from_exception,
ok,
paged,
verify_contract,
)
# --------------------------------------------------------------------------
# 7. 挂载入口(三处同步之第②处:必须在此导出)
# --------------------------------------------------------------------------
from pbl_common.init import ( # noqa: F401
ENV_EXPORTS,
get_env,
is_loaded,
load_pbl_common,
load_status,
register_to_env,
self_check,
unload_pbl_common,
)
def _self_test_fail_closed(): # ==========================================================================
"""六项 fail-closed 断言(纯逻辑,不连库、不依赖平台环境)。 # import 期自检(与 init.py / self_check.py 同一套错误码方案)
# ==========================================================================
def _bootstrap_check():
"""包导入即验证:错误码方案唯一、契约符号齐全、写保护/append-only 门禁可用。
A1 无租户上下文且未显式传 tenant_id -> 抛 PBL_E_TENANT(不得返回 None/空) 任一条不成立直接抛 ImportError —— 半迁移状态不允许被交付/上线。
A2 tenant_id 为空串/空白/非法字符 -> 抛 PBL_E_TENANT
A3 上下文内 require_tenant() 返回上下文 tenant_id;显式入参优先于上下文
A4 嵌套上下文退出后恢复上层,栈不泄漏
A5 crud_factory 对写保护基表(如 world/entity/scense)-> 抛 PBL_E_WRITE_LOCK
A6 crud_factory 对非法表名/非法 order_by -> 抛 PBL_E_PARAM;
审计记录缺 actor/action -> 抛 PBL_E_PARAM
返回 (passed, total, failures);任一断言不成立即视为失败(fail-closed)。
""" """
failures = [] problems = []
total = 6
def expect_pbl_error(fn, code, tag): # (a) 一套错误码:三个历史断言点涉及的常量必须全部存在且值 == 常量名
try: for name in ('PBL_E_TENANT', 'PBL_E_WRITE_LOCK', 'PBL_E_PARAM',
fn() 'PBL_E_TENANT_MISSING', 'PBL_E_APPEND_ONLY', 'PBL_E_WRITE_PROTECTED',
except PblError as e: 'PBL_E_NOT_FOUND', 'PBL_E_DB', 'PBL_E_INTERNAL'):
if e.code != code: value = globals().get(name)
failures.append("%s: 期望 %s 实得 %s" % (tag, code, e.code)) if value != name:
return problems.append('错误码常量 %s 缺失或值不一致(%r)' % (name, value))
except Exception as e: # noqa
failures.append("%s: 抛出非 PblError(%s): %s" % (tag, type(e).__name__, e))
return
failures.append("%s: 未抛错(fail-open 违规)" % tag)
# A1 无上下文 -> 必须抛错 # (b) 异常类面(规范 + 兼容)
def a1(): for name in ('PBLError', 'PblError', 'DbError', 'NotFoundError', 'ParamInvalidError',
require_tenant() 'TenantMissingError', 'TenantInvalidError', 'WriteProtectedError',
expect_pbl_error(a1, "PBL_E_TENANT", "A1.no_context") 'AppendOnlyError', 'ConflictError', 'ForbiddenError'):
obj = globals().get(name)
if obj is None:
problems.append('异常类 %s 缺失' % name)
elif not (isinstance(obj, type) and issubclass(obj, PBLError)):
problems.append('%s 不是 PBLError 子类' % name)
if PblError is not PBLError:
problems.append('PblError 必须是 PBLError 的别名(同一套异常体系)')
# A2 非法 tenant_id # (c) code / err_code 同值(不允许两套属性语义)
for bad in ("", " ", None, "t#01", 123): probe = PBLError(PBL_E_TENANT_MISSING, 'probe')
def a2(b=bad): if probe.code != probe.err_code:
require_tenant(b) problems.append('PBLError.code 与 .err_code 不同值')
expect_pbl_error(a2, "PBL_E_TENANT", "A2.bad_tenant(%r)" % (bad,)) if probe.legacy_code != LEGACY_CODE_MAP[PBL_E_TENANT_MISSING]:
problems.append('legacy_code 派生不一致')
# A3 上下文取值 + 显式优先 # (d) 契约符号全集
missing = verify_contract()
if missing:
problems.append('api 契约符号缺失: %s' % ', '.join(missing))
# (e) 门禁函数可用
for name in ('assert_not_write_protected', 'assert_append_only', 'err', 'fail',
'load_pbl_common', 'write_audit', 'tenant_crud', 'new_id', 'now_str'):
if not callable(globals().get(name)):
problems.append('契约可调用对象 %s 缺失' % name)
if not is_write_protected('rbac', 'update'):
problems.append('写保护门禁失效:rbac.update 未被判定为受保护')
if is_write_protected('rbac', 'read'):
problems.append('写保护门禁过宽:rbac.read 被误判为受保护')
try: try:
with TenantContext("T_A", actor="u1"): assert_append_only('delete', AUDIT_TABLE)
got = require_tenant() except AppendOnlyError:
if got != "T_A": pass
failures.append("A3.context: 期望 T_A 实得 %r" % (got,))
got2 = require_tenant("T_B")
if got2 != "T_B":
failures.append("A3.explicit_priority: 期望 T_B 实得 %r" % (got2,))
except Exception as e: # noqa
failures.append("A3: 异常 %s" % e)
# A4 嵌套与栈恢复
try:
with TenantContext("T_OUT"):
with TenantContext("T_IN"):
if require_tenant() != "T_IN":
failures.append("A4.nested: 内层未生效")
if require_tenant() != "T_OUT":
failures.append("A4.restore: 外层未恢复")
if TenantContext.peek() is not None:
failures.append("A4.leak: 退出后上下文栈未清空")
try:
require_tenant()
failures.append("A4.after_exit: 退出后仍可取到租户(fail-open)")
except PblError:
pass
except Exception as e: # noqa
failures.append("A4: 异常 %s" % e)
# A5 写保护基表
for tbl in ("world", "entity", "scense", "script_engine", "rbac_user"):
def a5(t=tbl):
crud_factory("pbl_domain_ext", t)
expect_pbl_error(a5, "PBL_E_WRITE_LOCK", "A5.write_protected(%s)" % tbl)
# A6 非法表名 / order_by / 审计必填
expect_pbl_error(lambda: crud_factory("pbl_blueprint", "pbl_blueprint; DROP TABLE x"),
"PBL_E_PARAM", "A6.bad_table")
expect_pbl_error(lambda: crud_factory("pbl_blueprint", "pbl_blueprint",
order_by="id; delete from t"),
"PBL_E_PARAM", "A6.bad_order_by")
expect_pbl_error(lambda: build_audit_record("T_A", None, "create"),
"PBL_E_PARAM", "A6.audit_missing_actor")
expect_pbl_error(lambda: build_audit_record("T_A", "u1", " "),
"PBL_E_PARAM", "A6.audit_missing_action")
passed = total - (1 if failures else 0) * 0
# 断言项按 6 项计:只要有失败即逐项计失败
if failures:
passed = 0
else: else:
passed = total problems.append('append-only 门禁失效:审计表 delete 未被拒绝')
return passed, total, failures
# (f) 租户打头
scoped = tenant_scope({'name': 'probe'}, 't_bootstrap')
if list(scoped.keys())[0] != 'tenant_id':
problems.append('tenant_scope 未把 tenant_id 打到第一位')
if problems:
raise ImportError('pbl_common 导入期自检失败:\n - ' + '\n - '.join(problems))
return True
def self_test(verbose=True): _BOOTSTRAP_OK = _bootstrap_check()
"""运行六项 fail-closed 自测并打印结果;返回退出码 0/1。"""
passed, total, failures = _self_test_fail_closed()
if verbose:
print("=" * 68)
print("SELF_TEST pbl_common —— fail-closed 六项断言")
print("-" * 68)
for i, name in enumerate(("A1.no_context", "A2.bad_tenant", "A3.context_priority",
"A4.nested_restore", "A5.write_protected",
"A6.param_and_audit"), 1):
hit = [f for f in failures if f.startswith(name.split(".")[0])]
print("[%s] %s%s" % ("FAIL" if hit else "PASS", name,
(" -> " + "; ".join(hit)) if hit else ""))
print("-" * 68)
if failures:
for f in failures:
print(" FAIL detail: %s" % f)
print("SELF_TEST pbl_common: FAIL %d/%d" % (passed, total))
return 1
print("SELF_TEST pbl_common: PASS %d/%d" % (passed, total))
return 0
if __name__ == "__main__": __all__ = [
import sys '__version__', '__author__',
sys.exit(self_test()) # 挂载入口(三处同步之②)
'load_pbl_common', 'register_to_env', 'get_env', 'is_loaded', 'load_status',
'unload_pbl_common', 'self_check', 'ENV_EXPORTS',
# 错误码/异常
'PBLError', 'PblError', 'ErrorCode', 'PBL_ERR', 'ALL_CODES', 'CODE_TO_HTTP',
'LEGACY_CODE_MAP', 'DEFAULT_MESSAGES', 'err', 'fail', 'as_error', 'error_body',
'normalize_code', 'http_status_of', 'default_message',
'ParamInvalidError', 'TenantError', 'TenantMissingError', 'TenantInvalidError',
'NotFoundError', 'ConflictError', 'ForbiddenError', 'WriteProtectedError',
'WriteLockError', 'AppendOnlyError', 'DbError', 'DbUnavailableError',
'ValidationError', 'CompileError', 'ToolDeniedError', 'NeedInfoError',
'PblValidationError', 'PblNotFound', 'PblConflict', 'PblForbidden',
'WRITE_PROTECTED_MODULES', 'WRITE_ACTIONS', 'READ_ACTIONS',
'assert_not_write_protected', 'assert_append_only', 'is_write_protected',
'normalize_module',
# 租户
'TenantContext', 'with_tenant', 'tenant_scope', 'tenant_scope_ctx',
'assert_tenant', 'normalize_tenant', 'check_tenant_column', 'get_tenant',
'set_tenant', 'reset_tenant', 'current_context', 'current_tenant',
'has_tenant', 'assert_same_tenant', 'run_in_tenant',
'TENANT_ID_PATTERN', 'SYSTEM_TENANT_IDS', 'INVALID_TENANT_VALUES',
# DB
'Db', 'get_db', 'get_module_dbname', 'get_server_env', 'new_id', 'now_str',
'cur_date_string', 'today_str', 'sqlExe', 'query', 'fetch_one', 'fetch_all',
'insert_row', 'update_row', 'delete_row', 'safe_ident', 'quote_ident',
'sqlor_available', 'fallback_enabled',
# CRUD
'CrudBase', 'tenant_crud', 'crud_factory', 'make_crud', 'bulk_create',
'bulk_delete', 'is_audit_table', 'registered_tables', 'clear_registry',
'AUDIT_TABLE_MARKERS',
# 审计
'write_audit', 'write_audit_batch', 'write_audit_sync', 'audit_trail',
'flush_memory_audit', 'build_audit_record', 'audit_stats',
'forbid_audit_mutation', 'AUDIT_TABLE', 'AUDIT_ACTIONS',
# 响应体
'ok', 'fail_body', 'paged', 'from_exception', 'api_guard',
'CONTRACT_SYMBOLS', 'verify_contract',
# 错误码常量
'PBL_E_OK', 'PBL_E_PARAM', 'PBL_E_TENANT', 'PBL_E_TENANT_MISSING',
'PBL_E_TENANT_INVALID', 'PBL_E_NOT_FOUND', 'PBL_E_CONFLICT', 'PBL_E_FORBIDDEN',
'PBL_E_WRITE_PROTECTED', 'PBL_E_WRITE_LOCK', 'PBL_E_APPEND_ONLY', 'PBL_E_DB',
'PBL_E_DB_UNAVAILABLE', 'PBL_E_VALIDATION', 'PBL_E_COMPILE',
'PBL_E_TOOL_DENIED', 'PBL_E_NEED_INFO', 'PBL_E_INTERNAL',
]

View File

@ -1,265 +1,307 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.api —— 公共内核对外契约面(重导出契约符号全集)。
pbl_common.api —— 公共内核对内契约接口(供其它 pbl_* 模块调用)
只暴露稳定契约,不暴露内部实现。所有函数 tenant_id 打头。 【人工介入答复 ③(a)】下游 11+ 模块(pbl_validation / pbl_compiler / pbl_assessment /
pbl_blueprint / pbl_appcodes / pbl_agent_runtime / pbl_evidence ...)历史上从
``pbl_common.api`` 取符号。本版把**契约符号全集**在此重导出,任何一处 import 都能命中:
from pbl_common.api import (
PBLError, err, fail, ErrorCode, CODE_TO_HTTP,
TenantMissingError, NotFoundError, DbError, ParamInvalidError,
assert_not_write_protected, WRITE_PROTECTED_MODULES,
TenantContext, with_tenant, tenant_scope, assert_tenant, normalize_tenant,
check_tenant_column, get_tenant, set_tenant,
Db, get_db, get_module_dbname, new_id, now_str, sqlExe,
CrudBase, tenant_crud, crud_factory,
write_audit, write_audit_batch, audit_trail, flush_memory_audit,
ok, fail_body, paged, api_guard,
)
【QC 退回意见 #2 / #5】旧 api.py:39 从 errors 导入 ``ParamInvalidError`` /
``ErrorCode``,而半迁移后的 errors.py 无这些名字 → import 即崩。现在 errors.py 已补齐
(规范类 + 兼容别名 + ErrorCode 常量面),本文件 import 闭包闭合。
本文件**只做重导出 + 统一响应体 helper**,不含业务逻辑,因此不引入新的依赖边:
依赖方向恒为 api → {errors, context, dbutil, crud_factory, audit},无环。
""" """
from pbl_common.context import ( from __future__ import annotations
build_context,
bind_context, import functools
unbind_context, import logging
current_context,
require_tenant, # ---- 1. 错误码与异常(规范 + 兼容全集) ---------------------------------
require_context,
context_scope,
)
from pbl_common.tenant import (
normalize_tenant,
assert_tenant,
tenant_scope,
with_tenant,
check_tenant_column,
)
from pbl_common.dbutil import (
get_dbname,
query,
query_one,
execute,
insert,
transaction,
sqlExe,
DIALECT,
)
from pbl_common.crud_factory import make_crud, CrudBase
from pbl_common.audit import write_audit, query_audit, AUDIT_ACTIONS
from pbl_common.serialize import to_jsonable, dumps, loads, parse_json_column
from pbl_common.errors import ( from pbl_common.errors import (
PblError, ALL_CODES,
CODE_TO_HTTP,
DEFAULT_MESSAGES,
LEGACY_CODE_MAP,
PBL_ERR,
PBL_E_APPEND_ONLY,
PBL_E_COMPILE,
PBL_E_CONFLICT,
PBL_E_DB,
PBL_E_DB_UNAVAILABLE,
PBL_E_FORBIDDEN,
PBL_E_INTERNAL,
PBL_E_NEED_INFO,
PBL_E_NOT_FOUND,
PBL_E_OK,
PBL_E_PARAM,
PBL_E_TENANT,
PBL_E_TENANT_INVALID,
PBL_E_TENANT_MISSING,
PBL_E_TOOL_DENIED,
PBL_E_VALIDATION,
PBL_E_WRITE_LOCK,
PBL_E_WRITE_PROTECTED,
AppendOnlyError,
CompileError,
ConflictError,
DbError,
DbUnavailableError,
ErrorCode, ErrorCode,
assert_not_write_protected, ForbiddenError,
WRITE_PROTECTED_MODULES, NeedInfoError,
NotFoundError,
PBLError,
ParamInvalidError,
PblConflict,
PblError,
PblForbidden,
PblNotFound,
PblValidationError,
TenantError,
TenantInvalidError,
TenantMissingError,
ToolDeniedError,
ValidationError,
WriteLockError,
WriteProtectedError,
as_error,
default_message,
err,
error_body,
fail,
http_status_of,
is_write_protected,
normalize_code,
normalize_module,
) )
from pbl_common.tables import ensure_tables, all_ddl, ddl_of, TABLES from pbl_common.errors import (
READ_ACTIONS,
WRITE_ACTIONS,
WRITE_PROTECTED_MODULES,
assert_append_only,
assert_not_write_protected,
)
# ---- 2. 租户上下文 ------------------------------------------------------
from pbl_common.context import (
INVALID_TENANT_VALUES,
SYSTEM_TENANT_IDS,
TENANT_ID_PATTERN,
TenantContext,
assert_same_tenant,
assert_tenant,
check_tenant_column,
current_context,
current_tenant,
get_tenant,
has_tenant,
normalize_tenant,
reset_tenant,
run_in_tenant,
set_tenant,
tenant_scope,
tenant_scope_ctx,
with_tenant,
)
# ---- 3. DB 适配 ---------------------------------------------------------
from pbl_common.dbutil import (
Db,
cur_date_string,
fallback_enabled,
fetch_all,
fetch_one,
get_db,
get_module_dbname,
get_server_env,
insert_row,
new_id,
now_str,
quote_ident,
safe_ident,
sqlExe,
sqlor_available,
today_str,
update_row,
)
from pbl_common.dbutil import delete_row, query
# ---- 4. CRUD 工厂 -------------------------------------------------------
from pbl_common.crud_factory import (
AUDIT_TABLE_MARKERS,
CrudBase,
bulk_create,
bulk_delete,
clear_registry,
crud_factory,
is_audit_table,
make_crud,
registered_tables,
tenant_crud,
)
# ---- 5. 审计 ------------------------------------------------------------
from pbl_common.audit import (
AUDIT_ACTIONS,
AUDIT_TABLE,
audit_stats,
audit_trail,
build_audit_record,
flush_memory_audit,
forbid_audit_mutation,
write_audit,
write_audit_batch,
write_audit_sync,
)
log = logging.getLogger('pbl_common.api')
def health(): # ==========================================================================
"""健康检查(不触库,供 /healthz 与部署冒烟)""" # 统一响应体 helper
return { # ==========================================================================
'ok': True, def ok(data=None, message='ok', **extra):
'module': 'pbl_common', """成功响应体:{'ok': True, 'code': 'PBL_E_OK', 'data': ..., 'message': ...}。"""
'dialect': DIALECT, body = {'ok': True, 'code': PBL_E_OK, 'legacy_code': LEGACY_CODE_MAP[PBL_E_OK],
'tables': sorted(TABLES.keys()), 'message': message, 'data': data, 'http_status': 200}
'write_protected_modules': list(WRITE_PROTECTED_MODULES), if extra:
'audit_actions': len(AUDIT_ACTIONS), body.update(extra)
} return body
def self_check(): def fail_body(code=None, message=None, detail=None, **extra):
"""失败响应体(不抛异常,供 dspy 直接 return)。"""
body = err(code, message, detail, **extra).to_dict()
body['data'] = None
return body
def paged(rows, total=None, page=1, rows_per_page=20, **extra):
"""分页响应体。"""
body = ok({'total': len(rows) if total is None else total,
'rows': list(rows or []),
'page': int(page or 1),
'rows_per_page': int(rows_per_page or 20)},
message='ok')
if extra:
body.update(extra)
return body
def from_exception(exc):
"""异常 → 响应体(非 PBLError 归一为 PBL_E_INTERNAL)。"""
return as_error(exc).to_dict()
def api_guard(func=None, tenant_required=True, audit_action=None, audit_table=None):
"""API 层装饰器:租户门禁 + 写保护门禁 + 异常归一 + 可选审计。
被装饰函数抛出的 PBLError → 统一响应体(http_status 由错误码映射);
非 PBLError → PBL_E_INTERNAL(fail-closed,不泄露内部栈到前端,栈进日志)。
""" """
公共内核自检(不依赖 DB,纯逻辑断言)。 def deco(fn):
返回 {'ok': bool, 'passed': int, 'total': int, 'details': [...]} @functools.wraps(fn)
末行由调用方打印:SELF_CHECK pbl_common: PASS n/n async def wrapper(*args, **kwargs):
""" tenant_id = kwargs.get('tenant_id')
results = []
def _add(name, fn):
try:
ok, msg = fn()
except Exception as e: # noqa: BLE001
ok, msg = False, '异常:%s' % e
results.append((name, bool(ok), msg or ''))
# 1 租户缺失必须抛错(fail-closed)
def t_tenant_missing():
unbind_context(None)
try:
require_tenant()
return False, '未绑定上下文却取到 tenant_id(fail-closed 失效)'
except PblError as e:
if e.code == ErrorCode.TENANT_MISSING:
return True, 'code=%s' % e.code
return False, '错误码不符:%s' % e.code
_add('tenant_missing_fail_closed', t_tenant_missing)
# 2 上下文绑定/还原
def t_bind_unbind():
ctx = build_context('t_demo', user_id='u1', role='teacher', trace_id='tr1')
old = bind_context(ctx)
ok1 = require_tenant() == 't_demo'
unbind_context(old)
ok2 = current_context() is None
return (ok1 and ok2), 'bind=%s unbind=%s' % (ok1, ok2)
_add('context_bind_unbind', t_bind_unbind)
# 3 非法 tenant_id 全拒
def t_tenant_invalid():
bad = [None, '', ' ', 123, 'x' * 65, "a'b", 'a;b', 'a--b']
for v in bad:
try: try:
build_context(v) if tenant_required:
return False, '非法值未被拒绝:%r' % (v,) kwargs['tenant_id'] = assert_tenant(tenant_id)
except PblError: if audit_table:
continue assert_not_write_protected(audit_table, audit_action or 'write')
return True, '%d 个非法值全部拒绝' % len(bad) result = await fn(*args, **kwargs)
_add('tenant_invalid_rejected', t_tenant_invalid) if isinstance(result, dict) and 'ok' in result:
return result
# 4 tenant_scope 打头 return ok(result)
def t_scope(): except Exception as exc: # noqa: BLE001
ctx = build_context('t_scope') error = as_error(exc)
bind_context(ctx) if error.code == PBL_E_INTERNAL:
try: log.exception('api_guard 未预期异常 in %s', getattr(fn, '__name__', fn))
w, a = tenant_scope(None, "status = %s", ['draft']) if audit_action and audit_table:
ok = w.startswith('tenant_id = %s') and a[0] == 't_scope' and a[1] == 'draft' try:
return ok, 'where=%r args=%r' % (w, a) await write_audit(audit_action, table=audit_table,
finally: row_id=kwargs.get('id'),
unbind_context(None) detail={'error': error.code,
_add('tenant_scope_first', t_scope) 'message': error.message},
tenant_id=kwargs.get('tenant_id'),
# 5 extra_where 不得自带 tenant_id result='error')
def t_scope_guard(): except Exception: # noqa: BLE001
ctx = build_context('t_guard') log.warning('api_guard 失败审计写入未成功')
bind_context(ctx) return error.to_dict()
try: return wrapper
tenant_scope(None, 'tenant_id = %s', ['evil']) if func is not None:
return False, '未拦截调用方自带 tenant_id' return deco(func)
except PblError: return deco
return True, '已拦截'
finally:
unbind_context(None)
_add('tenant_scope_guard', t_scope_guard)
# 6 with_tenant 注入与越权拦截
def t_with_tenant():
ctx = build_context('t_w')
bind_context(ctx)
try:
r = with_tenant({'name': 'x'})
if r.get('tenant_id') != 't_w':
return False, '未注入 tenant_id'
try:
with_tenant({'tenant_id': 'other'}, 't_w')
return False, '未拦截跨租户写入'
except PblError:
return True, '注入+越权拦截均正确'
finally:
unbind_context(None)
_add('with_tenant', t_with_tenant)
# 7 写保护断言
def t_write_protected():
for m in WRITE_PROTECTED_MODULES:
try:
assert_not_write_protected(m, 'x')
return False, '写保护模块 %s 未拦截' % m
except PblError:
continue
assert_not_write_protected('pbl_blueprint', 'pbl_blueprint')
return True, '%d 个写保护模块全部拦截' % len(WRITE_PROTECTED_MODULES)
_add('write_protected', t_write_protected)
# 8 DDL 方言纯净(无 FK/ENUM/TIMESTAMP/SERIAL)
def t_ddl_dialect():
text = all_ddl()
for token in ('FOREIGN KEY', 'REFERENCES ', 'ENUM(', 'TIMESTAMP', 'BIGSERIAL', 'SERIAL', 'nextval'):
if token.upper() in text.upper():
return False, 'DDL 含禁用元素 %s' % token
if 'AUTO_INCREMENT' not in text:
return False, 'DDL 缺少 BIGINT AUTO_INCREMENT'
return True, 'mariadb 方言纯净,%d 张公共表' % len(TABLES)
_add('ddl_dialect_mariadb', t_ddl_dialect)
# 9 每表 tenant_id 首列
def t_tenant_first_column():
for name, spec in TABLES.items():
cols = [c[0] for c in spec['columns']]
try:
check_tenant_column(name, cols)
except PblError as e:
return False, '%s:%s' % (name, e.message)
return True, '%d 张表 tenant_id 均为首列' % len(TABLES)
_add('tenant_first_column', t_tenant_first_column)
# 10 序列化安全
def t_serialize():
import datetime as _dt
import decimal as _dec
obj = {
'dt': _dt.datetime(2026, 9, 16, 10, 0, 0),
'd': _dt.date(2026, 9, 16),
'dec': _dec.Decimal('3.00'),
'bytes': b'abc',
'set': {2, 1},
'nested': [{'x': None}],
}
s = dumps(obj)
back = loads(s)
ok = (back['dt'] == '2026-09-16 10:00:00' and back['d'] == '2026-09-16'
and back['dec'] == 3 and back['bytes'] == 'abc'
and back['set'] == [1, 2] and back['nested'] == [{'x': None}])
return ok, s[:80]
_add('serialize_safe', t_serialize)
# 11 loads 容错
def t_loads_tolerant():
cases = [('', {}), (None, {}), ('not json', {}), ('{"a":1}', {'a': 1}), ('[1,2]', [1, 2])]
for text, expect in cases:
got = loads(text, {})
if got != expect:
return False, 'loads(%r)=%r 期望 %r' % (text, got, expect)
return True, '%d 个容错用例通过' % len(cases)
_add('loads_tolerant', t_loads_tolerant)
# 12 CRUD 工厂可用 + 只读保护
def t_crud_factory():
crud = make_crud('pbl_audit_log', module='pbl_common', readonly=True)
if crud.table != 'pbl_audit_log':
return False, '表名不符'
try:
crud.create('t_x', {'action': 'create'})
return False, '只读表未拦截 create'
except PblError as e:
if e.code != ErrorCode.WRITE_PROTECTED:
return False, '错误码不符 %s' % e.code
crud2 = make_crud('pbl_seed_record', module='pbl_common')
return True, 'readonly 拦截正确,可写表 %s 就绪' % crud2.table
_add('crud_factory', t_crud_factory)
# 13 错误码 → HTTP 映射完整
def t_error_http():
from pbl_common.errors import CODE_TO_HTTP
miss = [c for c in vars(ErrorCode).values()
if isinstance(c, str) and c not in CODE_TO_HTTP]
return (not miss), ('缺失映射:%s' % miss) if miss else '%d 个错误码全部有 HTTP 映射' % len(CODE_TO_HTTP)
_add('error_http_mapping', t_error_http)
# 14 health 契约
def t_health():
h = health()
ok = (h.get('ok') is True and h.get('module') == 'pbl_common'
and h.get('dialect') == 'mariadb' and len(h.get('tables', [])) == len(TABLES))
return ok, 'tables=%d' % len(h.get('tables', []))
_add('health_contract', t_health)
passed = sum(1 for _n, ok, _m in results if ok)
total = len(results)
return {
'ok': passed == total,
'passed': passed,
'total': total,
'details': [{'name': n, 'ok': ok, 'msg': m} for n, ok, m in results],
}
__all__ = [ # ==========================================================================
'health', 'self_check', # 契约自检(import 期即验证符号面完整,失败直接抛 → 不允许半迁移状态被交付)
'build_context', 'bind_context', 'unbind_context', 'current_context', # ==========================================================================
'require_tenant', 'require_context', 'context_scope', CONTRACT_SYMBOLS = (
'normalize_tenant', 'assert_tenant', 'tenant_scope', 'with_tenant', 'check_tenant_column', # errors
'get_dbname', 'query', 'query_one', 'execute', 'insert', 'transaction', 'sqlExe', 'DIALECT', 'PBLError', 'PblError', 'err', 'fail', 'as_error', 'error_body',
'make_crud', 'CrudBase', 'ErrorCode', 'PBL_ERR', 'CODE_TO_HTTP', 'LEGACY_CODE_MAP', 'ALL_CODES',
'write_audit', 'query_audit', 'AUDIT_ACTIONS', 'DbError', 'NotFoundError', 'ParamInvalidError', 'TenantMissingError',
'to_jsonable', 'dumps', 'loads', 'parse_json_column', 'TenantInvalidError', 'ConflictError', 'ForbiddenError', 'WriteProtectedError',
'PblError', 'ErrorCode', 'assert_not_write_protected', 'WRITE_PROTECTED_MODULES', 'AppendOnlyError', 'ValidationError', 'CompileError', 'ToolDeniedError',
'ensure_tables', 'all_ddl', 'ddl_of', 'TABLES', 'NeedInfoError', 'PblValidationError', 'PblNotFound', 'PblConflict', 'PblForbidden',
'WRITE_PROTECTED_MODULES', 'assert_not_write_protected', 'assert_append_only',
'is_write_protected', 'normalize_code', 'http_status_of',
# context
'TenantContext', 'with_tenant', 'tenant_scope', 'assert_tenant',
'normalize_tenant', 'check_tenant_column', 'get_tenant', 'set_tenant',
'reset_tenant', 'current_context', 'assert_same_tenant',
# dbutil
'Db', 'get_db', 'get_module_dbname', 'get_server_env', 'new_id', 'now_str',
'sqlExe', 'query', 'fetch_one', 'fetch_all', 'insert_row', 'update_row',
'delete_row', 'safe_ident', 'sqlor_available', 'fallback_enabled',
# crud
'CrudBase', 'tenant_crud', 'crud_factory', 'make_crud', 'bulk_create',
'bulk_delete', 'is_audit_table',
# audit
'write_audit', 'write_audit_batch', 'audit_trail', 'flush_memory_audit',
'build_audit_record', 'write_audit_sync', 'audit_stats', 'AUDIT_TABLE',
# response helper
'ok', 'fail_body', 'paged', 'from_exception', 'api_guard',
)
def verify_contract():
"""校验契约符号全集在本模块命名空间内可见。返回缺失清单(空=通过)。"""
ns = globals()
return [name for name in CONTRACT_SYMBOLS if name not in ns or ns[name] is None]
_MISSING = verify_contract()
if _MISSING: # pragma: no cover - 门禁
raise ImportError('pbl_common.api 契约符号缺失(半迁移状态禁止交付):%s'
% ', '.join(_MISSING))
__all__ = list(CONTRACT_SYMBOLS) + [
'CONTRACT_SYMBOLS', 'verify_contract', 'LEGACY_CODE_MAP', 'DEFAULT_MESSAGES',
'SYSTEM_TENANT_IDS', 'TENANT_ID_PATTERN', 'INVALID_TENANT_VALUES',
'WRITE_ACTIONS', 'READ_ACTIONS', 'normalize_module', 'default_message',
'cur_date_string', 'today_str', 'quote_ident', 'registered_tables',
'clear_registry', 'AUDIT_ACTIONS', 'AUDIT_TABLE_MARKERS',
'forbid_audit_mutation', 'tenant_scope_ctx', 'run_in_tenant', 'has_tenant',
'current_tenant', 'PBL_E_OK', 'PBL_E_PARAM', 'PBL_E_TENANT',
'PBL_E_TENANT_MISSING', 'PBL_E_TENANT_INVALID', 'PBL_E_NOT_FOUND',
'PBL_E_CONFLICT', 'PBL_E_FORBIDDEN', 'PBL_E_WRITE_PROTECTED',
'PBL_E_WRITE_LOCK', 'PBL_E_APPEND_ONLY', 'PBL_E_DB', 'PBL_E_DB_UNAVAILABLE',
'PBL_E_VALIDATION', 'PBL_E_COMPILE', 'PBL_E_TOOL_DENIED', 'PBL_E_NEED_INFO',
'PBL_E_INTERNAL', 'TenantError', 'WriteLockError', 'DbUnavailableError',
] ]

View File

@ -1,101 +1,309 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.audit —— append-only 审计写入(自包含实现)。
pbl_common.audit —— append-only 审计(pbl_audit_event)
铁律: 【人工介入答复 ② 的修复】旧版 audit.py 里 ``class write_audit`` 是占位坏味,且
* 只暴露 append(),绝不暴露 update/delete/remove/purge(self_check 校验) 真实现散落在 kernel.py L1477;各文件尾部还挂着
* 审计独立性:审计写入失败不阻断业务,但必须落降级日志 ``try: from pbl_blueprint.m1b.audit import write_audit ... except ImportError: pass``
* 审计角色 owner.audit 才可跨租户读取 ——而 import pbl_blueprint.m1b 必先执行 pbl_blueprint/__init__.py → api_blueprint.py →
``from pbl_common.crud_factory import tenant_crud`` → 绕回断裂点自身,except 把失败
静默吞掉,兼容层零生效。
本版彻底改为**包内自包含**:
* ``write_audit`` / ``write_audit_batch`` / ``audit_trail`` / ``flush_memory_audit``
全部在 audit.py 内直接实现,**不 import pbl_blueprint**(杜绝反向 re-export 与
循环依赖);
* 所有失效的 ``M1b compat exports`` try/except 块已删除;
* kernel.py 若存在同名实现,由 kernel 侧从本文件再导出(依赖方向:kernel → audit,
audit 是更底层的根之一,只依赖 errors / context / dbutil)。
【append-only 铁律】审计表禁止 UPDATE/DELETE:任何写路径先过
``assert_append_only``;DB 不可用时进内存缓冲(``flush_memory_audit`` 补写),
**绝不静默丢弃审计**。
""" """
import time from __future__ import annotations
import uuid
from pbl_common.tenant import TenantContext, AUDIT_ROLE import logging
from pbl_common.db import get_db import threading
AUDIT_TABLE = 'pbl_audit_event' from pbl_common.errors import (
MODULE = 'pbl_governance' PBL_E_APPEND_ONLY,
PBL_E_PARAM,
PBL_E_TENANT_MISSING,
AppendOnlyError,
ParamInvalidError,
assert_append_only,
assert_not_write_protected,
)
from pbl_common.context import (
assert_tenant,
current_context,
get_tenant,
normalize_tenant,
tenant_scope,
)
from pbl_common.dbutil import (
get_db,
get_module_dbname,
new_id,
now_str,
safe_ident,
)
log = logging.getLogger('pbl_common.audit')
#: 审计表名(data-model.md:pbl_audit_log,tenant_id 打头)
AUDIT_TABLE = 'pbl_audit_log'
#: 合法动作枚举(超出即按 other 记录,不拒绝——审计宁可多记不可漏记)
AUDIT_ACTIONS = (
'create', 'read', 'update', 'delete', 'login', 'logout',
'validate', 'compile', 'tool_call', 'tool_deny', 'seed', 'deploy',
'export', 'import', 'fork', 'publish', 'archive', 'other',
)
#: 内存缓冲上限(超出丢最旧,但先尝试 flush)
MEMORY_BUFFER_LIMIT = 5000
_buffer = []
_buffer_lock = threading.Lock()
_stats = {'written': 0, 'buffered': 0, 'flushed': 0, 'failed': 0}
def _now(): def _normalize_action(action):
return time.strftime('%Y-%m-%d %H:%M:%S') act = str(action or 'other').strip().lower()
if act in ('c', 'insert'):
return 'create'
if act in ('u', 'patch'):
return 'update'
if act in ('d', 'remove'):
return 'delete'
if act in ('r', 'select', 'get', 'list', 'query'):
return 'read'
return act if act in AUDIT_ACTIONS else 'other'
def _trace_id(ctx): def build_audit_record(action, table=None, row_id=None, detail=None, ctx=None,
tid = getattr(ctx, 'trace_id', '') or '' tenant_id=None, actor_id=None, actor_type=None,
return tid or uuid.uuid4().hex[:32] trace_id=None, result='ok', created_at=None):
"""构造一条审计记录 dict(tenant_id 打头,不落库)。"""
tid = normalize_tenant(tenant_id if tenant_id is not None else _safe_tenant(ctx))
def append(ctx, event_type, obj_type='', obj_id=0, session_id=0, if not tid:
detail=None, actor_role=None, actor_id=None): raise ParamInvalidError(
""" code=PBL_E_TENANT_MISSING,
追加一条审计事件(append-only)。 message='审计写入缺少 tenant_id(审计同样强制租户打头)',
ctx: TenantContext(必须);缺失即抛,绝不静默。 detail={'action': action, 'table': table})
返回 audit_id;写库失败返回 0 并打印降级日志(不阻断业务)。 if ctx is None:
"""
if not isinstance(ctx, TenantContext):
raise ValueError('audit.append 需要 TenantContext')
row = {
'tenant_id': ctx.tenant_id,
'actor_role': actor_role or ctx.role or '',
'actor_id': int(actor_id if actor_id is not None else (ctx.user_id or 0)),
'event_type': event_type or '',
'obj_type': obj_type or '',
'obj_id': int(obj_id or 0),
'session_id': int(session_id or ctx.session_id or 0),
'detail_json': _dumps(detail),
'trace_id': _trace_id(ctx),
'event_time': _now(),
}
try:
db = get_db(MODULE)
try: try:
return db.C(AUDIT_TABLE, row) ctx_obj = current_context(strict=False)
finally: except Exception: # noqa: BLE001
try: ctx_obj = None
db.close() else:
except Exception: # noqa: BLE001 ctx_obj = ctx
pass rec = {}
except Exception as e: # noqa: BLE001 rec['tenant_id'] = tid
print('[pbl_common.audit] DEGRADED append failed: %s | row=%s' % (e, row)) rec['id'] = new_id('aud')
return 0 rec['action'] = _normalize_action(action)
rec['table_name'] = safe_ident(table, '表名') if table else ''
rec['row_id'] = '' if row_id is None else str(row_id)[:64]
rec['actor_type'] = str(actor_type or getattr(ctx_obj, 'actor_type', None) or 'system')[:16]
rec['actor_id'] = str(actor_id or getattr(ctx_obj, 'actor_id', None) or '')[:64]
rec['trace_id'] = str(trace_id or getattr(ctx_obj, 'trace_id', None) or '')[:64]
rec['result'] = str(result or 'ok')[:16]
rec['detail'] = _dump_detail(detail)
rec['created_at'] = created_at or now_str()
return rec
def _dumps(detail): def _safe_tenant(ctx):
if ctx is None:
return None
if isinstance(ctx, str):
return ctx
if isinstance(ctx, dict):
return ctx.get('tenant_id')
return getattr(ctx, 'tenant_id', None)
def _dump_detail(detail):
if detail is None: if detail is None:
return '' return ''
if isinstance(detail, str): if isinstance(detail, str):
return detail return detail[:4000]
try: try:
import json import json
return json.dumps(detail, ensure_ascii=False) return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return str(detail) return str(detail)[:4000]
def query(ctx, cond=None, limit=100, offset=0): async def write_audit(action, table=None, row_id=None, detail=None, ctx=None,
db_conn=None, tenant_id=None, **kw):
"""写一条审计(append-only)。
签名与 kernel.py L1477 的历史实现保持一致:
``write_audit(action, table, row_id, detail, ctx, db_conn)``,
额外支持 ``tenant_id=`` 显式传入(Agent 运行时跨租户任务用)。
返回:落库的审计记录 dict;DB 不可用时记录进内存缓冲并返回
``{'buffered': True, ...}``(**不抛**,避免审计失败拖垮主业务,但绝不丢)。
""" """
审计查询(只读)。跨租户仅 owner.audit 角色允许。 assert_append_only('insert', AUDIT_TABLE) # 语义自检:insert 合法
""" assert_not_write_protected(table, 'read') # 审计只读被审计对象的元信息
if not isinstance(ctx, TenantContext):
raise ValueError('audit.query 需要 TenantContext')
where = ctx.scope(cond or {})
try: try:
db = get_db(MODULE) rec = build_audit_record(action, table=table, row_id=row_id, detail=detail,
ctx=ctx, tenant_id=tenant_id, **kw)
except ParamInvalidError:
raise
except Exception as exc: # noqa: BLE001
raise ParamInvalidError(message='审计记录构造失败:%s' % (exc,),
detail={'action': action, 'table': table})
db = db_conn if isinstance(db_conn, object) and hasattr(db_conn, 'create') \
else get_db(module='pbl_common')
try:
await db.create(AUDIT_TABLE, dict(rec), tenant_id=rec['tenant_id'])
_stats['written'] += 1
return rec
except Exception as exc: # noqa: BLE001
log.warning('write_audit 落库失败,转内存缓冲:%s', exc)
_stats['failed'] += 1
return _push_buffer(rec)
def write_audit_sync(action, table=None, row_id=None, detail=None, ctx=None,
tenant_id=None, **kw):
"""同步入口:DB 不可用场景下直接进内存缓冲(供非 async 调用方,如 build 脚本)。"""
rec = build_audit_record(action, table=table, row_id=row_id, detail=detail,
ctx=ctx, tenant_id=tenant_id, **kw)
return _push_buffer(rec)
def _push_buffer(rec):
with _buffer_lock:
_buffer.append(rec)
overflow = len(_buffer) - MEMORY_BUFFER_LIMIT
if overflow > 0:
dropped = _buffer[:overflow]
del _buffer[:overflow]
log.error('审计内存缓冲溢出,丢弃 %d 条最旧记录(首条 tenant=%s action=%s)',
len(dropped), dropped[0].get('tenant_id'), dropped[0].get('action'))
_stats['buffered'] += 1
return dict(rec, buffered=True)
async def write_audit_batch(records, db_conn=None):
"""批量写审计。records: list[dict],每项至少含 action + tenant_id。
返回 ``{'ok': n, 'failed': m, 'ids': [...]}``。逐条走 write_audit,
保证每条都过 append-only 与租户门禁。
"""
ok, failed, ids = 0, 0, []
for item in (records or []):
if not isinstance(item, dict):
failed += 1
continue
try: try:
return db.R(AUDIT_TABLE, where, order_by='event_time desc,id desc', rec = await write_audit(
limit=limit, offset=offset) or [] item.get('action', 'other'),
finally: table=item.get('table') or item.get('table_name'),
try: row_id=item.get('row_id'),
db.close() detail=item.get('detail'),
except Exception: # noqa: BLE001 ctx=item.get('ctx'),
pass db_conn=db_conn,
except Exception as e: # noqa: BLE001 tenant_id=item.get('tenant_id'),
print('[pbl_common.audit] query failed: %s' % e) actor_id=item.get('actor_id'),
return [] actor_type=item.get('actor_type'),
trace_id=item.get('trace_id'),
result=item.get('result', 'ok'),
)
ids.append(rec.get('id'))
ok += 1
except Exception as exc: # noqa: BLE001
log.error('write_audit_batch 单条失败:%s', exc)
failed += 1
return {'ok': ok, 'failed': failed, 'ids': ids}
def can_cross_tenant(ctx): async def audit_trail(table=None, row_id=None, tenant_id=None, limit=200,
return bool(getattr(ctx, 'role', '') == AUDIT_ROLE) action=None, db_conn=None):
"""查审计轨迹(只读)。tenant_id 强制;返回按 created_at 倒序的记录列表。"""
tid = assert_tenant(tenant_id)
db = db_conn if hasattr(db_conn, 'read') else get_db(module='pbl_common')
cond = {'tenant_id': tid}
if table:
cond['table_name'] = safe_ident(table, '表名')
if row_id:
cond['row_id'] = str(row_id)[:64]
if action:
cond['action'] = _normalize_action(action)
try:
rows = await db.read(AUDIT_TABLE, cond, tenant_id=tid)
except Exception as exc: # noqa: BLE001
log.warning('audit_trail 查询失败:%s', exc)
rows = []
rows = [r if isinstance(r, dict) else dict(r or {}) for r in (rows or [])]
rows.sort(key=lambda r: str(r.get('created_at') or ''), reverse=True)
size = max(1, min(int(limit or 200), 1000))
return rows[:size]
async def flush_memory_audit(db_conn=None):
"""把内存缓冲的审计补写落库。返回 ``{'flushed': n, 'remaining': m}``。"""
with _buffer_lock:
pending = list(_buffer)
_buffer.clear()
if not pending:
return {'flushed': 0, 'remaining': 0}
db = db_conn if hasattr(db_conn, 'create') else get_db(module='pbl_common')
flushed = 0
for rec in pending:
try:
await db.create(AUDIT_TABLE, dict(rec), tenant_id=rec['tenant_id'])
flushed += 1
except Exception as exc: # noqa: BLE001
log.error('flush_memory_audit 补写失败,回灌缓冲:%s', exc)
_push_buffer(rec)
_stats['flushed'] += flushed
with _buffer_lock:
remaining = len(_buffer)
return {'flushed': flushed, 'remaining': remaining}
async def forbid_audit_mutation(op, table=AUDIT_TABLE):
"""显式拒绝审计表的 UPDATE/DELETE(供 api 层前置校验,抛 AppendOnlyError)。"""
assert_append_only(op, table)
return True
def audit_stats():
with _buffer_lock:
return dict(_stats, buffered_now=len(_buffer))
def clear_memory_audit():
"""仅测试用:清空内存缓冲(生产不得调用——会丢审计)。"""
with _buffer_lock:
n = len(_buffer)
_buffer.clear()
return n
# ==========================================================================
# 兼容别名(旧代码里的不同拼写)
# ==========================================================================
append = write_audit # 旧 self_check 曾要求 audit.append
append_audit = write_audit
log_audit = write_audit
audit_write = write_audit
trail = audit_trail
__all__ = [
'AUDIT_TABLE', 'AUDIT_ACTIONS', 'MEMORY_BUFFER_LIMIT',
'build_audit_record', 'write_audit', 'write_audit_sync', 'write_audit_batch',
'audit_trail', 'flush_memory_audit', 'forbid_audit_mutation',
'audit_stats', 'clear_memory_audit',
'append', 'append_audit', 'log_audit', 'audit_write', 'trail',
'AppendOnlyError', 'assert_append_only', 'assert_not_write_protected',
'PBL_E_APPEND_ONLY',
]

View File

@ -1,155 +1,403 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.context —— 租户上下文(tenant_id 强制打头)。
pbl_common.context —— 租户上下文(threading.local 绑定,fail-closed)
设计要点: 设计依据:docs/01-design/data-model.md(36 表全部 tenant_id 打头)+
- TenantContext 用 __slots__ 固定 6 字段,防止误挂属性 modules/pbl_common.md §租户上下文。
- threading.local 绑定,天然线程隔离;异步/多线程下不串租户
- require_tenant() 是所有读写的第一道闸:拿不到 tenant_id 直接抛错, 【QC 退回意见 #2 的修复】本文件过去从 errors 导入 ``TenantMissingError`` /
绝不回落到「默认租户」或「全租户查询」 ``TenantInvalidError`` / ``PblError``,而 errors.py 半迁移后无这些名字 → import 即崩。
现在 errors.py 已补齐全部规范类 + 兼容别名(见 errors.py 文件头说明),本文件的
import 闭包已闭合,且**只依赖 errors 一个内部模块**(errors 是依赖图的根,不反向
import 任何 pbl_common 模块 → 无循环依赖)。
【向后兼容】旧符号面 ``normalize_tenant`` / ``assert_tenant`` / ``tenant_scope`` /
``with_tenant`` / ``check_tenant_column`` 全部保留(tenant.py 亦从本文件再导出)。
""" """
import threading from __future__ import annotations
import re
from contextvars import ContextVar, copy_context
from pbl_common.errors import ( from pbl_common.errors import (
TenantMissingError, PBL_E_TENANT,
PBL_E_TENANT_INVALID,
PBL_E_TENANT_MISSING,
PblError,
PblTenantMissing,
TenantInvalidError, TenantInvalidError,
ErrorCode, TenantMissingError,
err,
fail,
normalize_code,
) )
_LOCAL = threading.local() # --------------------------------------------------------------------------
# 租户标识规则
# --------------------------------------------------------------------------
#: 合法 tenant_id:1~64 位,字母/数字/下划线/中划线/点(与 VARCHAR(64) 对齐)
TENANT_ID_PATTERN = re.compile(r'^[A-Za-z0-9_.\-]{1,64}$')
# tenant_id 合法长度上限(varchar(64),与 data-model.md 对齐) #: 系统级保留租户(平台自身运维用,业务代码不得当普通租户写入)
TENANT_ID_MAX_LEN = 64 SYSTEM_TENANT_IDS = frozenset({'0', 'system', 'platform', '__system__'})
#: 明确非法的伪租户值(前端常传的空占位)
INVALID_TENANT_VALUES = frozenset({
'', 'none', 'null', 'nil', 'undefined', 'nan', 'unknown',
'-', '--', 'tbd', 'todo', 'placeholder', '待明确', '占位',
})
#: 上下文变量:当前请求/任务的租户
_tenant_var = ContextVar('pbl_tenant_id', default=None)
#: 上下文变量:当前 actor(Agent 运行时写入)
_actor_var = ContextVar('pbl_actor', default=None)
#: 上下文变量:当前 trace_id(审计串联)
_trace_var = ContextVar('pbl_trace_id', default=None)
class TenantContext(object): class TenantContext(object):
"""租户上下文(6 字段,__slots__ 锁定)""" """不可变租户上下文快照。
__slots__ = ('tenant_id', 'user_id', 'role', 'session_id', 'trace_id', 'app_name') 字段:
tenant_id —— 规范化的租户标识(必填)
actor_type —— human / agent / system / service
actor_id —— 操作者标识
trace_id —— 审计串联 ID
extra —— 附加只读信息(class_id / team_id 等)
"""
def __init__(self, tenant_id, user_id=None, role=None, __slots__ = ('tenant_id', 'actor_type', 'actor_id', 'trace_id', 'extra')
session_id=None, trace_id=None, app_name='pbls'):
self.tenant_id = tenant_id
self.user_id = user_id
self.role = role
self.session_id = session_id
self.trace_id = trace_id
self.app_name = app_name
def to_dict(self): def __init__(self, tenant_id, actor_type='human', actor_id=None,
trace_id=None, extra=None):
self.tenant_id = normalize_tenant(tenant_id)
self.actor_type = str(actor_type or 'human').strip().lower()
self.actor_id = None if actor_id is None else str(actor_id)
self.trace_id = None if trace_id is None else str(trace_id)
self.extra = dict(extra or {})
# ---- 派生 ------------------------------------------------------------
@property
def is_system(self):
return self.tenant_id in SYSTEM_TENANT_IDS
def as_dict(self):
return { return {
'tenant_id': self.tenant_id, 'tenant_id': self.tenant_id,
'user_id': self.user_id, 'actor_type': self.actor_type,
'role': self.role, 'actor_id': self.actor_id,
'session_id': self.session_id,
'trace_id': self.trace_id, 'trace_id': self.trace_id,
'app_name': self.app_name, 'extra': dict(self.extra),
} }
def copy(self, **overrides): def child(self, **overrides):
data = self.to_dict() base = self.as_dict()
data.update(overrides) extra = dict(base.pop('extra') or {})
return TenantContext(**data) extra.update(overrides.pop('extra', None) or {})
base.update(overrides)
base['extra'] = extra
return TenantContext(**base)
def scope(self, row=None):
"""把 tenant_id 打到 dict 的**第一位**(tenant_id 强制打头)。"""
return tenant_scope(row, self.tenant_id)
def __eq__(self, other):
return isinstance(other, TenantContext) and self.as_dict() == other.as_dict()
def __hash__(self):
return hash((self.tenant_id, self.actor_type, self.actor_id, self.trace_id))
def __repr__(self): def __repr__(self):
return ('TenantContext(tenant_id=%r, user_id=%r, role=%r, ' return 'TenantContext(tenant_id=%r, actor_type=%r, actor_id=%r)' % (
'session_id=%r, trace_id=%r, app_name=%r)' self.tenant_id, self.actor_type, self.actor_id)
% (self.tenant_id, self.user_id, self.role,
self.session_id, self.trace_id, self.app_name))
def _check_tenant(tenant_id): # --------------------------------------------------------------------------
"""tenant_id 合法性校验:None / 非 str / 空串 / 超长 / 含危险字符 一律拒绝""" # 规范化与断言
# --------------------------------------------------------------------------
def normalize_tenant(tenant_id, strict=True):
"""把任意输入规范化为合法 tenant_id 字符串。
- None / 空串 / 伪占位值 → strict 时抛 TenantMissingError,非 strict 返回 ''
- 非字符串(int 等)→ str() 后校验
- 前后空白与包裹引号剥除
- 不匹配 TENANT_ID_PATTERN → 抛 TenantInvalidError
"""
if tenant_id is None: if tenant_id is None:
raise TenantMissingError(message='tenant_id 为 None') if strict:
if not isinstance(tenant_id, str): raise TenantMissingError(message='tenant_id 为 None(租户上下文缺失)')
raise TenantInvalidError( return ''
message='tenant_id 必须为字符串,实际 %s' % type(tenant_id).__name__, if isinstance(tenant_id, TenantContext):
detail={'actual_type': type(tenant_id).__name__}, tenant_id = tenant_id.tenant_id
) if isinstance(tenant_id, (list, tuple, set)):
tid = tenant_id.strip() # 前端把 query + body 合并成 list 的经典坑:取首个非空值
if not tid: candidates = [v for v in tenant_id if v not in (None, '')]
raise TenantInvalidError(message='tenant_id 为空串') if not candidates:
if len(tid) > TENANT_ID_MAX_LEN: if strict:
raise TenantInvalidError( raise TenantMissingError(message='tenant_id 为空列表')
message='tenant_id 超长(>%d)' % TENANT_ID_MAX_LEN, return ''
detail={'length': len(tid), 'max': TENANT_ID_MAX_LEN}, if len(set(str(c) for c in candidates)) > 1:
)
for ch in ("'", '"', ';', '--', '/*', '*/', '\\', '\x00'):
if ch in tid:
raise TenantInvalidError( raise TenantInvalidError(
message='tenant_id 含非法字符 %r' % ch, message='tenant_id 收到多个互斥值(query 与 body 重复传参)',
detail={'illegal_char': ch}, detail={'values': [str(c) for c in candidates]})
) tenant_id = candidates[0]
return tid if isinstance(tenant_id, dict):
tenant_id = (tenant_id.get('tenant_id') or tenant_id.get('tenantid')
or tenant_id.get('id'))
def build_context(tenant_id, user_id=None, role=None, if tenant_id is None:
session_id=None, trace_id=None, app_name='pbls'): if strict:
"""构造 TenantContext(构造即校验,非法直接抛错)""" raise TenantMissingError(message='tenant_id dict 中无 tenant_id 键')
return TenantContext( return ''
tenant_id=_check_tenant(tenant_id), text = str(tenant_id).strip()
user_id=user_id, if len(text) >= 2 and text[0] == text[-1] and text[0] in '\'"':
role=role, text = text[1:-1].strip()
session_id=session_id, if text.lower() in INVALID_TENANT_VALUES:
trace_id=trace_id, if strict:
app_name=app_name, raise TenantMissingError(
) message='tenant_id 为非法占位值 %r' % (text,),
detail={'raw': text})
return ''
def bind_context(ctx): if not TENANT_ID_PATTERN.match(text):
"""绑定上下文到当前线程,返回被替换的旧上下文(供 unbind 还原)"""
if not isinstance(ctx, TenantContext):
raise TenantInvalidError( raise TenantInvalidError(
message='bind_context 需要 TenantContext 实例,实际 %s' % type(ctx).__name__, message='tenant_id %r 不符合规则(1~64 位 [A-Za-z0-9_.-])' % (text,),
) detail={'raw': text, 'pattern': TENANT_ID_PATTERN.pattern})
old = getattr(_LOCAL, 'ctx', None) return text
_LOCAL.ctx = ctx
return old
def unbind_context(old=None): def assert_tenant(tenant_id=None, ctx=None):
"""解绑上下文;传入 bind_context 的返回值可还原上一层""" """断言租户存在且合法,返回规范化后的 tenant_id。
if old is None:
_LOCAL.ctx = None
else:
_LOCAL.ctx = old
return True
入参优先级:显式 tenant_id > 显式 ctx > 当前上下文变量。
def current_context(): 缺失即抛 TenantMissingError(fail-closed,绝不静默降级为全局租户)。
"""取当前线程上下文,未绑定返回 None(不抛错,供探测用)""" """
return getattr(_LOCAL, 'ctx', None) if tenant_id is None and ctx is not None:
tenant_id = getattr(ctx, 'tenant_id', None) or (ctx.get('tenant_id') if isinstance(ctx, dict) else None)
if tenant_id is None:
def require_context(): tenant_id = _tenant_var.get()
"""取当前线程上下文,未绑定即抛 TenantMissingError(fail-closed)""" if tenant_id is None or str(tenant_id).strip() == '':
ctx = current_context()
if ctx is None:
raise TenantMissingError( raise TenantMissingError(
message='未绑定租户上下文,请先 bind_context(build_context(tenant_id=...))', message='缺少租户上下文:tenant_id 必须打头传入或先 set_tenant()',
detail={'code': ErrorCode.TENANT_MISSING}, detail={'hint': '调用 with_tenant(tid) / set_tenant(tid) 或显式传 tenant_id'})
) return normalize_tenant(tenant_id)
return ctx
def require_tenant(): def check_tenant_column(table, columns=None, required=True):
"""取当前 tenant_id 字符串,未绑定即抛错(所有读写第一道闸)""" """校验表定义/字段清单是否以 tenant_id 打头(data-model.md 硬约束)。
return require_context().tenant_id
返回 (ok, first_column)。required=True 且不合规时抛 PblError(PBL_E_TENANT)。
"""
if columns is None:
columns = []
names = []
for col in columns:
if isinstance(col, dict):
names.append(str(col.get('name') or col.get('field') or ''))
else:
names.append(str(col))
names = [n for n in names if n]
first = names[0] if names else ''
ok = bool(names) and first == 'tenant_id'
if not ok and required:
raise err(
PBL_E_TENANT,
'表 %s 未以 tenant_id 打头(实际首列=%r)' % (table, first),
detail={'table': table, 'first_column': first, 'columns': names[:8]})
return ok, first
class context_scope(object): def tenant_scope(row=None, tenant_id=None, position='first'):
"""with 语法糖:进入绑定、退出还原(异常也还原)""" """把 tenant_id 注入 dict 并保证其位于**第一位**(SQL 参数序 = 打头序)。
def __init__(self, ctx): row 中已有 tenant_id 且与入参不一致 → 抛 TenantInvalidError(防跨租户串写)。
self.ctx = ctx """
self._old = None tid = assert_tenant(tenant_id)
out = {}
src = dict(row or {})
existing = src.pop('tenant_id', None)
if existing is not None and str(existing).strip() != '' \
and normalize_tenant(existing, strict=False) != tid:
raise TenantInvalidError(
message='tenant_id 冲突:上下文=%r 数据行=%r(疑似跨租户写入)' % (tid, existing),
detail={'context_tenant': tid, 'row_tenant': str(existing)})
out['tenant_id'] = tid
if position == 'first':
out.update(src)
else:
out.update(src)
out['tenant_id'] = out.pop('tenant_id')
return out
def assert_same_tenant(left, right, what='对象'):
"""断言两个租户标识相同(跨对象操作前的越权门禁)。"""
a = normalize_tenant(left)
b = normalize_tenant(right)
if a != b:
raise TenantInvalidError(
message='%s 租户不一致:%r != %r' % (what, a, b),
detail={'left': a, 'right': b})
return a
# --------------------------------------------------------------------------
# 上下文读写
# --------------------------------------------------------------------------
def set_tenant(tenant_id, actor_type='human', actor_id=None, trace_id=None, extra=None):
"""设置当前上下文的租户,返回 TenantContext 与 reset token。"""
ctx = TenantContext(tenant_id, actor_type=actor_type, actor_id=actor_id,
trace_id=trace_id, extra=extra)
token = _tenant_var.set(ctx.tenant_id)
if actor_type is not None:
_actor_var.set({'actor_type': ctx.actor_type, 'actor_id': ctx.actor_id})
if ctx.trace_id is not None:
_trace_var.set(ctx.trace_id)
return ctx, token
def reset_tenant(token=None):
"""复位上下文(token 为 set_tenant 返回的 token;None 时置空)。"""
if token is not None:
try:
_tenant_var.reset(token)
return True
except (ValueError, LookupError):
pass
_tenant_var.set(None)
return False
def get_tenant(strict=True):
"""取当前上下文 tenant_id。strict=True 缺失即抛(默认 fail-closed)。"""
tid = _tenant_var.get()
if strict:
return assert_tenant(tid)
return normalize_tenant(tid, strict=False) if tid else ''
def current_tenant(strict=True):
"""get_tenant 的语义化别名。"""
return get_tenant(strict=strict)
def current_context(strict=True):
"""取当前完整 TenantContext(无上下文且 strict 时抛 TenantMissingError)。"""
tid = _tenant_var.get()
if tid is None or str(tid).strip() == '':
if strict:
raise TenantMissingError(message='当前无租户上下文')
return None
actor = _actor_var.get() or {}
return TenantContext(tid, actor_type=actor.get('actor_type', 'human'),
actor_id=actor.get('actor_id'), trace_id=_trace_var.get())
def has_tenant():
tid = _tenant_var.get()
return bool(tid and str(tid).strip() != '')
class with_tenant(object):
"""上下文管理器 / 装饰器双用:在作用域内绑定租户。
用法::
with with_tenant('t_001') as ctx:
await create_blueprint(...)
@with_tenant('t_001')
async def handler(...): ...
"""
def __init__(self, tenant_id, actor_type='human', actor_id=None,
trace_id=None, extra=None):
self._spec = dict(tenant_id=tenant_id, actor_type=actor_type,
actor_id=actor_id, trace_id=trace_id, extra=extra)
self._token = None
self._ctx = None
self._func = None
if callable(tenant_id):
# @with_tenant 无参装饰函数用法
self._func = tenant_id
self._spec['tenant_id'] = None
# ---- with 语义 -------------------------------------------------------
def __enter__(self): def __enter__(self):
self._old = bind_context(self.ctx) self._ctx, self._token = set_tenant(**self._spec)
return self.ctx return self._ctx
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc, tb):
unbind_context(self._old) reset_tenant(self._token)
self._token = None
return False return False
# ---- 装饰器语义 ------------------------------------------------------
def __call__(self, func):
if self._func is not None and func is None:
func = self._func
import functools
@functools.wraps(func)
async def awrapper(*args, **kwargs):
tid = self._resolve(kwargs)
ctx, token = set_tenant(tid, actor_type=self._spec['actor_type'],
actor_id=self._spec['actor_id'],
trace_id=self._spec['trace_id'],
extra=self._spec['extra'])
try:
return await func(*args, **kwargs)
finally:
reset_tenant(token)
@functools.wraps(func)
def swrapper(*args, **kwargs):
tid = self._resolve(kwargs)
ctx, token = set_tenant(tid, actor_type=self._spec['actor_type'],
actor_id=self._spec['actor_id'],
trace_id=self._spec['trace_id'],
extra=self._spec['extra'])
try:
return func(*args, **kwargs)
finally:
reset_tenant(token)
import asyncio
return awrapper if asyncio.iscoroutinefunction(func) else swrapper
def _resolve(self, kwargs):
tid = self._spec.get('tenant_id')
if tid is None:
tid = kwargs.get('tenant_id') or kwargs.get('tenantid')
if tid is None:
raise TenantMissingError(message='with_tenant 未能确定 tenant_id')
return tid
def tenant_scope_ctx(row=None):
"""用当前上下文租户给 row 打头(tenant_scope 的上下文版)。"""
return tenant_scope(row, get_tenant())
def run_in_tenant(tenant_id, func, *args, **kwargs):
"""在隔离的 contextvars 副本中执行 func(不污染调用方上下文)。"""
ctx = copy_context()
def _inner():
set_tenant(tenant_id)
return func(*args, **kwargs)
return ctx.run(_inner)
__all__ = [
'TENANT_ID_PATTERN', 'SYSTEM_TENANT_IDS', 'INVALID_TENANT_VALUES',
'TenantContext',
'normalize_tenant', 'assert_tenant', 'check_tenant_column', 'tenant_scope',
'assert_same_tenant', 'tenant_scope_ctx',
'set_tenant', 'reset_tenant', 'get_tenant', 'current_tenant',
'current_context', 'has_tenant', 'with_tenant', 'run_in_tenant',
# 兼容再导出(旧代码从 context 直接取错误类)
'TenantMissingError', 'TenantInvalidError', 'PblError', 'PblTenantMissing',
'err', 'fail', 'normalize_code',
'PBL_E_TENANT', 'PBL_E_TENANT_MISSING', 'PBL_E_TENANT_INVALID',
]

View File

@ -1,169 +1,51 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.crud —— 兼容 shim(QC 退回意见 #4/#7)。
pbl_common.crud —— CRUD 工厂(sqlor 标准 API:仅 C/U/D/R/I/sqlExe)
铁律: 旧 self_check._check_crud_factory 导入 ``pbl_common.crud``(当时不存在),而交付的是
* 不编造 save/list/insert/update/delete/query 等非标准方法 ``crud_factory.py``。本轮定案:**唯一实现 = crud_factory.py**,crud.py 降级为薄再导出
* 所有读写条件一律经 TenantContext.scope() 包装,tenant_id 强制打头 shim,保证 ``from pbl_common.crud import CrudBase / tenant_crud`` 等旧路径可用。
* 写操作(C/U/D)自动补 tenant_id / create_time / update_time / is_deleted
* 软删除:D 默认置 is_deleted=1,不物理删(审计/证据/日志表除外) 依赖方向:crud(shim) → crud_factory → {dbutil, context, errors},无环。
""" """
import time from __future__ import annotations
from pbl_common.tenant import TenantContext, require_tenant, PBLTenantError from pbl_common.crud_factory import ( # noqa: F401 (兼容再导出)
from pbl_common.errors import PBLError, PBL_ERR AUDIT_TABLE_MARKERS,
from pbl_common.db import get_db CREATED_COLUMNS,
UPDATED_COLUMNS,
# 物理删除白名单(append-only 表不允许删;这些是可重算的临时表) CrudBase,
HARD_DELETE_TABLES = () bulk_create,
bulk_delete,
# 只读表(append-only:审计 / 证据日志 / 工具调用 / 校验问题) clear_registry,
APPEND_ONLY_TABLES = ( crud_factory,
'pbl_audit_event', is_audit_table,
'pbl_evidence_collect_log', make_crud,
'pbl_tool_call', registered_tables,
'pbl_validation_issue', tenant_crud,
'pbl_kdb_query_log', )
'pbl_blueprint_change_log', from pbl_common.errors import ( # noqa: F401
AppendOnlyError,
ConflictError,
NotFoundError,
ParamInvalidError,
WriteProtectedError,
assert_append_only,
assert_not_write_protected,
) )
# 写保护模块的基表(PBL 侧一律不得写) #: 旧命名别名
WRITE_PROTECTED_TABLES = ( Crud = CrudBase
'world', 'scene', 'entity', 'scense', 'scense_game', TenantCrud = CrudBase
'scense_runtime', 'script_engine', 'rbac_role', 'rbac_user', create_crud = tenant_crud
'rbac_permission', 'app_codes', get_crud = tenant_crud
)
def _now(): __all__ = [
return time.strftime('%Y-%m-%d %H:%M:%S') 'CrudBase', 'Crud', 'TenantCrud', 'tenant_crud', 'crud_factory', 'make_crud',
'create_crud', 'get_crud', 'bulk_create', 'bulk_delete', 'is_audit_table',
'registered_tables', 'clear_registry', 'AUDIT_TABLE_MARKERS',
def _assert_writable(tblname): 'CREATED_COLUMNS', 'UPDATED_COLUMNS',
if tblname in WRITE_PROTECTED_TABLES: 'NotFoundError', 'ConflictError', 'ParamInvalidError', 'AppendOnlyError',
raise PBLError(PBL_ERR['agent_tool']['write_protected'], 'WriteProtectedError', 'assert_not_write_protected', 'assert_append_only',
'表 %s 属写保护模块,PBL 侧禁止写入' % tblname) ]
def _ctx(params, strict=True):
"""从 params 取/构造租户上下文。"""
if isinstance(params, TenantContext):
return params
if strict:
return require_tenant(params if isinstance(params, dict) else {})
try:
return require_tenant(params if isinstance(params, dict) else {})
except PBLTenantError:
return None
class Crud(object):
"""单表 CRUD 封装(方法名严格对齐 sqlor:C/U/D/R/I)。"""
def __init__(self, tblname, module=None, pk='id', soft_delete=True,
tenant_field='tenant_id'):
self.tblname = tblname
self.module = module
self.pk = pk
self.soft_delete = soft_delete and tblname not in APPEND_ONLY_TABLES
self.tenant_field = tenant_field
self._db = None
# ---- DB 句柄 -------------------------------------------------------
@property
def db(self):
if self._db is None:
self._db = get_db(self.module)
return self._db
def close(self):
if self._db is not None:
try:
self._db.close()
except Exception: # noqa: BLE001
pass
self._db = None
# ---- C:新增 -------------------------------------------------------
def C(self, params):
"""新增一行。params 必须含 tenant_id(缺失即 fail-closed)。"""
_assert_writable(self.tblname)
ctx = _ctx(params)
row = dict(params)
row[self.tenant_field] = ctx.tenant_id
row.setdefault('create_time', _now())
row.setdefault('update_time', _now())
row.setdefault('create_user', ctx.user_id)
if self.soft_delete:
row.setdefault('is_deleted', 0)
row.pop(self.pk, None)
return self.db.C(self.tblname, row)
# ---- U:更新 -------------------------------------------------------
def U(self, params, cond=None):
"""按条件更新。cond 会被 scope() 包进租户作用域。"""
_assert_writable(self.tblname)
ctx = _ctx(params)
row = dict(params)
row.pop(self.tenant_field, None) # 租户列不可改
row.pop(self.pk, None)
row['update_time'] = _now()
row['update_user'] = ctx.user_id
where = ctx.scope(cond or {})
if self.soft_delete and 'is_deleted' not in where:
where['is_deleted'] = 0
return self.db.U(self.tblname, row, where)
# ---- D:删除(默认软删)-------------------------------------------
def D(self, params, cond=None):
_assert_writable(self.tblname)
if self.tblname in APPEND_ONLY_TABLES:
raise PBLError(PBL_ERR['agent_tool']['write_protected'],
'表 %s 为 append-only,禁止删除' % self.tblname)
ctx = _ctx(params)
where = ctx.scope(cond or {})
if not self.soft_delete or self.tblname in HARD_DELETE_TABLES:
return self.db.D(self.tblname, where)
return self.db.U(self.tblname,
{'is_deleted': 1, 'update_time': _now(),
'update_user': ctx.user_id},
where)
# ---- R:查询(单行/列表)------------------------------------------
def R(self, params, cond=None, fields=None, order_by=None,
limit=None, offset=0, one=False):
"""租户作用域查询。one=True 返回单行 dict,否则返回 list。"""
ctx = _ctx(params)
where = ctx.scope(cond or {})
if self.soft_delete and 'is_deleted' not in where:
where['is_deleted'] = 0
rows = self.db.R(self.tblname, where, fields=fields,
order_by=order_by, limit=limit, offset=offset)
rows = rows or []
if one:
return rows[0] if rows else None
return rows
# ---- I:原生 SQL(只读统计/复杂联查)------------------------------
def I(self, sql, args=None, params=None):
"""
执行原生 SELECT。调用方必须自行把 tenant_id 放进 WHERE 首条件;
非 SELECT 语句一律拒绝(写操作请走 C/U/D)。
"""
s = (sql or '').strip().lower()
if not s.startswith('select'):
raise PBLError(PBL_ERR['agent_tool']['write_protected'],
'I() 仅允许 SELECT,写操作请用 C/U/D')
if params is not None:
ctx = _ctx(params)
if 'tenant_id' not in s:
raise PBLError(PBL_ERR['tenant']['missing'],
'原生 SQL 必须显式带 tenant_id 条件')
args = tuple([ctx.tenant_id] + list(args or ()))
return self.db.I(sql, args or ())
def make_crud(tblname, module=None, **kw):
"""工厂:make_crud('pbl_blueprint') -> Crud 实例(含 C/U/D/R/I)。"""
return Crud(tblname, module=module, **kw)

View File

@ -1,234 +1,368 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.crud_factory —— 租户安全 CRUD 工厂(C/U/D/R/I 全集)。
pbl_common.crud_factory —— CRUD 工厂(统一租户打头 + 写保护 + 审计)
各 pbl_* 模块的表 CRUD 一律由本工厂生成,避免每个模块各写一套 【QC 退回意见 #4 的修复】旧 self_check._check_crud_factory 要求 C/U/D/R/I 五类方法,
(漏 tenant_id / 漏审计 / 误写引用基表)的风险。 而交付的 CrudBase 只有 create/get/list/update/delete → 自检与实现脱节。本版把
**两套命名同时提供**(规范名 C/U/D/R/I 对齐 sqlor 语义 + 语义名 create/read/update/
delete/list/get),self_check.py 按实际交付的符号断言,二者一一对应。
用法: 【QC 退回意见 #2 的修复】本文件从 errors 导入的 ``NotFoundError`` / ``DbError`` /
crud = make_crud('pbl_blueprint', 'pbl_blueprint', module='pbl_blueprint') ``ParamInvalidError`` / ``ConflictError`` 等已在 errors.py 补齐(规范类 + 兼容别名)。
crud.create(tenant_id, {'code': 'bp001', 'name': 'x'})
crud.get(tenant_id, 1) 【人工介入答复 ④】``tenant_crud`` 在本文件内实现为工厂函数(不 import
crud.list(tenant_id, where="status = %s", params=['draft'], limit=20) pbl_blueprint.m1b,不做反向 re-export),返回按 (module, table) 绑定的 CRUD 实例。
crud.update(tenant_id, 1, {'name': 'y'})
crud.delete(tenant_id, 1) 【写保护】所有写操作前过 ``assert_not_write_protected``;审计表过 ``assert_append_only``。
""" """
import datetime from __future__ import annotations
import logging
from pbl_common.errors import ( from pbl_common.errors import (
PBL_E_APPEND_ONLY,
PBL_E_CONFLICT,
PBL_E_NOT_FOUND,
PBL_E_PARAM,
PBL_E_TENANT_MISSING,
PBL_E_WRITE_PROTECTED,
AppendOnlyError,
ConflictError,
NotFoundError, NotFoundError,
ParamInvalidError, ParamInvalidError,
ErrorCode, WriteProtectedError,
assert_append_only,
assert_not_write_protected, assert_not_write_protected,
err,
)
from pbl_common.context import (
assert_same_tenant,
assert_tenant,
check_tenant_column,
get_tenant,
normalize_tenant,
tenant_scope,
)
from pbl_common.dbutil import (
Db,
get_db,
get_module_dbname,
new_id,
now_str,
safe_ident,
) )
from pbl_common.tenant import normalize_tenant, tenant_scope, with_tenant
from pbl_common.dbutil import query, query_one, execute, insert, transaction
from pbl_common.audit import write_audit
from pbl_common.serialize import dumps
MAX_PAGE_SIZE = 500 log = logging.getLogger('pbl_common.crud_factory')
DEFAULT_PAGE_SIZE = 20
#: 审计表名后缀/前缀(append-only)
AUDIT_TABLE_MARKERS = ('audit_log', 'audit_trail', 'pbl_audit')
#: 自动维护的时间戳列
CREATED_COLUMNS = ('created_at', 'create_time', 'created_date')
UPDATED_COLUMNS = ('updated_at', 'update_time', 'modified_at')
def _now(): def is_audit_table(table):
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') low = str(table or '').lower()
return any(m in low for m in AUDIT_TABLE_MARKERS)
class CrudBase(object): class CrudBase(object):
"""单表 CRUD 基类(tenant_id 强制打头,fail-closed)""" """单表租户安全 CRUD 基类。
def __init__(self, table, pk='id', module='pbl_common', 构造:``CrudBase(table='pbl_blueprint', module='pbl_blueprint', db=None)``
readonly=False, audit=True, json_columns=None,
auto_timestamp=True):
self.table = table
self.pk = pk
self.module = module
self.readonly = readonly
self.audit = audit
self.json_columns = set(json_columns or ())
self.auto_timestamp = auto_timestamp
# 写保护:引用模块基表禁止通过工厂写入
assert_not_write_protected(module, table)
# ---------- 内部工具 ---------- 方法面(两套命名等价):
def _guard_write(self): 规范名:C(row) / R(where) / U(row, where) / D(where) / I(ns)
if self.readonly: 语义名:create / read / list / get / update / delete / upsert
raise ParamInvalidError( """
code=ErrorCode.WRITE_PROTECTED,
message='表 %s 为只读(引用模块基表),禁止写入' % self.table,
detail={'table': self.table},
http_status=403,
)
def _encode_json_columns(self, row): def __init__(self, table, module=None, db=None, pk='id', tenant_column='tenant_id',
auto_timestamp=True, required_columns=None):
self.table = safe_ident(table, '表名')
self.module = str(module or '').strip()
self.pk = safe_ident(pk, '主键名')
self.tenant_column = str(tenant_column or 'tenant_id')
self.auto_timestamp = bool(auto_timestamp)
self.required_columns = tuple(required_columns or ())
self.db = db if isinstance(db, Db) else get_db(module=self.module or None)
self._protected = None
# ------------------------------------------------------------------
# 门禁
# ------------------------------------------------------------------
def _guard_write(self, action):
assert_not_write_protected(self.table, action)
assert_not_write_protected(self.module, action)
if is_audit_table(self.table):
assert_append_only(action, self.table)
return True
def _tid(self, tenant_id=None):
return assert_tenant(tenant_id if tenant_id is not None else None)
def _stamp(self, row, created=False):
if not self.auto_timestamp:
return row
out = dict(row) out = dict(row)
for col in self.json_columns: stamp = now_str()
if col in out and not isinstance(out[col], (str, bytes, type(None))): cols = CREATED_COLUMNS if created else UPDATED_COLUMNS
out[col] = dumps(out[col]) for col in cols:
if col in out or (created and col in out):
out[col] = out.get(col) or stamp
break
else:
# 仅当表定义里存在该列时才写(避免 unknown column)
pass
return out return out
def _audit(self, action, tenant_id, resource_id=None, result='success', detail=None): def _check_required(self, row):
if not self.audit: missing = [c for c in self.required_columns if not str(row.get(c, '')).strip()]
return False if missing:
return write_audit( raise ParamInvalidError(
action=action, message='%s 缺少必填字段:%s' % (self.table, ', '.join(missing)),
resource_type=self.table, detail={'table': self.table, 'missing': missing})
resource_id=resource_id, return True
tenant_id=tenant_id,
result=result,
detail=detail,
module=self.module,
)
# ---------- C ---------- # ------------------------------------------------------------------
def create(self, tenant_id, row, conn=None): # C —— create
"""插入一行;自动注入 tenant_id / created_at / updated_at""" # ------------------------------------------------------------------
self._guard_write() async def C(self, row, tenant_id=None, id_prefix=None):
if not isinstance(row, dict) or not row: """插入一行:tenant_id 打头、自动补主键与时间戳。返回新行 dict。"""
raise ParamInvalidError(message='create 需要非空 dict') self._guard_write('create')
data = with_tenant(row, tenant_id) data = dict(row or {})
data = self._encode_json_columns(data) tid = self._tid(tenant_id)
if self.auto_timestamp: self._check_required(data)
now = _now() if not data.get(self.pk):
data.setdefault('created_at', now) data[self.pk] = new_id(id_prefix or self.table[:3])
data.setdefault('updated_at', now) data = self._stamp(data, created=True)
res = insert(self.table, data, module=self.module, conn=conn) data.setdefault('created_at', now_str())
new_id = res.get('last_id') ns = tenant_scope(data, tid)
self._audit('create', data['tenant_id'], new_id, detail={'columns': sorted(data.keys())}) await self.db.create(self.table, ns, tenant_id=tid)
return new_id log.debug('crud.C %s tenant=%s pk=%s', self.table, tid, ns.get(self.pk))
return ns
# ---------- R ---------- async def create(self, row, tenant_id=None, **kw):
def get(self, tenant_id, pk_value, columns='*'): return await self.C(row, tenant_id=tenant_id, **kw)
"""按主键取单行(租户隔离),不存在抛 NotFoundError"""
tid = normalize_tenant(tenant_id) async def I(self, ns, tenant_id=None):
sql = 'SELECT %s FROM `%s` WHERE tenant_id = %%s AND `%s` = %%s LIMIT 1' % ( """sqlor ``sor.I(ns)`` 语义:ns 内含表名上下文时直接插入。"""
columns, self.table, self.pk) self._guard_write('insert')
row = query_one(sql, [tid, pk_value], module=self.module, tenant_id=tid) data = tenant_scope(dict(ns or {}), self._tid(tenant_id))
ctx = self.db._sqlor_context() # noqa: SLF001 - 通道探测
if ctx is not None:
async with ctx as sor:
return await sor.I(data)
return await self.db.create(self.table, data, tenant_id=data['tenant_id'])
async def insert(self, ns, tenant_id=None):
return await self.I(ns, tenant_id=tenant_id)
# ------------------------------------------------------------------
# R —— read
# ------------------------------------------------------------------
async def R(self, where=None, tenant_id=None, one=False, sql=None, params=None):
"""查询:强制 tenant_id 过滤。one=True 返回单行或 None。"""
tid = self._tid(tenant_id)
if sql:
rows = await self.db.execute(sql, params)
rows = [r if isinstance(r, dict) else dict(r or {}) for r in (rows or [])]
for r in rows:
if self.tenant_column in r:
assert_same_tenant(r[self.tenant_column], tid, what=self.table)
return (rows[0] if rows else None) if one else rows
cond = dict(where or {})
cond[self.tenant_column] = tid
rows = await self.db.read(self.table, cond, tenant_id=tid)
return (rows[0] if rows else None) if one else rows
async def read(self, where=None, tenant_id=None, **kw):
return await self.R(where, tenant_id=tenant_id, **kw)
async def list(self, where=None, tenant_id=None, page=1, rows=20, sort=None,
order='asc'):
"""分页列表:返回 {'total': N, 'rows': [...]}(不硬编码 LIMIT,走 sqlPaging 语义)。"""
tid = self._tid(tenant_id)
cond = dict(where or {})
cond[self.tenant_column] = tid
all_rows = await self.db.read(self.table, cond, tenant_id=tid)
if sort:
key = safe_ident(sort, '排序列')
all_rows.sort(key=lambda r: (r.get(key) is None, r.get(key)),
reverse=str(order).lower() == 'desc')
total = len(all_rows)
page = max(1, int(page or 1))
size = max(1, min(int(rows or 20), 500))
start = (page - 1) * size
return {'total': total, 'rows': all_rows[start:start + size],
'page': page, 'rows_per_page': size}
async def get(self, pk_value=None, where=None, tenant_id=None):
"""按主键取单行;不存在抛 NotFoundError(fail-closed,不返回 None 掩盖)。"""
cond = dict(where or {})
if pk_value is not None:
cond[self.pk] = pk_value
if not cond:
raise ParamInvalidError(message='get: 必须提供主键或 where',
detail={'table': self.table})
row = await self.R(cond, tenant_id=tenant_id, one=True)
if row is None: if row is None:
raise NotFoundError( raise NotFoundError(
message='%s[%s=%s] 不存在(租户 %s)' % (self.table, self.pk, pk_value, tid), message='%s 记录不存在:%s' % (self.table, cond),
detail={'table': self.table, 'pk': pk_value}, detail={'table': self.table, 'where': cond})
)
return row return row
def find(self, tenant_id, where=None, params=None, columns='*', order_by=None, limit=1): async def get_or_none(self, pk_value=None, where=None, tenant_id=None):
"""条件查询首行,无结果返回 None(不抛错)""" try:
tid = normalize_tenant(tenant_id) return await self.get(pk_value, where, tenant_id)
w, args = tenant_scope(tid, where, params) except NotFoundError:
sql = 'SELECT %s FROM `%s` WHERE %s' % (columns, self.table, w) return None
if order_by:
sql += ' ORDER BY %s' % order_by
sql += ' LIMIT %d' % max(1, int(limit))
rows = query(sql, args, module=self.module, tenant_id=tid)
return rows[0] if rows else None
def list(self, tenant_id, where=None, params=None, columns='*', async def exists(self, where=None, tenant_id=None):
order_by=None, limit=DEFAULT_PAGE_SIZE, offset=0): return await self.R(where, tenant_id=tenant_id, one=True) is not None
"""分页列表(tenant_id 打头),返回 {'items','total','limit','offset'}"""
tid = normalize_tenant(tenant_id)
w, args = tenant_scope(tid, where, params)
limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
offset = max(0, int(offset or 0))
total_row = query_one('SELECT COUNT(1) AS cnt FROM `%s` WHERE %s' % (self.table, w), async def count(self, where=None, tenant_id=None):
args, module=self.module, tenant_id=tid) rows = await self.R(where, tenant_id=tenant_id)
total = int((total_row or {}).get('cnt', 0)) return len(rows)
sql = 'SELECT %s FROM `%s` WHERE %s' % (columns, self.table, w) # ------------------------------------------------------------------
sql += ' ORDER BY %s' % order_by if order_by else ' ORDER BY `%s` DESC' % self.pk # U —— update
sql += ' LIMIT %s OFFSET %s' # ------------------------------------------------------------------
items = query(sql, list(args) + [limit, offset], module=self.module, tenant_id=tid) async def U(self, row, where=None, tenant_id=None):
return {'items': items, 'total': total, 'limit': limit, 'offset': offset} """更新:where 强制含 tenant_id;审计表拒绝。返回受影响行数。"""
self._guard_write('update')
tid = self._tid(tenant_id)
data = dict(row or {})
data.pop(self.tenant_column, None) # 租户列不可改
data.pop(self.pk, None) # 主键不可改
if not data:
raise ParamInvalidError(message='update: 无可更新字段',
detail={'table': self.table})
cond = dict(where or {})
if not cond:
pk_value = (row or {}).get(self.pk)
if not pk_value:
raise ParamInvalidError(message='update: 缺少 where 或主键',
detail={'table': self.table})
cond[self.pk] = pk_value
cond[self.tenant_column] = tid
data = self._stamp(data, created=False)
data.setdefault('updated_at', now_str())
return await self.db.update(self.table, data, cond, tenant_id=tid)
def count(self, tenant_id, where=None, params=None): async def update(self, row, where=None, tenant_id=None):
tid = normalize_tenant(tenant_id) return await self.U(row, where=where, tenant_id=tenant_id)
w, args = tenant_scope(tid, where, params)
row = query_one('SELECT COUNT(1) AS cnt FROM `%s` WHERE %s' % (self.table, w),
args, module=self.module, tenant_id=tid)
return int((row or {}).get('cnt', 0))
def exists(self, tenant_id, where=None, params=None): async def upsert(self, row, where=None, tenant_id=None):
return self.count(tenant_id, where, params) > 0 """存在则更新,不存在则插入(按 where 或主键判定)。"""
tid = self._tid(tenant_id)
cond = dict(where or {})
if not cond and (row or {}).get(self.pk):
cond[self.pk] = row[self.pk]
existing = await self.R(cond, tenant_id=tid, one=True) if cond else None
if existing:
await self.U(row, where=cond, tenant_id=tid)
return dict(existing, **{k: v for k, v in (row or {}).items()
if k not in (self.pk, self.tenant_column)})
return await self.C(row, tenant_id=tid)
# ---------- U ---------- # ------------------------------------------------------------------
def update(self, tenant_id, pk_value, changes, conn=None): # D —— delete
"""按主键更新(租户隔离),返回受影响行数""" # ------------------------------------------------------------------
self._guard_write() async def D(self, where=None, tenant_id=None):
tid = normalize_tenant(tenant_id) """删除:强制 where + tenant_id;审计表拒绝(append-only)。"""
if not isinstance(changes, dict) or not changes: self._guard_write('delete')
raise ParamInvalidError(message='update 需要非空 dict') tid = self._tid(tenant_id)
data = self._encode_json_columns(dict(changes)) cond = dict(where or {})
# 禁止通过 update 篡改租户归属 if not cond:
if 'tenant_id' in data: raise ParamInvalidError(message='delete: 必须提供 where(禁止全表删除)',
raise ParamInvalidError( detail={'table': self.table})
code=ErrorCode.TENANT_MISMATCH, cond[self.tenant_column] = tid
message='不允许修改 tenant_id(租户归属不可变)', return await self.db.delete(self.table, cond, tenant_id=tid)
http_status=403,
)
if self.auto_timestamp:
data.setdefault('updated_at', _now())
cols = list(data.keys())
sql = 'UPDATE `%s` SET %s WHERE tenant_id = %%s AND `%s` = %%s' % (
self.table,
', '.join('`%s` = %%s' % c for c in cols),
self.pk,
)
args = [data[c] for c in cols] + [tid, pk_value]
res = execute(sql, args, module=self.module, conn=conn)
self._audit('update', tid, pk_value, detail={'columns': cols})
return res.get('affected', 0)
def update_where(self, tenant_id, where, params, changes, conn=None): async def delete(self, where=None, tenant_id=None):
"""条件更新(tenant_id 打头),返回受影响行数""" return await self.D(where, tenant_id=tenant_id)
self._guard_write()
tid = normalize_tenant(tenant_id)
if not isinstance(changes, dict) or not changes:
raise ParamInvalidError(message='update_where 需要非空 changes')
data = self._encode_json_columns(dict(changes))
data.pop('tenant_id', None)
if self.auto_timestamp:
data.setdefault('updated_at', _now())
w, wargs = tenant_scope(tid, where, params)
cols = list(data.keys())
sql = 'UPDATE `%s` SET %s WHERE %s' % (
self.table, ', '.join('`%s` = %%s' % c for c in cols), w)
args = [data[c] for c in cols] + wargs
res = execute(sql, args, module=self.module, conn=conn)
self._audit('update', tid, None, detail={'where': w, 'columns': cols})
return res.get('affected', 0)
# ---------- D ---------- # ------------------------------------------------------------------
def delete(self, tenant_id, pk_value, conn=None, soft=False): # 表定义校验
""" # ------------------------------------------------------------------
删除(租户隔离)。soft=True 时置 is_deleted=1(软删,保留审计链)。 def check_schema(self, columns):
返回受影响行数。 """校验表定义 tenant_id 打头(data-model.md 硬约束)。"""
""" return check_tenant_column(self.table, columns)
self._guard_write()
tid = normalize_tenant(tenant_id)
if soft:
return self.update(tid, pk_value, {'is_deleted': 1}, conn=conn)
sql = 'DELETE FROM `%s` WHERE tenant_id = %%s AND `%s` = %%s' % (self.table, self.pk)
res = execute(sql, [tid, pk_value], module=self.module, conn=conn)
self._audit('delete', tid, pk_value)
return res.get('affected', 0)
# ---------- 事务 ---------- def __repr__(self):
def in_transaction(self): return 'CrudBase(table=%r, module=%r, pk=%r)' % (self.table, self.module, self.pk)
"""返回 transaction 上下文管理器(多表原子写,pbl_runtime_ext 依赖)"""
return transaction(module=self.module)
def make_crud(table, table_name=None, module='pbl_common', **kwargs): # ==========================================================================
# 工厂
# ==========================================================================
_registry = {}
def tenant_crud(table, module=None, db=None, pk='id', **kw):
"""返回按 (module, table) 绑定的租户安全 CRUD 实例(带缓存)。
这是下游模块(pbl_blueprint / pbl_evidence / pbl_assessment ...)取 CRUD 的
**唯一入口**:``crud = tenant_crud('pbl_blueprint', 'pbl_blueprint')``。
""" """
CRUD 工厂入口。 key = (str(module or ''), str(table), str(pk))
if key not in _registry:
_registry[key] = CrudBase(table=table, module=module, db=db, pk=pk, **kw)
return _registry[key]
table : 表名(第一参数,兼容 make_crud('pbl_blueprint') 单参写法)
table_name : 可选,与 table 同义(兼容旧签名 make_crud(module, table)) def crud_factory(table, module=None, db=None, pk='id', **kw):
module : 模块名(用于 get_module_dbname 与写保护判定) """tenant_crud 的别名(历史命名,保留兼容)。"""
kwargs : 透传 CrudBase(pk/readonly/audit/json_columns/auto_timestamp) return tenant_crud(table, module=module, db=db, pk=pk, **kw)
"""
real_table = table_name or table
if real_table and not table: def make_crud(table, module=None, **kw):
real_table = table """每次返回新实例(不缓存),用于测试隔离。"""
return CrudBase(real_table, module=module, **kwargs) return CrudBase(table=table, module=module, **kw)
def clear_registry():
_registry.clear()
return True
def registered_tables():
return sorted({k[1] for k in _registry})
# ==========================================================================
# 批量操作
# ==========================================================================
async def bulk_create(rows, table, module=None, tenant_id=None, **kw):
"""批量插入(逐行走 C,保证每行都过写保护与租户门禁)。返回成功行数。"""
crud = tenant_crud(table, module=module, **kw)
ok = 0
for row in (rows or []):
await crud.C(row, tenant_id=tenant_id)
ok += 1
return ok
async def bulk_delete(where_list, table, module=None, tenant_id=None, **kw):
crud = tenant_crud(table, module=module, **kw)
ok = 0
for cond in (where_list or []):
await crud.D(cond, tenant_id=tenant_id)
ok += 1
return ok
__all__ = [
'CrudBase', 'tenant_crud', 'crud_factory', 'make_crud',
'clear_registry', 'registered_tables',
'bulk_create', 'bulk_delete', 'is_audit_table',
'AUDIT_TABLE_MARKERS', 'CREATED_COLUMNS', 'UPDATED_COLUMNS',
# 兼容再导出
'NotFoundError', 'ConflictError', 'ParamInvalidError', 'AppendOnlyError',
'WriteProtectedError', 'assert_not_write_protected', 'assert_append_only',
'assert_tenant', 'tenant_scope', 'check_tenant_column',
'new_id', 'now_str', 'get_module_dbname',
'PBL_E_NOT_FOUND', 'PBL_E_CONFLICT', 'PBL_E_PARAM', 'PBL_E_APPEND_ONLY',
'PBL_E_WRITE_PROTECTED', 'PBL_E_TENANT_MISSING',
]

View File

@ -1,236 +1,532 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.dbutil —— DB 适配层(sqlor 主路径 + PyMySQL 显式回落)。
pbl_common.dbutil —— DB 适配层(mariadb 方言 / sqlor 优先 / 连接池)
约定: 【QC 退回意见 #6 的修复】module-development-spec 要求关系操作走 sqlor
- 库名一律 ServerEnv().get_module_dbname(module),禁止硬编码 DBNAME (仅 ``sor.C/U/D/R/I/sqlExe``,禁编造 save/list/insert)。旧版 dbutil.py 文档声称
- 方言 mariadb:占位符 %s,主键 BIGINT AUTO_INCREMENT 『sqlor 优先』但代码零 sqlor 调用、只有裸 PyMySQL 并自造本地 sqlExe()。本版:
- 优先走平台 sqlor(sor.C/U/D/R/I/sqlExe);sqlor 不可用时回落 PyMySQL
- 所有写操作必须显式事务(transaction 上下文管理器) * **主路径 = 平台 sqlor**:``DBPools().sqlorContext(dbname)`` → ``sor.C/U/D/R/I/sqlExe``,
方法名与参数个数严格按规范(C/U/D/R 两个参数,I 一个参数);
* **回落 = PyMySQL**,且只在 ``PBL_DB_FALLBACK=1`` 或 sqlor 不可导入时启用,
回落路径每次执行都写一条 warning 级日志(不再静默),并保留 tenant_id 打头校验;
* 本地不再自造 ``sqlExe()`` 函数冒充 sqlor API —— 统一走 ``Db.execute()``,
内部按通道分派到 ``sor.sqlExe`` 或 PyMySQL cursor。
【QC 退回意见 #2 / 人工介入答复 ③】``new_id`` / ``now_str`` 在本文件内自包含实现
(不 import pbl_blueprint.m1b,杜绝反向 re-export 造成的循环依赖)。
【库名】禁止硬编码 DBNAME:统一 ``ServerEnv().get_module_dbname(module)``,
取不到时按 ``pbls_{module}`` 规则派生并可被环境变量覆盖。
""" """
import threading from __future__ import annotations
from pbl_common.errors import DbError, ErrorCode import os
from pbl_common.tenant import normalize_tenant import re
import time
import uuid
import logging
from datetime import datetime
# 方言常量(与 projects/pbls/env/test.json ddl.dialect 同值) from pbl_common.errors import (
DIALECT = 'mariadb' PBL_E_APPEND_ONLY,
PLACEHOLDER = '%s' PBL_E_DB,
PBL_E_DB_UNAVAILABLE,
PBL_E_PARAM,
PBL_E_TENANT_MISSING,
AppendOnlyError,
DbError,
DbUnavailableError,
ParamInvalidError,
assert_append_only,
assert_not_write_protected,
err,
)
from pbl_common.context import (
assert_tenant,
get_tenant,
normalize_tenant,
tenant_scope,
)
_POOL_LOCK = threading.RLock() log = logging.getLogger('pbl_common.dbutil')
_POOLS = {} # dbname -> pool
_ENV_CACHE = {} # 缓存 ServerEnv 取到的库名 # ==========================================================================
# 1. 基础工具:ID / 时间 / 标识符
# ==========================================================================
_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
_IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
def _server_env(): def new_id(prefix=''):
"""取平台 ServerEnv 单例(不可用时返回 None,走回落分支)""" """生成 32 位以内的字符串主键(VARCHAR(32) 对齐)。
try:
from ahserver.serverenv import ServerEnv
return ServerEnv()
except Exception: # noqa: BLE001
return None
规则:``{prefix}{时间戳36进制}{随机}``,无 prefix 时为 32 位 uuid4 hex。
def get_dbname(module='pbl_common'):
""" """
取模块对应库名:ServerEnv().get_module_dbname(module)。 if not prefix:
禁止在模块内硬编码库名——这是本函数存在的唯一理由。 return uuid.uuid4().hex
""" prefix = str(prefix).strip('_')[:8]
if module in _ENV_CACHE: stamp = _base36(int(time.time() * 1000))[-8:]
return _ENV_CACHE[module] rand = uuid.uuid4().hex[:32 - len(prefix) - len(stamp) - 1]
env = _server_env() return ('%s_%s%s' % (prefix, stamp, rand))[:32]
dbname = None
if env is not None:
fn = getattr(env, 'get_module_dbname', None)
if callable(fn):
try:
dbname = fn(module)
except Exception: # noqa: BLE001
dbname = None
if not dbname:
# 回落:环境变量(部署期由 .env 注入),仍不硬编码业务库名
import os
dbname = os.environ.get('PBLS_DBNAME') or os.environ.get('DBNAME')
if not dbname:
raise DbError(
message='无法解析模块 %s 的库名(ServerEnv.get_module_dbname 未挂载且无 PBLS_DBNAME)' % module,
detail={'module': module},
)
_ENV_CACHE[module] = dbname
return dbname
def _db_conf(dbname): def _base36(num):
"""从 ServerEnv / conf 读取连接参数""" if num == 0:
env = _server_env() return '0'
conf = {} out = []
while num:
num, rem = divmod(num, 36)
out.append(_ID_ALPHABET[rem])
return ''.join(reversed(out))
def now_str(fmt='%Y-%m-%d %H:%M:%S'):
"""当前时间字符串(DATETIME 文本,禁用 TIMESTAMP 类型 → 见 data-model.md)。"""
return datetime.now().strftime(fmt)
def cur_date_string():
"""平台 curDateString() 的等价实现(YYYY-MM-DD HH:MM:SS)。"""
return now_str()
def today_str():
return datetime.now().strftime('%Y-%m-%d')
def safe_ident(name, what='标识符'):
"""SQL 标识符白名单校验(表名/列名),防注入。非法即抛 ParamInvalidError。"""
text = str(name or '').strip()
if not _IDENT_RE.match(text):
raise ParamInvalidError(message='非法%s:%r' % (what, text), detail={'raw': text})
return text
def quote_ident(name):
return '`%s`' % safe_ident(name)
# ==========================================================================
# 2. 库名解析(禁止硬编码 DBNAME)
# ==========================================================================
def get_module_dbname(module, env=None):
"""模块 → 库名。优先级:ServerEnv 映射 > 环境变量 > pbls_{module} 派生。"""
mod = str(module or '').strip()
if not mod:
raise ParamInvalidError(message='get_module_dbname: module 不能为空')
if env is None:
env = get_server_env()
if env is not None: if env is not None:
getter = getattr(env, 'get_database', None) or getattr(env, 'database', None) getter = getattr(env, 'get_module_dbname', None)
if callable(getter): if callable(getter):
try: try:
conf = getter(dbname) or {} name = getter(mod)
except Exception: # noqa: BLE001 except Exception as exc: # noqa: BLE001 - 回落而非崩
conf = {} log.warning('ServerEnv.get_module_dbname(%s) 失败:%s', mod, exc)
elif isinstance(getter, dict): name = None
conf = getter.get(dbname) or {} if name:
if not conf: return str(name)
import os override = os.environ.get('PBL_DBNAME_%s' % mod.upper().replace('-', '_'))
conf = { if override:
'host': os.environ.get('PBLS_DB_HOST', '127.0.0.1'), return override
'port': int(os.environ.get('PBLS_DB_PORT', '3306')), default_db = os.environ.get('PBL_DBNAME_DEFAULT')
'user': os.environ.get('PBLS_DB_USER', 'pbls'), if default_db:
'password': os.environ.get('PBLS_DB_PASSWORD', ''), return default_db
'charset': 'utf8mb4', return 'pbls_%s' % mod.replace('-', '_')
}
return conf
def get_conn(dbname=None, module='pbl_common'): def get_server_env():
"""取一个 DB 连接(PyMySQL 回落实现;平台 sqlor 可用时由 sqlor 接管)""" """取 ahserver ServerEnv 单例(不可用时返回 None,不抛)。"""
dbname = dbname or get_dbname(module) for modpath in ('ahserver.serverenv', 'appbase.serverenv'):
try:
import pymysql
except ImportError:
raise DbError(
message='PyMySQL 未安装且平台 sqlor 不可用,无法建立 DB 连接',
detail={'dbname': dbname},
)
conf = dict(_db_conf(dbname))
conf.setdefault('charset', 'utf8mb4')
conf.setdefault('autocommit', False)
conf['database'] = dbname
conf.pop('dbname', None)
try:
return pymysql.connect(**conf)
except Exception as e: # noqa: BLE001
raise DbError(message='DB 连接失败:%s' % e, detail={'dbname': dbname})
def _rows_to_dicts(cursor):
cols = [d[0] for d in (cursor.description or [])]
return [dict(zip(cols, r)) for r in cursor.fetchall()]
def query(sql, params=None, dbname=None, module='pbl_common', tenant_id=None):
"""
只读查询,返回 list[dict]。
传入 tenant_id 时做「SQL 必须含 tenant_id 条件」的软断言(fail-closed 提示)。
"""
if tenant_id is not None:
normalize_tenant(tenant_id)
if 'tenant_id' not in str(sql).lower():
raise DbError(
code=ErrorCode.TENANT_MISSING,
message='查询 SQL 未包含 tenant_id 条件(租户隔离铁律)',
detail={'sql_head': str(sql)[:120]},
)
conn = get_conn(dbname, module)
try:
with conn.cursor() as cur:
cur.execute(sql, tuple(params or ()))
return _rows_to_dicts(cur)
except DbError:
raise
except Exception as e: # noqa: BLE001
raise DbError(message='查询失败:%s' % e, detail={'sql_head': str(sql)[:120]})
finally:
try: try:
conn.close() mod = __import__(modpath, fromlist=['ServerEnv'])
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass continue
cls = getattr(mod, 'ServerEnv', None)
if cls is None:
def query_one(sql, params=None, dbname=None, module='pbl_common', tenant_id=None): continue
"""只读查询单行,无结果返回 None"""
rows = query(sql, params, dbname, module, tenant_id=tenant_id)
return rows[0] if rows else None
def execute(sql, params=None, dbname=None, module='pbl_common', conn=None):
"""
写操作(INSERT/UPDATE/DELETE),返回受影响行数。
传入 conn 时由调用方控制事务提交;否则自动提交。
"""
own = conn is None
conn = conn or get_conn(dbname, module)
try:
with conn.cursor() as cur:
affected = cur.execute(sql, tuple(params or ()))
last_id = getattr(cur, 'lastrowid', None)
if own:
conn.commit()
return {'affected': affected or 0, 'last_id': last_id}
except Exception as e: # noqa: BLE001
if own:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
raise DbError(message='写入失败:%s' % e, detail={'sql_head': str(sql)[:120]})
finally:
if own:
try:
conn.close()
except Exception: # noqa: BLE001
pass
def insert(table, row, dbname=None, module='pbl_common', conn=None):
"""
通用 INSERT(自动拼装列与占位符),返回 {'affected','last_id'}。
row 必须已含 tenant_id(由 pbl_common.tenant.with_tenant 注入)。
"""
if not isinstance(row, dict) or not row:
raise DbError(message='insert 需要非空 dict', detail={'table': table})
if 'tenant_id' not in row:
raise DbError(
code=ErrorCode.TENANT_MISSING,
message='insert %s 缺少 tenant_id(租户隔离铁律)' % table,
detail={'table': table},
)
cols = list(row.keys())
sql = 'INSERT INTO `%s` (%s) VALUES (%s)' % (
table,
', '.join('`%s`' % c for c in cols),
', '.join([PLACEHOLDER] * len(cols)),
)
return execute(sql, [row[c] for c in cols], dbname, module, conn=conn)
class transaction(object):
"""
事务上下文管理器:with transaction() as conn: ...
正常退出 commit,异常 rollback 并原样抛出(pbl_runtime_ext 单事务
事件+状态写入依赖它保证原子性)。
"""
def __init__(self, dbname=None, module='pbl_common'):
self.dbname = dbname
self.module = module
self.conn = None
def __enter__(self):
self.conn = get_conn(self.dbname, self.module)
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
try: try:
return cls()
except Exception as exc: # noqa: BLE001
log.warning('%s.ServerEnv() 实例化失败:%s', modpath, exc)
return None
# ==========================================================================
# 3. sqlor 通道探测(主路径)
# ==========================================================================
def _load_sqlor_pools():
"""导入平台 sqlor 的 DBPools(主路径)。不可用返回 None。"""
try:
from sqlor import DBPools # noqa: WPS433 - 平台包
except Exception: # noqa: BLE001
try:
from sqlor.dbpools import DBPools # noqa: WPS433
except Exception: # noqa: BLE001
return None
return DBPools
def sqlor_available():
"""sqlor 主路径是否可用(供自检与部署诊断)。"""
return _load_sqlor_pools() is not None
def fallback_enabled():
"""PyMySQL 回落是否被显式允许(默认关闭,避免静默降级)。"""
return str(os.environ.get('PBL_DB_FALLBACK', '0')).strip().lower() in (
'1', 'true', 'yes', 'on')
# ==========================================================================
# 4. Db 适配器
# ==========================================================================
class Db(object):
"""统一 DB 门面:sqlor 优先,PyMySQL 显式回落。
只暴露 5 个语义方法(create/read/update/delete/execute)+ 事务上下文,
内部严格映射到 sqlor 的 ``sor.C/U/D/R/I/sqlExe``。
"""
def __init__(self, dbname=None, module=None, config=None):
self.module = str(module or '').strip()
self.dbname = str(dbname or (get_module_dbname(self.module) if self.module
else os.environ.get('PBL_DBNAME_DEFAULT', 'pbls')))
self.config = dict(config or {})
self.channel = None # 'sqlor' | 'pymysql' | None
self._pymysql_conn = None
# ---- 通道 ------------------------------------------------------------
def _sqlor_context(self):
pools = _load_sqlor_pools()
if pools is None:
return None
try:
return pools().sqlorContext(self.dbname)
except Exception as exc: # noqa: BLE001
log.warning('sqlorContext(%s) 打开失败:%s', self.dbname, exc)
return None
def _pymysql_connect(self):
if not fallback_enabled():
raise DbUnavailableError(
message='sqlor 不可用且未开启 PyMySQL 回落(设 PBL_DB_FALLBACK=1 才允许)',
detail={'dbname': self.dbname})
try:
import pymysql # noqa: WPS433
except Exception as exc: # noqa: BLE001
raise DbUnavailableError(
message='PyMySQL 回落通道不可用:%s' % (exc,),
detail={'dbname': self.dbname})
cfg = dict(self.config)
cfg.setdefault('host', os.environ.get('PBL_DB_HOST', '127.0.0.1'))
cfg.setdefault('port', int(os.environ.get('PBL_DB_PORT', '3306')))
cfg.setdefault('user', os.environ.get('PBL_DB_USER', 'root'))
cfg.setdefault('password', os.environ.get('PBL_DB_PASSWORD', ''))
cfg.setdefault('charset', 'utf8mb4')
cfg['database'] = self.dbname
try:
self._pymysql_conn = pymysql.connect(**cfg)
except Exception as exc: # noqa: BLE001
raise DbError(message='PyMySQL 连接失败:%s' % (exc,),
detail={'dbname': self.dbname})
self.channel = 'pymysql'
log.warning('[FALLBACK] %s 走 PyMySQL 回落通道(非 sqlor 主路径)', self.dbname)
return self._pymysql_conn
# ---- 写保护 + append-only 门禁 ---------------------------------------
def _guard(self, table, action):
safe_ident(table, '表名')
assert_not_write_protected(table, action)
if str(table).lower().endswith('audit_log') or str(table).lower().startswith('pbl_audit'):
assert_append_only(action, table)
return True
# ---- CRUD ------------------------------------------------------------
async def create(self, table, row, tenant_id=None):
"""插入一行;tenant_id 强制打头。返回受影响行数/新 id。"""
self._guard(table, 'create')
ns = tenant_scope(row, tenant_id if tenant_id is not None else _ctx_tenant())
ctx = self._sqlor_context()
if ctx is not None:
self.channel = 'sqlor'
async with ctx as sor:
return await sor.C(table, ns)
conn = self._pymysql_connect()
cols = list(ns.keys())
sql = 'INSERT INTO %s (%s) VALUES (%s)' % (
quote_ident(table),
', '.join(quote_ident(c) for c in cols),
', '.join(['%s'] * len(cols)))
return self._execute_sync(sql, [ns[c] for c in cols])
async def read(self, sql_or_table, params=None, tenant_id=None, one=False):
"""查询。sql_or_table 为表名时自动拼 tenant_id 过滤(打头)。"""
params = dict(params or {})
if _IDENT_RE.match(str(sql_or_table)):
table = safe_ident(sql_or_table, '表名')
tid = normalize_tenant(tenant_id if tenant_id is not None else _ctx_tenant())
where = ['tenant_id = %s']
values = [tid]
for key, val in params.items():
safe_ident(key, '列名')
where.append('`%s` = %%s' % key)
values.append(val)
sql = 'SELECT * FROM %s WHERE %s' % (quote_ident(table), ' AND '.join(where))
args = values
else:
sql = str(sql_or_table)
args = _to_args(params)
ctx = self._sqlor_context()
if ctx is not None:
self.channel = 'sqlor'
async with ctx as sor:
rows = await sor.sqlExe(sql, args)
else:
rows = self._query_sync(sql, args)
rows = [_row_to_dict(r) for r in (rows or [])]
if one:
return rows[0] if rows else None
return rows
async def update(self, table, row, where=None, tenant_id=None):
"""按 where 更新;where 未含 tenant_id 时自动补(防跨租户串写)。"""
self._guard(table, 'update')
data = dict(row or {})
data.pop('tenant_id', None)
if not data:
raise ParamInvalidError(message='update: 无可更新字段', detail={'table': table})
cond = dict(where or {})
tid = normalize_tenant(tenant_id if tenant_id is not None else _ctx_tenant())
cond['tenant_id'] = tid
ns = tenant_scope(data, tid)
ctx = self._sqlor_context()
if ctx is not None:
self.channel = 'sqlor'
async with ctx as sor:
return await sor.U(table, ns, cond) if _u_takes_three(sor) \
else await sor.U(table, dict(ns, **{'__where__': cond}))
sets = ', '.join('%s = %%s' % quote_ident(k) for k in data)
wheres = ' AND '.join('%s = %%s' % quote_ident(k) for k in cond)
sql = 'UPDATE %s SET %s WHERE %s' % (quote_ident(table), sets, wheres)
return self._execute_sync(sql, list(data.values()) + list(cond.values()))
async def delete(self, table, where=None, tenant_id=None):
"""按 where 删除;强制带 tenant_id。"""
self._guard(table, 'delete')
cond = dict(where or {})
if not cond:
raise ParamInvalidError(message='delete: 必须提供 where(禁止全表删除)',
detail={'table': table})
tid = normalize_tenant(tenant_id if tenant_id is not None else _ctx_tenant())
cond['tenant_id'] = tid
ctx = self._sqlor_context()
if ctx is not None:
self.channel = 'sqlor'
async with ctx as sor:
return await sor.D(table, cond)
wheres = ' AND '.join('%s = %%s' % quote_ident(k) for k in cond)
sql = 'DELETE FROM %s WHERE %s' % (quote_ident(table), wheres)
return self._execute_sync(sql, list(cond.values()))
async def insert(self, ns, table=None):
"""sqlor ``sor.I(ns)`` 语义(1 个参数,表名由上下文解析)。"""
if table:
return await self.create(table, ns)
ctx = self._sqlor_context()
if ctx is None:
raise DbUnavailableError(message='insert(ns) 需要 sqlor 上下文(回落通道不支持)')
self.channel = 'sqlor'
async with ctx as sor:
return await sor.I(ns)
async def execute(self, sql, params=None):
"""执行任意 SQL(DDL / 复杂查询)。params 支持 dict 或 list。"""
sql_text = str(sql or '').strip()
if not sql_text:
raise ParamInvalidError(message='execute: SQL 为空')
_reject_forbidden_sql(sql_text)
args = _to_args(params)
ctx = self._sqlor_context()
if ctx is not None:
self.channel = 'sqlor'
async with ctx as sor:
return await sor.sqlExe(sql_text, args)
head = sql_text.split(None, 1)[0].lower()
if head == 'select' or sql_text.lower().startswith('show'):
return self._query_sync(sql_text, args)
return self._execute_sync(sql_text, args)
# 兼容别名:旧代码调 db.sqlExe(...)(大写 E)
async def sqlExe(self, sql, params=None): # noqa: N802 - 对齐 sqlor 命名
return await self.execute(sql, params)
# ---- 事务 ------------------------------------------------------------
async def transaction(self):
return _Transaction(self)
# ---- 同步底层(仅回落通道使用) --------------------------------------
def _query_sync(self, sql, args):
conn = self._pymysql_conn or self._pymysql_connect()
with conn.cursor() as cur:
cur.execute(sql, args or [])
cols = [d[0] for d in (cur.description or [])]
return [dict(zip(cols, r)) for r in cur.fetchall()]
def _execute_sync(self, sql, args):
conn = self._pymysql_conn or self._pymysql_connect()
with conn.cursor() as cur:
affected = cur.execute(sql, args or [])
conn.commit()
return affected
def close(self):
if self._pymysql_conn is not None:
try:
self._pymysql_conn.close()
except Exception: # noqa: BLE001
pass
self._pymysql_conn = None
class _Transaction(object):
"""最小事务上下文:sqlor 通道下退化为顺序执行(sqlor 自带连接管理)。"""
def __init__(self, db):
self.db = db
self._conn = None
async def __aenter__(self):
if self.db.channel == 'pymysql' or self.db._pymysql_conn is not None:
self._conn = self.db._pymysql_conn or self.db._pymysql_connect()
self._conn.begin()
return self.db
async def __aexit__(self, exc_type, exc, tb):
if self._conn is not None:
if exc_type is None: if exc_type is None:
self.conn.commit() self._conn.commit()
else: else:
self.conn.rollback() self._conn.rollback()
finally:
try:
self.conn.close()
except Exception: # noqa: BLE001
pass
return False return False
def sqlExe(sql, params=None, dbname=None, module='pbl_common'): # ==========================================================================
"""sqlor 兼容别名(平台 sor.sqlExe 语义:执行任意 SQL 返回结果集)""" # 5. 内部 helper
low = str(sql).strip().lower() # ==========================================================================
if low.startswith('select') or low.startswith('show') or low.startswith('desc'): def _ctx_tenant():
return query(sql, params, dbname, module) """取当前上下文租户;无上下文时抛 TenantMissingError(fail-closed)。"""
return execute(sql, params, dbname, module) try:
return get_tenant(strict=True)
except Exception: # noqa: BLE001
raise err(PBL_E_TENANT_MISSING, '无租户上下文,拒绝执行 DB 操作')
def _u_takes_three(sor):
import inspect
try:
sig = inspect.signature(sor.U)
return len(sig.parameters) >= 3
except (TypeError, ValueError):
return True
def _to_args(params):
if params is None:
return []
if isinstance(params, (list, tuple)):
return list(params)
if isinstance(params, dict):
return list(params.values())
return [params]
def _row_to_dict(row):
if row is None:
return None
if isinstance(row, dict):
return dict(row)
# sqlor 返回 DictObject:不可 dict() 转换,逐属性取
keys = getattr(row, 'keys', None)
if callable(keys):
try:
return {k: row[k] for k in keys()}
except Exception: # noqa: BLE001
pass
return {'value': row}
_FORBIDDEN_SQL = (
'drop database', 'drop schema', 'truncate table',
)
def _reject_forbidden_sql(sql):
low = ' '.join(sql.lower().split())
for token in _FORBIDDEN_SQL:
if low.startswith(token):
raise ParamInvalidError(message='禁止执行破坏性 SQL:%s' % token,
detail={'sql': sql[:200]})
return True
# ==========================================================================
# 6. 模块级便捷函数(下游模块直接 import 这些名字)
# ==========================================================================
_default_db = {}
def get_db(module=None, dbname=None, config=None):
"""取(并缓存)Db 实例。"""
key = dbname or get_module_dbname(module) if module else (dbname or 'pbls')
if key not in _default_db:
_default_db[key] = Db(dbname=key, module=module, config=config)
return _default_db[key]
async def sqlExe(sql, params=None, module=None, dbname=None): # noqa: N802
"""模块级 sqlExe:走 Db.execute(sqlor 主路径)。"""
return await get_db(module=module, dbname=dbname).execute(sql, params)
async def query(sql, params=None, module=None, dbname=None):
return await get_db(module=module, dbname=dbname).execute(sql, params)
async def fetch_one(table, where=None, tenant_id=None, module=None, dbname=None):
rows = await get_db(module=module, dbname=dbname).read(
table, where, tenant_id=tenant_id)
return rows[0] if rows else None
async def fetch_all(table, where=None, tenant_id=None, module=None, dbname=None):
return await get_db(module=module, dbname=dbname).read(
table, where, tenant_id=tenant_id)
async def insert_row(table, row, tenant_id=None, module=None, dbname=None):
return await get_db(module=module, dbname=dbname).create(table, row, tenant_id)
async def update_row(table, row, where=None, tenant_id=None, module=None, dbname=None):
return await get_db(module=module, dbname=dbname).update(table, row, where, tenant_id)
async def delete_row(table, where=None, tenant_id=None, module=None, dbname=None):
return await get_db(module=module, dbname=dbname).delete(table, where, tenant_id)
__all__ = [
'Db', 'get_db', 'get_module_dbname', 'get_server_env',
'sqlor_available', 'fallback_enabled',
'new_id', 'now_str', 'cur_date_string', 'today_str',
'safe_ident', 'quote_ident',
'sqlExe', 'query', 'fetch_one', 'fetch_all',
'insert_row', 'update_row', 'delete_row',
# 兼容再导出
'DbError', 'DbUnavailableError', 'ParamInvalidError', 'AppendOnlyError',
'assert_not_write_protected', 'assert_append_only',
'assert_tenant', 'tenant_scope', 'normalize_tenant',
'PBL_E_DB', 'PBL_E_DB_UNAVAILABLE', 'PBL_E_APPEND_ONLY',
]

View File

@ -1,164 +1,534 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.errors —— PBL Agent OS 统一错误码与异常体系(全包唯一事实源)。
pbl_common.errors —— PBL 统一错误码(6 大类,码值全局唯一)
约定: 【QC 退回意见 #3 的根治:只保留一套错误码方案】
* 码值格式 PBL-<类别缩写>-<三位序号>,全局唯一(self_check 校验) 历史上包内曾并存三套互斥方案:
* 所有对外接口失败一律返回 {'ok': False, 'err_code': ..., 'err_msg': ...} (a) errors.py 的 ``PBL-TEN-001`` 连字符风格 + ``PBLError.err_code``;
* fail-closed:未登记错误码不允许对外抛出裸异常 (b) init.py 自检断言的 ``PBL_E_TENANT_MISSING`` / ``PBL_E_APPEND_ONLY`` + ``PblError.code``;
(c) __init__.py 自检断言的 ``PBL_E_TENANT`` / ``PBL_E_WRITE_LOCK`` / ``PBL_E_PARAM``。
三者不可能同时成立。本轮统一为 **一套规范方案**:
规范错误码 == 符号常量 ``PBL_E_*``(常量的字符串值与常量名同名)
* ``PBLError.code`` 与 ``PBLError.err_code`` 是**同一个值的两个属性名**(向后兼容);
* 连字符风格降级为只读派生属性 ``PBLError.legacy_code``,由 ``LEGACY_CODE_MAP``
机械映射生成,仅供旧日志/旧工单检索,**任何断言都不得依赖它**;
* ``ErrorCode``(类属性访问)与 ``PBL_ERR``(命名空间对象访问)是同一批常量的
两种读取方式,值完全一致,不构成第二套编码。
【向后兼容(设计文档 pbl_common.md §5:接口变更需向后兼容,不删既有签名)】
旧符号面全部保留为 ``PBLError`` 的别名或薄子类,共 10 个类名 + 4 个函数/常量:
PblError, DbError, NotFoundError, ParamInvalidError, TenantMissingError,
TenantInvalidError, PblValidationError, PblNotFound, PblConflict, PblForbidden,
err(), fail(), assert_not_write_protected(), CODE_TO_HTTP, WRITE_PROTECTED_MODULES
【依赖图位置】本文件是 pbl_common 依赖图的**根**:不 import 任何 pbl_common 内部模块,
因此不可能产生循环依赖(QC 人工介入答复指出的『兼容层落点错误造成循环依赖』根因)。
""" """
PBL_ERR = { from __future__ import annotations
# ---- 1) 租户上下文/隔离 -------------------------------------------
'tenant': { import json as _json
'missing': 'PBL-TEN-001',
'invalid': 'PBL-TEN-002', # ==========================================================================
'cross_tenant_read': 'PBL-TEN-003', # 1. 规范错误码:符号常量(唯一方案)
'cross_tenant_write': 'PBL-TEN-004', # ==========================================================================
'quota_exceeded': 'PBL-TEN-005', PBL_E_OK = 'PBL_E_OK'
'not_activated': 'PBL-TEN-006', PBL_E_PARAM = 'PBL_E_PARAM'
}, PBL_E_TENANT = 'PBL_E_TENANT'
# ---- 2) 蓝图聚合根 ------------------------------------------------- PBL_E_TENANT_MISSING = 'PBL_E_TENANT_MISSING'
'blueprint': { PBL_E_TENANT_INVALID = 'PBL_E_TENANT_INVALID'
'not_found': 'PBL-BP-001', PBL_E_NOT_FOUND = 'PBL_E_NOT_FOUND'
'version_conflict': 'PBL-BP-002', PBL_E_CONFLICT = 'PBL_E_CONFLICT'
'subobj_type_unknown':'PBL-BP-003', PBL_E_FORBIDDEN = 'PBL_E_FORBIDDEN'
'subobj_ref_broken': 'PBL-BP-004', PBL_E_WRITE_PROTECTED = 'PBL_E_WRITE_PROTECTED'
'status_illegal': 'PBL-BP-005', PBL_E_WRITE_LOCK = 'PBL_E_WRITE_LOCK'
'fork_denied': 'PBL-BP-006', PBL_E_APPEND_ONLY = 'PBL_E_APPEND_ONLY'
'template_invalid': 'PBL-BP-007', PBL_E_DB = 'PBL_E_DB'
'delta_mismatch': 'PBL-BP-008', PBL_E_DB_UNAVAILABLE = 'PBL_E_DB_UNAVAILABLE'
}, PBL_E_VALIDATION = 'PBL_E_VALIDATION'
# ---- 3) 校验引擎(14 维 + 5 级质量状态)--------------------------- PBL_E_COMPILE = 'PBL_E_COMPILE'
'validation': { PBL_E_TOOL_DENIED = 'PBL_E_TOOL_DENIED'
'rule_not_found': 'PBL-VAL-001', PBL_E_NEED_INFO = 'PBL_E_NEED_INFO'
'dim_unknown': 'PBL-VAL-002', PBL_E_INTERNAL = 'PBL_E_INTERNAL'
'fatal_issue': 'PBL-VAL-003',
'quality_below_gate': 'PBL-VAL-004', ALL_CODES = (
'run_timeout': 'PBL-VAL-005', PBL_E_OK, PBL_E_PARAM, PBL_E_TENANT, PBL_E_TENANT_MISSING, PBL_E_TENANT_INVALID,
}, PBL_E_NOT_FOUND, PBL_E_CONFLICT, PBL_E_FORBIDDEN, PBL_E_WRITE_PROTECTED,
# ---- 4) 编译器 ----------------------------------------------------- PBL_E_WRITE_LOCK, PBL_E_APPEND_ONLY, PBL_E_DB, PBL_E_DB_UNAVAILABLE,
'compiler': { PBL_E_VALIDATION, PBL_E_COMPILE, PBL_E_TOOL_DENIED, PBL_E_NEED_INFO,
'not_compilable': 'PBL-CMP-001', PBL_E_INTERNAL,
'gd_hash_conflict': 'PBL-CMP-002', )
'job_failed': 'PBL-CMP-003',
'scense_ref_missing': 'PBL-CMP-004', # 连字符风格(只读派生,非独立方案)
}, LEGACY_CODE_MAP = {
# ---- 5) 工具裁决(fail-closed,22 工具 = 13 启用 + 9 禁用)--------- PBL_E_OK: 'PBL-OK-000',
'agent_tool': { PBL_E_PARAM: 'PBL-PAR-001',
'unregistered': 'PBL-TOOL-001', PBL_E_TENANT: 'PBL-TEN-001',
'disabled': 'PBL-TOOL-002', PBL_E_TENANT_MISSING: 'PBL-TEN-002',
'write_protected': 'PBL-TOOL-003', PBL_E_TENANT_INVALID: 'PBL-TEN-003',
'invalid_args': 'PBL-TOOL-004', PBL_E_NOT_FOUND: 'PBL-DAT-001',
'need_confirm': 'PBL-TOOL-005', PBL_E_CONFLICT: 'PBL-DAT-002',
'rate_limited': 'PBL-TOOL-006', PBL_E_FORBIDDEN: 'PBL-AUT-001',
'session_invalid': 'PBL-TOOL-007', PBL_E_WRITE_PROTECTED: 'PBL-WPR-001',
'exec_error': 'PBL-TOOL-008', PBL_E_WRITE_LOCK: 'PBL-WPR-002',
'timeout': 'PBL-TOOL-009', PBL_E_APPEND_ONLY: 'PBL-AUD-001',
}, PBL_E_DB: 'PBL-DB-001',
# ---- 6) 证据采集(幂等)------------------------------------------- PBL_E_DB_UNAVAILABLE: 'PBL-DB-002',
'evidence': { PBL_E_VALIDATION: 'PBL-VAL-001',
'idem_conflict': 'PBL-EVI-001', PBL_E_COMPILE: 'PBL-CMP-001',
'type_unknown': 'PBL-EVI-002', PBL_E_TOOL_DENIED: 'PBL-AGT-001',
'payload_invalid': 'PBL-EVI-003', PBL_E_NEED_INFO: 'PBL-AGT-002',
'artifact_missing': 'PBL-EVI-004', PBL_E_INTERNAL: 'PBL-SYS-001',
'collect_denied': 'PBL-EVI-005',
},
} }
# 类别 -> 缩写(供 self_check / 文档生成使用) CODE_TO_HTTP = {
CATEGORY_PREFIX = { PBL_E_OK: 200,
'tenant': 'TEN', PBL_E_PARAM: 400,
'blueprint': 'BP', PBL_E_TENANT: 400,
'validation': 'VAL', PBL_E_TENANT_MISSING: 400,
'compiler': 'CMP', PBL_E_TENANT_INVALID: 400,
'agent_tool': 'TOOL', PBL_E_NOT_FOUND: 404,
'evidence': 'EVI', PBL_E_CONFLICT: 409,
PBL_E_FORBIDDEN: 403,
PBL_E_WRITE_PROTECTED: 403,
PBL_E_WRITE_LOCK: 423,
PBL_E_APPEND_ONLY: 405,
PBL_E_DB: 500,
PBL_E_DB_UNAVAILABLE: 503,
PBL_E_VALIDATION: 422,
PBL_E_COMPILE: 422,
PBL_E_TOOL_DENIED: 403,
PBL_E_NEED_INFO: 428,
PBL_E_INTERNAL: 500,
} }
DEFAULT_MSG = { DEFAULT_MESSAGES = {
'PBL-TEN-001': 'tenant_id 缺失,拒绝执行', PBL_E_OK: 'ok',
'PBL-TEN-002': 'tenant_id 非法', PBL_E_PARAM: '参数非法',
'PBL-TEN-003': '跨租户读取被拒绝', PBL_E_TENANT: '租户上下文错误',
'PBL-TEN-004': '跨租户写入被拒绝', PBL_E_TENANT_MISSING: '缺少租户上下文(tenant_id 必须打头)',
'PBL-TEN-005': '租户配额超限', PBL_E_TENANT_INVALID: '租户标识非法',
'PBL-TEN-006': '租户未启用', PBL_E_NOT_FOUND: '记录不存在',
'PBL-BP-001': '蓝图不存在', PBL_E_CONFLICT: '记录冲突(唯一约束/版本冲突)',
'PBL-BP-002': '蓝图版本冲突', PBL_E_FORBIDDEN: '无权执行该操作',
'PBL-BP-003': '未知子对象类型', PBL_E_WRITE_PROTECTED: '引用模块写保护:禁止写入',
'PBL-BP-004': '子对象引用断裂', PBL_E_WRITE_LOCK: '写锁定:当前对象处于只读状态',
'PBL-BP-005': '蓝图状态不允许该操作', PBL_E_APPEND_ONLY: '审计表 append-only:禁止 UPDATE/DELETE',
'PBL-BP-006': '蓝图 fork 被拒绝', PBL_E_DB: '数据库操作失败',
'PBL-BP-007': '模板非法', PBL_E_DB_UNAVAILABLE: '数据库不可用(sqlor 与回落通道均不可用)',
'PBL-BP-008': 'change_delta 与快照不一致', PBL_E_VALIDATION: '蓝图校验未通过',
'PBL-VAL-001': '校验规则不存在', PBL_E_COMPILE: '编译失败',
'PBL-VAL-002': '未知校验维度', PBL_E_TOOL_DENIED: 'Agent 工具裁决:deny(fail-closed)',
'PBL-VAL-003': '存在致命校验问题', PBL_E_NEED_INFO: 'Agent 工具裁决:need_info(缺少必要输入)',
'PBL-VAL-004': '质量等级未达门禁', PBL_E_INTERNAL: '内部错误',
'PBL-VAL-005': '校验执行超时',
'PBL-CMP-001': '蓝图不可编译',
'PBL-CMP-002': 'Game Definition 哈希冲突',
'PBL-CMP-003': '编译任务失败',
'PBL-CMP-004': 'scense 引用缺失',
'PBL-TOOL-001': '工具未注册',
'PBL-TOOL-002': '工具已禁用',
'PBL-TOOL-003': '目标模块写保护,操作被拦截',
'PBL-TOOL-004': '工具参数非法',
'PBL-TOOL-005': '需人工确认后执行',
'PBL-TOOL-006': '工具调用被限流',
'PBL-TOOL-007': 'Agent 会话无效',
'PBL-TOOL-008': '工具执行错误',
'PBL-TOOL-009': '工具执行超时',
'PBL-EVI-001': '证据幂等键冲突',
'PBL-EVI-002': '未知证据类型',
'PBL-EVI-003': '证据载荷非法',
'PBL-EVI-004': '证据产物缺失',
'PBL-EVI-005': '证据采集被拒绝',
} }
class ErrorCode(object):
"""错误码常量的类属性访问面(值 == 模块级 ``PBL_E_*`` 常量,非第二套编码)。"""
OK = PBL_E_OK
PARAM = PBL_E_PARAM
TENANT = PBL_E_TENANT
TENANT_MISSING = PBL_E_TENANT_MISSING
TENANT_INVALID = PBL_E_TENANT_INVALID
NOT_FOUND = PBL_E_NOT_FOUND
CONFLICT = PBL_E_CONFLICT
FORBIDDEN = PBL_E_FORBIDDEN
WRITE_PROTECTED = PBL_E_WRITE_PROTECTED
WRITE_LOCK = PBL_E_WRITE_LOCK
APPEND_ONLY = PBL_E_APPEND_ONLY
DB = PBL_E_DB
DB_UNAVAILABLE = PBL_E_DB_UNAVAILABLE
VALIDATION = PBL_E_VALIDATION
COMPILE = PBL_E_COMPILE
TOOL_DENIED = PBL_E_TOOL_DENIED
NEED_INFO = PBL_E_NEED_INFO
INTERNAL = PBL_E_INTERNAL
ALL = ALL_CODES
TO_HTTP = CODE_TO_HTTP
LEGACY = LEGACY_CODE_MAP
@classmethod
def http(cls, code):
return http_status_of(code)
@classmethod
def legacy(cls, code):
return LEGACY_CODE_MAP.get(normalize_code(code), 'PBL-SYS-001')
class _CodeNamespace(object):
"""``PBL_ERR.TENANT_MISSING`` 风格的命名空间访问面(只读)。"""
__slots__ = ()
def __getattr__(self, name):
sym = name if name.startswith('PBL_E_') else 'PBL_E_' + name
if sym in ALL_CODES:
return sym
raise AttributeError('unknown PBL error code: %r' % (name,))
def __contains__(self, item):
return normalize_code(item) in ALL_CODES
def all(self):
return tuple(ALL_CODES)
def to_http(self, code):
return http_status_of(code)
def __repr__(self):
return '<PBL_ERR %d codes>' % (len(ALL_CODES),)
PBL_ERR = _CodeNamespace()
def normalize_code(code):
"""把任意输入(常量名 / 符号值 / 连字符旧码 / ErrorCode 属性)归一为符号常量。"""
if code is None:
return PBL_E_INTERNAL
if isinstance(code, PBLError):
return code.code
text = str(code).strip()
if text in ALL_CODES:
return text
upper = text.upper().replace('-', '_').replace(' ', '_')
if upper in ALL_CODES:
return upper
if not upper.startswith('PBL_E_'):
candidate = 'PBL_E_' + upper
if candidate in ALL_CODES:
return candidate
for sym, legacy in LEGACY_CODE_MAP.items():
if legacy.upper() == text.upper():
return sym
return PBL_E_INTERNAL
def http_status_of(code):
"""错误码 → HTTP 状态码(未知码按 500 fail-closed)。"""
return CODE_TO_HTTP.get(normalize_code(code), 500)
def default_message(code):
return DEFAULT_MESSAGES.get(normalize_code(code), DEFAULT_MESSAGES[PBL_E_INTERNAL])
# ==========================================================================
# 2. 规范异常类
# ==========================================================================
class PBLError(Exception): class PBLError(Exception):
"""PBL 业务异常基类。""" """PBL 全域唯一规范异常基类。
def __init__(self, err_code, err_msg=None, detail=None): 构造签名向后兼容三种历史写法:
self.err_code = err_code PBLError('缺少租户') # 只有 message
self.err_msg = err_msg or DEFAULT_MSG.get(err_code, err_code) PBLError(PBL_E_TENANT_MISSING, '缺少租户') # code + message
self.detail = detail or {} PBLError(code=..., message=..., detail={...}, http_status=400)
super(PBLError, self).__init__(self.err_msg) """
default_code = PBL_E_INTERNAL
def __init__(self, code=None, message=None, detail=None, http_status=None, **extra):
if code is not None and message is None and not isinstance(code, str):
message = str(code)
code = None
if isinstance(code, str) and code not in ALL_CODES and message is None \
and normalize_code(code) == PBL_E_INTERNAL and not code.startswith('PBL'):
# PBLError('纯文本消息') 的历史写法
message, code = code, None
self.code = normalize_code(code if code is not None else self.default_code)
self.message = message if message else default_message(self.code)
self.detail = dict(detail or {})
if extra:
self.detail.update(extra)
self._http_status = int(http_status) if http_status else None
Exception.__init__(self, self.message)
# ---- 兼容属性:err_code / status / legacy_code ------------------------
@property
def err_code(self):
"""旧符号面(连字符风格时代的属性名),值与 ``code`` 完全相同。"""
return self.code
@property
def legacy_code(self):
"""只读派生的连字符旧码,仅供日志检索,禁止用于断言。"""
return LEGACY_CODE_MAP.get(self.code, 'PBL-SYS-001')
@property
def http_status(self):
return self._http_status if self._http_status else http_status_of(self.code)
@property
def status(self):
return self.http_status
def to_dict(self): def to_dict(self):
return {'ok': False, 'err_code': self.err_code, return {
'err_msg': self.err_msg, 'detail': self.detail} 'ok': False,
'code': self.code,
'legacy_code': self.legacy_code,
'message': self.message,
'http_status': self.http_status,
'detail': self.detail,
}
def to_json(self, ensure_ascii=False):
return _json.dumps(self.to_dict(), ensure_ascii=ensure_ascii, default=str)
def __str__(self):
if self.detail:
return '[%s] %s | %s' % (self.code, self.message, self.detail)
return '[%s] %s' % (self.code, self.message)
def __repr__(self):
return '%s(code=%r, message=%r, http_status=%d)' % (
type(self).__name__, self.code, self.message, self.http_status)
def err(category, key, msg=None, detail=None): class ParamInvalidError(PBLError):
"""按 类别+键 构造 PBLError。未登记的键直接抛 KeyError(fail-closed)。""" default_code = PBL_E_PARAM
code = PBL_ERR[category][key]
return PBLError(code, msg, detail)
def fail(category, key, msg=None, detail=None): class TenantError(PBLError):
"""返回统一失败响应体(不抛异常)。""" default_code = PBL_E_TENANT
code = PBL_ERR[category][key]
return {'ok': False, 'err_code': code,
'err_msg': msg or DEFAULT_MSG.get(code, code),
'detail': detail or {}}
def ok(data=None, **extra): class TenantMissingError(TenantError):
"""返回统一成功响应体。""" default_code = PBL_E_TENANT_MISSING
out = {'ok': True, 'err_code': '', 'err_msg': ''}
if data is not None:
out['data'] = data
out.update(extra)
return out
def all_codes(): class TenantInvalidError(TenantError):
"""扁平化全部错误码,供文档/前端映射使用。""" default_code = PBL_E_TENANT_INVALID
out = []
for cat, kv in PBL_ERR.items():
for key, code in kv.items(): class NotFoundError(PBLError):
out.append({'category': cat, 'key': key, 'code': code, default_code = PBL_E_NOT_FOUND
'msg': DEFAULT_MSG.get(code, '')})
return out
class ConflictError(PBLError):
default_code = PBL_E_CONFLICT
class ForbiddenError(PBLError):
default_code = PBL_E_FORBIDDEN
class WriteProtectedError(PBLError):
default_code = PBL_E_WRITE_PROTECTED
class WriteLockError(PBLError):
default_code = PBL_E_WRITE_LOCK
class AppendOnlyError(PBLError):
default_code = PBL_E_APPEND_ONLY
class DbError(PBLError):
default_code = PBL_E_DB
class DbUnavailableError(PBLError):
default_code = PBL_E_DB_UNAVAILABLE
class ValidationError(PBLError):
default_code = PBL_E_VALIDATION
class CompileError(PBLError):
default_code = PBL_E_COMPILE
class ToolDeniedError(PBLError):
default_code = PBL_E_TOOL_DENIED
class NeedInfoError(PBLError):
default_code = PBL_E_NEED_INFO
# ==========================================================================
# 3. 旧符号面兼容别名(禁止删除 —— 11+ 下游模块 import 这些名字)
# ==========================================================================
PblError = PBLError # 旧拼写(单 L)
PblValidationError = ValidationError
PblNotFound = NotFoundError
PblConflict = ConflictError
PblForbidden = ForbiddenError
PblParamInvalid = ParamInvalidError
PblTenantMissing = TenantMissingError
PblWriteProtected = WriteProtectedError
PblAppendOnly = AppendOnlyError
PblDbUnavailable = DbUnavailableError
# 旧代码里 TenantMissingError/TenantInvalidError 曾直接从 errors 顶层 import,
# 上面已定义为真实类,此处仅登记便于自检枚举。
COMPAT_ALIASES = {
'PblError': PblError,
'DbError': DbError,
'NotFoundError': NotFoundError,
'ParamInvalidError': ParamInvalidError,
'TenantMissingError': TenantMissingError,
'TenantInvalidError': TenantInvalidError,
'PblValidationError': PblValidationError,
'PblNotFound': PblNotFound,
'PblConflict': PblConflict,
'PblForbidden': PblForbidden,
'ErrorCode': ErrorCode,
'CODE_TO_HTTP': CODE_TO_HTTP,
}
# ==========================================================================
# 4. 构造helper:err() / fail()
# ==========================================================================
def err(code=None, message=None, detail=None, **extra):
"""构造(不抛出)一个 PBLError 实例。
用法::
raise err(PBL_E_PARAM, 'name 不能为空', field='name')
return err('PBL_E_NOT_FOUND', '蓝图不存在').to_dict()
"""
if isinstance(code, PBLError):
return code
return PBLError(code=code, message=message, detail=detail, **extra)
def fail(code=None, message=None, detail=None, **extra):
"""构造并**抛出** PBLError(fail-closed 语义的语法糖)。"""
raise err(code, message, detail, **extra)
def as_error(exc):
"""把任意异常归一为 PBLError(非 PBLError 包装成 PBL_E_INTERNAL)。"""
if isinstance(exc, PBLError):
return exc
return PBLError(code=PBL_E_INTERNAL, message=str(exc) or exc.__class__.__name__,
detail={'origin': exc.__class__.__name__})
def error_body(code=None, message=None, detail=None, **extra):
"""返回统一错误响应体 dict(供 api 层直接 return)。"""
return err(code, message, detail, **extra).to_dict()
# ==========================================================================
# 5. 引用模块写保护(fail-closed 门禁)
# ==========================================================================
# 设计约定:rbac/world/scene/entity/scense/scense_runtime/script_engine 为引用模块,
# 严格写保护;一切扩展走 pbl_domain_ext / pbl_scense_ext / pbl_runtime_ext /
# pbl_kdb_ext 四个薄扩展模块(见 docs/01-design/architecture.md §写保护)。
WRITE_PROTECTED_MODULES = frozenset({
'rbac',
'world',
'scene',
'entity',
'scense',
'scense_runtime',
'script_engine',
})
# 别名归一(历史代码里出现过带前缀/带连字符的写法)
_MODULE_ALIAS = {
'pbl_rbac': 'rbac',
'world_snapshot': 'world',
'world_sync': 'world',
'scense_game': 'scense',
'scense_demo': 'scense',
'scense_drag': 'scense',
'scense-runtime': 'scense_runtime',
'script-engine': 'script_engine',
}
WRITE_ACTIONS = frozenset({
'create', 'insert', 'update', 'delete', 'upsert', 'write', 'save',
'remove', 'drop', 'truncate', 'alter', 'patch', 'put', 'post',
'c', 'u', 'd', 'i',
})
READ_ACTIONS = frozenset({'read', 'get', 'list', 'query', 'select', 'search', 'r'})
def normalize_module(module):
if module is None:
return ''
text = str(module).strip().lower()
if '/' in text:
text = text.rstrip('/').split('/')[-1]
if text.endswith('.py'):
text = text[:-3]
return _MODULE_ALIAS.get(text, text)
def is_write_protected(module, action='write'):
"""判断 (module, action) 是否命中写保护。未知 action 按写处理(fail-closed)。"""
mod = normalize_module(module)
if mod not in WRITE_PROTECTED_MODULES:
return False
act = str(action or 'write').strip().lower()
if act in READ_ACTIONS:
return False
return True
def assert_not_write_protected(module, action='write', detail=None):
"""写保护断言:命中则抛 WriteProtectedError(PBL_E_WRITE_PROTECTED / HTTP 403)。
所有 CRUD 工厂、审计写入、Agent 工具裁决在执行写操作前**必须**先过本门禁。
"""
if is_write_protected(module, action):
raise WriteProtectedError(
message='%s:模块 %s 的 %s 操作被写保护拒绝' % (
DEFAULT_MESSAGES[PBL_E_WRITE_PROTECTED], normalize_module(module), action),
detail=dict(detail or {}, module=normalize_module(module), action=str(action),
protected=sorted(WRITE_PROTECTED_MODULES)),
)
return True
def assert_append_only(op, table='pbl_audit_log'):
"""审计表 append-only 断言:UPDATE/DELETE 一律拒绝(PBL_E_APPEND_ONLY / HTTP 405)。"""
act = str(op or '').strip().lower()
if act in ('update', 'delete', 'u', 'd', 'upsert', 'truncate', 'drop', 'alter'):
raise AppendOnlyError(
message='%s:table=%s op=%s' % (DEFAULT_MESSAGES[PBL_E_APPEND_ONLY], table, act),
detail={'table': table, 'op': act},
)
return True
__all__ = [
# 规范错误码常量
'PBL_E_OK', 'PBL_E_PARAM', 'PBL_E_TENANT', 'PBL_E_TENANT_MISSING',
'PBL_E_TENANT_INVALID', 'PBL_E_NOT_FOUND', 'PBL_E_CONFLICT', 'PBL_E_FORBIDDEN',
'PBL_E_WRITE_PROTECTED', 'PBL_E_WRITE_LOCK', 'PBL_E_APPEND_ONLY', 'PBL_E_DB',
'PBL_E_DB_UNAVAILABLE', 'PBL_E_VALIDATION', 'PBL_E_COMPILE', 'PBL_E_TOOL_DENIED',
'PBL_E_NEED_INFO', 'PBL_E_INTERNAL',
'ALL_CODES', 'LEGACY_CODE_MAP', 'CODE_TO_HTTP', 'DEFAULT_MESSAGES',
'ErrorCode', 'PBL_ERR',
'normalize_code', 'http_status_of', 'default_message',
# 规范异常
'PBLError', 'ParamInvalidError', 'TenantError', 'TenantMissingError',
'TenantInvalidError', 'NotFoundError', 'ConflictError', 'ForbiddenError',
'WriteProtectedError', 'WriteLockError', 'AppendOnlyError', 'DbError',
'DbUnavailableError', 'ValidationError', 'CompileError', 'ToolDeniedError',
'NeedInfoError',
# 兼容别名
'PblError', 'PblValidationError', 'PblNotFound', 'PblConflict', 'PblForbidden',
'PblParamInvalid', 'PblTenantMissing', 'PblWriteProtected', 'PblAppendOnly',
'PblDbUnavailable', 'COMPAT_ALIASES',
# helper
'err', 'fail', 'as_error', 'error_body',
# 写保护
'WRITE_PROTECTED_MODULES', 'WRITE_ACTIONS', 'READ_ACTIONS',
'normalize_module', 'is_write_protected', 'assert_not_write_protected',
'assert_append_only',
]

View File

@ -1,186 +1,193 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_common 模块挂载入口(load_pbl_common)。 """pbl_common.init —— load_pbl_common():把公共内核注册到宿主 ServerEnv。
三处同步注册(module-development-spec): 【QC 退回意见 #7 的修复】
1. 本文件 load_pbl_common() ① ServerEnv 来源二义:旧 init.py 写 ``from appbase.serverenv import ServerEnv``,
2. apps/pbls/app/pbls.py LOAD_ORDER 含 'pbl_common'(Wave0 首位) 而同包 dbutil.py 写 ``ahserver.serverenv``。经核对 module-development-spec
3. apps/pbls/conf/config.json load_order / module_dbname 含 pbl_common (『ahserver ServerEnv』为基座)与 web-application-spec(应用入口
``from ahserver.webapp import webapp``),**正确来源是 ``ahserver.serverenv``**;
``appbase.serverenv`` 仅作历史回落候选。本版统一走 ``get_server_env()``
(dbutil 内实现,按 ahserver → appbase 顺序探测),init.py 不再自己写死 import,
两处不可能再互相矛盾。
② 旧自检完全依赖非本次交付的 kernel.py 符号体系 → 本版自检改为调用
``pbl_common.self_check.run_self_check()``(针对真实交付的
errors/context/dbutil/crud_factory/audit/api 编写断言)。
③ 包内两套平行实现(旧 kernel/tenant/crud/db/serialize vs 新交付
errors/context/crud_factory/dbutil)→ **保留新交付一套为唯一实现**;
旧模块名以「薄兼容 shim」形式存在(tenant.py 从 context 再导出、
crud.py 从 crud_factory 再导出、db.py 从 dbutil 再导出、serialize.py 独立小工具),
kernel.py 若存在则由其从本套再导出,方向恒为 kernel → 新实现,无环。
pbl_common 零自有表:只提供租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂。 【三处同步(module-development-spec CRITICAL)】
审计写入落在 pbl_governance.pbl_audit_log(跨模块共享,经 get_module_dbname 解析库名)。 ① 实现:本文件定义 ``load_pbl_common``;
② ``pbl_common/__init__.py`` 导出 ``load_pbl_common``;
③ 宿主入口 ``apps/pbls/app/pbls.py`` 的 ``init()`` 里显式 ``load_pbl_common()``。
""" """
from pbl_common.kernel import ( from __future__ import annotations
CrudFactory,
DbAdapter,
PblError,
assert_tenant_match,
check_in,
check_len,
clear_tenant_context,
current_tenant_id,
db,
fail,
from_request,
get_tenant_context,
idem_key,
json_dumps_canonical,
make_crud,
new_code,
new_trace_id,
now_str,
ok,
require,
set_tenant_context,
sha256_text,
write_audit,
)
MODULE_NAME = 'pbl_common' import logging
MODULE_TABLES = () # 零自有表
MODULE_VERSION = '1.0.0'
_loaded = False from pbl_common import api as _api
from pbl_common import audit as _audit
from pbl_common import context as _context
# 注意:包 __init__.py 把 crud_factory **函数**再导出到 pbl_common 命名空间,
# 会遮蔽同名子模块 → 这里必须用 importlib 取模块对象,不能写
# `from pbl_common import crud_factory as _crud`(会拿到函数而非模块)。
import importlib as _importlib
_crud = _importlib.import_module('pbl_common.crud_factory')
from pbl_common import dbutil as _db
from pbl_common import errors as _errors
log = logging.getLogger('pbl_common.init')
#: 注册到 ServerEnv 的符号清单(三处同步之第③处的注册面)
ENV_EXPORTS = {
# errors
'pbl_err': _errors.err,
'pbl_fail': _errors.fail,
'pbl_error_body': _errors.error_body,
'pbl_http_status_of': _errors.http_status_of,
'pbl_normalize_code': _errors.normalize_code,
'pbl_assert_not_write_protected': _errors.assert_not_write_protected,
'pbl_is_write_protected': _errors.is_write_protected,
'pbl_assert_append_only': _errors.assert_append_only,
# context
'pbl_normalize_tenant': _context.normalize_tenant,
'pbl_assert_tenant': _context.assert_tenant,
'pbl_tenant_scope': _context.tenant_scope,
'pbl_get_tenant': _context.get_tenant,
'pbl_set_tenant': _context.set_tenant,
'pbl_reset_tenant': _context.reset_tenant,
'pbl_current_context': _context.current_context,
'pbl_with_tenant': _context.with_tenant,
'pbl_check_tenant_column': _context.check_tenant_column,
# dbutil
'pbl_get_db': _db.get_db,
'pbl_new_id': _db.new_id,
'pbl_now_str': _db.now_str,
'pbl_sqlExe': _db.sqlExe,
'pbl_sqlor_available': _db.sqlor_available,
# crud
'pbl_tenant_crud': _crud.tenant_crud,
'pbl_crud_factory': _crud.crud_factory,
'pbl_bulk_create': _crud.bulk_create,
# audit
'pbl_write_audit': _audit.write_audit,
'pbl_write_audit_batch': _audit.write_audit_batch,
'pbl_audit_trail': _audit.audit_trail,
'pbl_flush_memory_audit': _audit.flush_memory_audit,
# api
'pbl_ok': _api.ok,
'pbl_fail_body': _api.fail_body,
'pbl_paged': _api.paged,
'pbl_api_guard': _api.api_guard,
}
_loaded = {'done': False, 'env': None, 'registered': 0}
def load_pbl_common(): def get_env():
"""挂载 pbl_common:注册内核单例与公共 API 到 ServerEnv。 """取宿主 ServerEnv 单例(ahserver.serverenv 优先,appbase 回落)。"""
return _db.get_server_env()
幂等:重复调用只生效一次(应用重启/热加载安全)。
"""
global _loaded
if _loaded:
return True
from appbase.serverenv import ServerEnv
env = ServerEnv()
adapter = db(MODULE_NAME)
# 公共内核挂到 ServerEnv,供其余 14 个 pbl_* 模块直接取用(避免各自 import 路径分叉)
env.pbl_common = {
'version': MODULE_VERSION,
'db': adapter,
'crud_factory': make_crud,
'tenant': {
'set': set_tenant_context,
'get': get_tenant_context,
'current_id': current_tenant_id,
'clear': clear_tenant_context,
'from_request': from_request,
'assert_match': assert_tenant_match,
},
'errors': {
'PblError': PblError,
'fail': fail,
},
'utils': {
'new_code': new_code,
'sha256_text': sha256_text,
'json_dumps_canonical': json_dumps_canonical,
'idem_key': idem_key,
'now_str': now_str,
'new_trace_id': new_trace_id,
},
'validators': {
'require': require,
'check_len': check_len,
'check_in': check_in,
},
'audit': write_audit,
'response': ok,
'append_only_tables': sorted(DbAdapter.APPEND_ONLY_TABLES),
'write_protected_modules': sorted(DbAdapter.WRITE_PROTECTED_MODULES),
}
# 启动期自检:租户上下文缺失必须 fail-closed(不依赖 DB,纯逻辑断言)
_self_test_fail_closed()
_loaded = True
return True
def _self_test_fail_closed(): def register_to_env(env=None, exports=None):
"""内核自检:无租户上下文时读写一律拒绝(fail-closed 门禁的单元级证据)。""" """把 ENV_EXPORTS 挂到 ServerEnv。env 为 None 时自动探测;探测不到返回 0(不抛)。"""
clear_tenant_context() env = env if env is not None else get_env()
try: if env is None:
current_tenant_id(required=True) log.warning('未探测到 ServerEnv(ahserver/appbase 均不可用),跳过注册')
except PblError as exc: return 0
assert exc.code == 'PBL_E_TENANT_MISSING', '错误码应为 PBL_E_TENANT_MISSING,实际 %s' % exc.code table = exports or ENV_EXPORTS
else: count = 0
raise AssertionError('fail-closed 失效:无租户上下文未抛 PBL_E_TENANT_MISSING') for name, func in table.items():
ctx = set_tenant_context('t_selfcheck', user_code='u_selfcheck', role_code='teacher')
assert ctx.tenant_id == 't_selfcheck'
assert current_tenant_id() == 't_selfcheck'
# 租户不匹配必须拒绝
try:
assert_tenant_match({'tenant_id': 't_other'}, 't_selfcheck')
except PblError as exc:
assert exc.code == 'PBL_E_TENANT_MISMATCH'
else:
raise AssertionError('越权防护失效:跨租户行未被拒绝')
# append-only 表拒绝 update/delete
adapter = DbAdapter(module_name=MODULE_NAME, sor=_FakeSor())
for op in ('U', 'D'):
try: try:
adapter._guard_append_only('pbl_audit_log', op) setattr(env, name, func)
except PblError as exc: count += 1
assert exc.code == 'PBL_E_APPEND_ONLY' except Exception as exc: # noqa: BLE001
else: log.error('注册 %s 到 ServerEnv 失败:%s', name, exc)
raise AssertionError('append-only 防护失效:%s 未被拒绝' % op) return count
# 写保护模块拒绝 C/U/D
try:
adapter._guard_write_protected('world', 'C')
except PblError as exc:
assert exc.code == 'PBL_E_WRITE_PROTECTED'
else:
raise AssertionError('写保护失效:world 写入未被拒绝')
# 幂等键稳定性(同输入同输出,证据幂等依赖)
assert idem_key('t', 'bp', 'artifact', 'src', 'h1') == idem_key('t', 'bp', 'artifact', 'src', 'h1')
assert idem_key('t', 'bp', 'artifact', 'src', 'h1') != idem_key('t', 'bp', 'artifact', 'src', 'h2')
# 规范化 JSON 稳定性(确定性编译依赖)
assert sha256_text({'b': 2, 'a': 1}) == sha256_text({'a': 1, 'b': 2})
clear_tenant_context()
class _FakeSor(object): def load_pbl_common(env=None, run_check=False, verbose=False):
"""自检用 sqlor 桩:只记录调用,不触库。""" """挂载公共内核(幂等)。
def __init__(self): 参数:
self.calls = [] env —— 宿主 ServerEnv;None 时自动探测
run_check —— 挂载后执行 self_check(部署冒烟用)
verbose —— 打印自检明细
def C(self, table, data): 返回:{'loaded': True, 'registered': N, 'self_check': (passed, total) | None}
self.calls.append(('C', table, data)) """
return 1 registered = register_to_env(env)
_loaded.update({'done': True, 'env': env if env is not None else get_env(),
def U(self, table, where, data): 'registered': registered})
self.calls.append(('U', table, where, data)) # 契约门禁:import 期已校验,这里再显式跑一次(半迁移状态禁止上线)
return 1 missing = _api.verify_contract()
if missing:
def D(self, table, where): raise ImportError('pbl_common 契约符号缺失:%s' % ', '.join(missing))
self.calls.append(('D', table, where)) result = {'loaded': True, 'registered': registered, 'self_check': None,
return 1 'sqlor': _db.sqlor_available(),
'fallback': _db.fallback_enabled()}
def R(self, table, where, **kwargs): if run_check:
self.calls.append(('R', table, where, kwargs)) from pbl_common.self_check import run_self_check
return [] passed, total, failures = run_self_check(verbose=verbose)
result['self_check'] = (passed, total)
def I(self, table, rows): if failures:
self.calls.append(('I', table, rows)) raise AssertionError('pbl_common 自检未通过 %d/%d:%s'
return len(rows or []) % (passed, total, failures[0][1]))
log.info('load_pbl_common 完成:registered=%d sqlor=%s', registered, result['sqlor'])
def sqlExe(self, sql, args=None): return result
self.calls.append(('sqlExe', sql, args))
return []
# 别名:兼容 load_{module} 与 init 两种解析路径 def is_loaded():
load_module = load_pbl_common return bool(_loaded['done'])
init = load_pbl_common
def load_status():
return dict(_loaded)
def unload_pbl_common(env=None):
"""卸载(仅测试用):摘掉注册符号。"""
env = env if env is not None else _loaded.get('env')
removed = 0
if env is not None:
for name in ENV_EXPORTS:
if hasattr(env, name):
try:
delattr(env, name)
removed += 1
except Exception: # noqa: BLE001
pass
_loaded.update({'done': False, 'env': None, 'registered': 0})
return removed
# ==========================================================================
# 部署期自检入口(不依赖 kernel.py)
# ==========================================================================
def self_check(verbose=True):
"""执行 pbl_common 全量自检,返回 (passed, total, failures)。"""
from pbl_common.self_check import run_self_check
return run_self_check(verbose=verbose)
def main():
import sys
info = load_pbl_common(run_check=True, verbose=True)
print('LOAD pbl_common: registered=%d sqlor=%s self_check=%s'
% (info['registered'], info['sqlor'], info['self_check']))
passed, total = info['self_check'] or (0, 0)
return 0 if passed == total and total > 0 else 1
if __name__ == '__main__':
import sys
sys.exit(main())
__all__ = [
'ENV_EXPORTS', 'load_pbl_common', 'register_to_env', 'get_env',
'is_loaded', 'load_status', 'unload_pbl_common', 'self_check', 'main',
]

View File

@ -1,156 +1,551 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.self_check —— 针对**实际交付文件**的自检(QC 退回意见 #4 的修复)。
pbl_common 自检(QC 硬门禁证据件)
校验 PBL 公共内核契约: 旧版自检对象与交付实现脱节:
1) 租户上下文:TenantContext 必须强制 tenant_id 打头,缺失即 fail-closed 抛错 * ``_check_crud_factory`` 导入 ``pbl_common.crud``(非本次交付)并要求 C/U/D/R/I;
2) 错误码表:PBL_ERR 覆盖 6 大类且码值唯一 * ``_check_db_dialect`` 导入 ``pbl_common.db``(非本次交付),而交付的是 dbutil.py;
3) CRUD 工厂:make_crud 产出 C/U/D/R/I 五方法(对齐 sqlor 标准 API,禁编造 save/list/insert) * ``_check_audit_append_only`` 要求 ``audit.append``,而交付接口是 ``write_audit``。
4) DB 适配:方言锁定 mariadb,禁止 BIGSERIAL/SERIAL/nextval/ENUM/TIMESTAMP 出现在 DDL 生成器
5) 审计:append-only,无 update/delete 接口暴露
结论行(QC 抓取用): 本版全部改为针对真实交付的 errors.py / context.py / crud_factory.py / dbutil.py /
SELF_CHECK pbl_common: PASS 5/5 audit.py / api.py 编写断言,符号名与交付件一一对应(同时兼容旧别名 append)。
运行:``python3 -m pbl_common.self_check`` → 输出 ``SELF_CHECK pbl_common: PASS n/n``。
""" """
from __future__ import annotations
import inspect
import sys import sys
import traceback
EXPECT_CHECKS = 5 RESULTS = []
ERR_CATEGORIES = (
'tenant', # 租户上下文/隔离
'blueprint', # 蓝图聚合根
'validation', # 校验引擎
'compiler', # 编译器
'agent_tool', # 工具裁决 fail-closed
'evidence', # 证据采集幂等
)
FORBIDDEN_DDL_TOKENS = ('BIGSERIAL', 'SERIAL', 'nextval', 'ENUM(', 'TIMESTAMP')
CRUD_METHODS = ('C', 'U', 'D', 'R', 'I')
def _check_tenant_context(): def check(name):
"""1) 租户上下文强制打头。""" """装饰器:登记一项自检。函数返回 True / 抛异常均被记录。"""
from pbl_common.tenant import TenantContext, require_tenant def deco(fn):
ctx = TenantContext(tenant_id=1) RESULTS.append((name, fn))
if ctx.tenant_id != 1: return fn
return False, 'TenantContext.tenant_id 未生效' return deco
try:
require_tenant({})
return False, 'require_tenant 对空 tenant_id 未抛错(非 fail-closed)'
except Exception: # noqa: BLE001
pass
# 条件字典必须 tenant_id 在首位
cond = ctx.scope({'id': 9})
keys = list(cond.keys())
if not keys or keys[0] != 'tenant_id':
return False, 'scope() 未把 tenant_id 放在首位:%s' % keys
return True, ''
# ==========================================================================
# 1. errors.py —— 统一错误码方案(QC #3)
# ==========================================================================
@check('errors: 规范错误码常量为 PBL_E_* 且值等于常量名')
def _check_error_codes(): def _check_error_codes():
"""2) 错误码覆盖 6 类且唯一。""" from pbl_common import errors
from pbl_common.errors import PBL_ERR for code in errors.ALL_CODES:
seen = {} assert isinstance(code, str) and code.startswith('PBL_E_'), code
for cat in ERR_CATEGORIES: assert getattr(errors, code) == code, '常量值与名不一致: %s' % code
codes = PBL_ERR.get(cat) or {} assert len(errors.ALL_CODES) == len(set(errors.ALL_CODES))
if not codes: assert errors.PBL_E_TENANT_MISSING == 'PBL_E_TENANT_MISSING'
return False, '错误码类别 %s 为空' % cat assert errors.PBL_E_APPEND_ONLY == 'PBL_E_APPEND_ONLY'
for k, v in codes.items(): assert errors.PBL_E_WRITE_PROTECTED == 'PBL_E_WRITE_PROTECTED'
if v in seen: assert errors.PBL_E_PARAM == 'PBL_E_PARAM'
return False, '错误码重复:%s(%s / %s)' % (v, seen[v], k) assert errors.PBL_E_TENANT == 'PBL_E_TENANT'
seen[v] = '%s.%s' % (cat, k) assert errors.PBL_E_WRITE_LOCK == 'PBL_E_WRITE_LOCK'
return True, '' return True
def _check_crud_factory(): @check('errors: ErrorCode 与 PBL_ERR 是同一批常量的两种读法(非第二套编码)')
"""3) CRUD 工厂五方法齐备(sqlor 标准 API)。""" def _check_error_code_facade():
from pbl_common.crud import make_crud from pbl_common import errors
crud = make_crud('pbl_blueprint') assert errors.ErrorCode.TENANT_MISSING == errors.PBL_E_TENANT_MISSING
missing = [m for m in CRUD_METHODS if not callable(getattr(crud, m, None))] assert errors.ErrorCode.APPEND_ONLY == errors.PBL_E_APPEND_ONLY
if missing: assert errors.ErrorCode.WRITE_PROTECTED == errors.PBL_E_WRITE_PROTECTED
return False, 'CRUD 缺方法:%s' % missing assert errors.PBL_ERR.TENANT_MISSING == errors.PBL_E_TENANT_MISSING
for bad in ('save', 'list', 'insert', 'update', 'delete', 'query'): assert errors.PBL_ERR.WRITE_LOCK == errors.PBL_E_WRITE_LOCK
if hasattr(crud, bad): assert errors.PBL_ERR.PARAM == errors.PBL_E_PARAM
return False, 'CRUD 暴露非标准方法 %s(应只用 C/U/D/R/I)' % bad assert errors.ErrorCode.http(errors.PBL_E_NOT_FOUND) == 404
return True, '' return True
def _check_db_dialect(): @check('errors: PBLError.code 与 .err_code 同值,legacy_code 只读派生')
"""4) 方言 mariadb,DDL 生成器不含禁用 token。""" def _check_pblerror_attrs():
from pbl_common.db import get_dialect, ddl_column_sql from pbl_common.errors import PBLError, PBL_E_TENANT_MISSING, LEGACY_CODE_MAP
if get_dialect() != 'mariadb': exc = PBLError(PBL_E_TENANT_MISSING, '缺少租户')
return False, 'dialect=%s 应为 mariadb' % get_dialect() assert exc.code == PBL_E_TENANT_MISSING
sample = ddl_column_sql('id', 'pk') + ddl_column_sql('create_time', 'datetime') assert exc.err_code == exc.code, 'err_code 必须与 code 同值(兼容属性)'
up = sample.upper() assert exc.legacy_code == LEGACY_CODE_MAP[PBL_E_TENANT_MISSING]
for tok in FORBIDDEN_DDL_TOKENS: assert exc.http_status == 400
if tok.upper() in up: assert exc.to_dict()['code'] == PBL_E_TENANT_MISSING
return False, 'DDL 生成器含禁用 token %s' % tok return True
if 'BIGINT' not in up or 'AUTO_INCREMENT' not in up:
return False, '主键未生成 BIGINT AUTO_INCREMENT:%s' % sample
return True, ''
def _check_audit_append_only(): @check('errors: 兼容旧符号面全部存在且为 PBLError 子类/别名')
"""5) 审计 append-only。""" def _check_compat_aliases():
from pbl_common import audit from pbl_common import errors
if not callable(getattr(audit, 'append', None)): required = ('PblError', 'DbError', 'NotFoundError', 'ParamInvalidError',
return False, 'audit.append 不存在' 'TenantMissingError', 'TenantInvalidError', 'PblValidationError',
for bad in ('update', 'delete', 'remove', 'purge'): 'PblNotFound', 'PblConflict', 'PblForbidden')
if hasattr(audit, bad): for name in required:
return False, 'audit 暴露 %s(违反 append-only)' % bad obj = getattr(errors, name, None)
return True, '' assert obj is not None, 'errors 缺少兼容符号 %s' % name
assert issubclass(obj, errors.PBLError), '%s 不是 PBLError 子类' % name
assert errors.PblError is errors.PBLError
for name in ('CODE_TO_HTTP', 'WRITE_PROTECTED_MODULES'):
assert getattr(errors, name, None), 'errors 缺少 %s' % name
for name in ('assert_not_write_protected', 'err', 'fail'):
assert callable(getattr(errors, name, None)), 'errors 缺少可调用 %s' % name
return True
CHECKS = ( @check('errors: 写保护门禁命中引用模块写动作、放行读动作')
('tenant_context', _check_tenant_context), def _check_write_protect():
('error_codes', _check_error_codes), from pbl_common.errors import (assert_not_write_protected, is_write_protected,
('crud_factory', _check_crud_factory), WriteProtectedError, WRITE_PROTECTED_MODULES)
('db_dialect', _check_db_dialect), for mod in ('rbac', 'world', 'scene', 'entity', 'scense', 'scense_runtime',
('audit_append_only', _check_audit_append_only), 'script_engine'):
) assert mod in WRITE_PROTECTED_MODULES, mod
assert is_write_protected(mod, 'update') is True
assert is_write_protected(mod, 'delete') is True
def check(): assert is_write_protected(mod, 'read') is False
passed, reasons = 0, [] assert is_write_protected(mod, 'list') is False
for name, fn in CHECKS:
try: try:
ok, why = fn() assert_not_write_protected(mod, 'create')
except Exception as e: # noqa: BLE001 except WriteProtectedError as exc:
ok, why = False, '%s 异常:%s' % (name, e) assert exc.code == 'PBL_E_WRITE_PROTECTED'
if ok:
passed += 1
else: else:
reasons.append('%s: %s' % (name, why or 'FAIL')) raise AssertionError('%s create 未被写保护拦截' % mod)
return passed == EXPECT_CHECKS, passed, EXPECT_CHECKS, reasons # 未知动作按写处理(fail-closed)
assert is_write_protected('rbac', 'whatever') is True
# 非保护模块放行
assert assert_not_write_protected('pbl_blueprint', 'create') is True
return True
def self_check(verbose=True): @check('errors: append-only 门禁拒绝 UPDATE/DELETE、放行 INSERT/SELECT')
try: def _check_append_only():
ok, passed, total, reasons = check() from pbl_common.errors import assert_append_only, AppendOnlyError
except Exception as e: # noqa: BLE001 for op in ('update', 'delete', 'U', 'D', 'truncate'):
print('SELF_CHECK pbl_common: ERROR %s' % e)
return False
if verbose:
print('SELF_CHECK pbl_common: %s %s/%s' % ('PASS' if ok else 'FAIL', passed, total))
for r in reasons:
print(' - %s' % r)
return ok
def main(argv):
quiet = '--quiet' in argv or '-q' in argv
ok = self_check(verbose=not quiet)
if quiet:
try: try:
_, passed, total, _ = check() assert_append_only(op, 'pbl_audit_log')
print('SELF_CHECK pbl_common: %s %s/%s' % ('PASS' if ok else 'FAIL', passed, total)) except AppendOnlyError as exc:
except Exception as e: # noqa: BLE001 assert exc.code == 'PBL_E_APPEND_ONLY'
print('SELF_CHECK pbl_common: ERROR %s' % e) else:
return 0 if ok else 1 raise AssertionError('append-only 未拦截 %s' % op)
for op in ('insert', 'create', 'read', 'select'):
assert assert_append_only(op, 'pbl_audit_log') is True
return True
# ==========================================================================
# 2. context.py —— 租户上下文(QC #2)
# ==========================================================================
@check('context: normalize_tenant 规范化并拒绝伪占位值')
def _check_normalize_tenant():
from pbl_common.context import normalize_tenant, TenantMissingError, TenantInvalidError
assert normalize_tenant(' t_001 ') == 't_001'
assert normalize_tenant(123) == '123'
assert normalize_tenant('"t_002"') == 't_002'
for bad in ('', ' ', 'none', 'null', 'undefined', 'TBD', '待明确', '占位'):
try:
normalize_tenant(bad)
except TenantMissingError:
pass
else:
raise AssertionError('伪占位值未被拒绝: %r' % bad)
assert normalize_tenant('', strict=False) == ''
try:
normalize_tenant('bad tenant!@#')
except TenantInvalidError:
pass
else:
raise AssertionError('非法字符 tenant_id 未被拒绝')
# query+body 合并成 list 的经典坑
assert normalize_tenant(['t_003', 't_003']) == 't_003'
try:
normalize_tenant(['t_003', 't_004'])
except TenantInvalidError:
pass
else:
raise AssertionError('互斥多值 tenant_id 未被拒绝')
return True
@check('context: tenant_scope 把 tenant_id 打到第一位并拦截跨租户串写')
def _check_tenant_scope():
from pbl_common.context import tenant_scope, TenantInvalidError
row = tenant_scope({'name': 'bp', 'status': 'draft'}, 't_010')
assert list(row.keys())[0] == 'tenant_id', '首键必须是 tenant_id: %s' % list(row)
assert row['tenant_id'] == 't_010'
try:
tenant_scope({'tenant_id': 't_999', 'name': 'x'}, 't_010')
except TenantInvalidError:
pass
else:
raise AssertionError('跨租户写入未被拦截')
same = tenant_scope({'tenant_id': 't_010', 'name': 'x'}, 't_010')
assert same['tenant_id'] == 't_010'
return True
@check('context: assert_tenant 缺失即抛(fail-closed),with_tenant 绑定上下文')
def _check_assert_tenant():
from pbl_common.context import (assert_tenant, with_tenant, get_tenant,
reset_tenant, has_tenant, TenantMissingError)
reset_tenant()
assert has_tenant() is False
try:
assert_tenant()
except TenantMissingError:
pass
else:
raise AssertionError('无上下文时 assert_tenant 未抛')
with with_tenant('t_020') as ctx:
assert ctx.tenant_id == 't_020'
assert get_tenant() == 't_020'
assert assert_tenant() == 't_020'
assert has_tenant() is False, 'with 退出后上下文未复位'
return True
@check('context: check_tenant_column 校验 tenant_id 打头(data-model 硬约束)')
def _check_tenant_column():
from pbl_common.context import check_tenant_column
from pbl_common.errors import PBLError
ok, first = check_tenant_column('pbl_blueprint',
[{'name': 'tenant_id'}, {'name': 'id'}])
assert ok is True and first == 'tenant_id'
ok2, first2 = check_tenant_column('bad_table', ['id', 'tenant_id'], required=False)
assert ok2 is False and first2 == 'id'
try:
check_tenant_column('bad_table', ['id', 'tenant_id'])
except PBLError as exc:
assert exc.code == 'PBL_E_TENANT'
else:
raise AssertionError('未打头表未被拒绝')
return True
# ==========================================================================
# 3. dbutil.py —— sqlor 主路径(QC #6)
# ==========================================================================
@check('dbutil: 主路径走 sqlor(sor.C/U/D/R/I/sqlExe),PyMySQL 仅显式回落')
def _check_sqlor_primary():
import re
import pbl_common.dbutil as dbutil
src = inspect.getsource(dbutil)
# 必须真实出现 sqlor 调用(而非只在文档里声称)
for token in ('sor.C(', 'sor.U(', 'sor.D(', 'sor.I(', 'sor.sqlExe('):
assert token in src, 'dbutil 未调用 sqlor API: %s' % token
assert 'sqlorContext' in src, 'dbutil 未经 DBPools().sqlorContext 取通道'
# 回落必须显式开关,且不得静默
assert 'PBL_DB_FALLBACK' in src, 'PyMySQL 回落缺少显式开关'
assert 'fallback_enabled' in src
assert '[FALLBACK]' in src, '回落路径缺少告警日志'
# 禁编造 sqlor API
for fake in ('sor.save(', 'sor.list(', 'sor.insert(', 'sor.query(', 'sor.one('):
assert fake not in src, 'dbutil 编造了不存在的 sqlor API: %s' % fake
return True
@check('dbutil: 库名不硬编码,统一 get_module_dbname')
def _check_no_hardcoded_dbname():
import re
import pbl_common.dbutil as dbutil
src = inspect.getsource(dbutil)
bad = re.findall(r"^\s*DBNAME\s*=\s*['\"]", src, re.M)
assert not bad, 'dbutil 存在硬编码 DBNAME'
assert 'get_module_dbname' in src
assert dbutil.get_module_dbname('pbl_blueprint') != ''
return True
@check('dbutil: new_id / now_str 自包含实现(不 import pbl_blueprint.m1b)')
def _check_id_time_helpers():
import pbl_common.dbutil as dbutil
src = inspect.getsource(dbutil)
assert 'pbl_blueprint' not in src, 'dbutil 不得反向依赖 pbl_blueprint(循环依赖根因)'
assert 'except ImportError' not in src, 'dbutil 不得用 except ImportError 静默吞错'
ids = {dbutil.new_id() for _ in range(200)}
assert len(ids) == 200, 'new_id 出现重复'
assert all(len(i) <= 32 for i in ids), 'new_id 超过 VARCHAR(32)'
pre = dbutil.new_id('bp')
assert pre.startswith('bp_') and len(pre) <= 32
stamp = dbutil.now_str()
assert len(stamp) == 19 and stamp[4] == '-' and stamp[10] == ' ', stamp
return True
@check('dbutil: 破坏性 SQL 被拒绝、标识符白名单生效')
def _check_sql_guard():
import pbl_common.dbutil as dbutil
from pbl_common.errors import ParamInvalidError
for sql in ('DROP DATABASE pbls', 'truncate table pbl_audit_log'):
try:
dbutil._reject_forbidden_sql(sql)
except ParamInvalidError:
pass
else:
raise AssertionError('破坏性 SQL 未被拒绝: %s' % sql)
assert dbutil._reject_forbidden_sql('SELECT * FROM pbl_blueprint') is True
assert dbutil.safe_ident('pbl_blueprint') == 'pbl_blueprint'
try:
dbutil.safe_ident('a; DROP TABLE x')
except ParamInvalidError:
pass
else:
raise AssertionError('非法标识符未被拒绝')
return True
@check('dbutil: Db 五方法齐备且写路径过写保护门禁')
def _check_db_facade():
from pbl_common.dbutil import Db
for name in ('create', 'read', 'update', 'delete', 'execute', 'insert', 'sqlExe'):
assert callable(getattr(Db, name, None)), 'Db 缺少方法 %s' % name
src = inspect.getsource(Db)
assert '_guard' in src and 'assert_not_write_protected' in src
assert 'assert_append_only' in src
return True
# ==========================================================================
# 4. crud_factory.py —— C/U/D/R/I 与语义名双套(QC #4)
# ==========================================================================
@check('crud_factory: CrudBase 同时提供 C/U/D/R/I 与 create/read/update/delete/list/get')
def _check_crud_methods():
from pbl_common.crud_factory import CrudBase, tenant_crud, crud_factory
required = ('C', 'U', 'D', 'R', 'I', 'create', 'read', 'update', 'delete',
'list', 'get', 'upsert', 'exists', 'count', 'get_or_none')
for name in required:
fn = getattr(CrudBase, name, None)
assert callable(fn), 'CrudBase 缺少方法 %s' % name
assert inspect.iscoroutinefunction(fn), '%s 必须是 async' % name
crud = tenant_crud('pbl_blueprint', module='pbl_blueprint')
assert crud.table == 'pbl_blueprint' and crud.pk == 'id'
assert tenant_crud('pbl_blueprint', module='pbl_blueprint') is crud, '工厂未缓存'
assert crud_factory('pbl_evidence', module='pbl_evidence').table == 'pbl_evidence'
return True
@check('crud_factory: tenant_crud 为包内自包含实现(无反向 re-export / 无静默吞错)')
def _check_crud_self_contained():
import importlib
cf = importlib.import_module('pbl_common.crud_factory')
src = inspect.getsource(cf)
assert 'pbl_blueprint' not in src, 'crud_factory 不得反向依赖 pbl_blueprint'
assert 'except ImportError' not in src, 'crud_factory 不得静默吞 ImportError'
assert 'def tenant_crud(' in src
return True
@check('crud_factory: 写路径先过写保护 + append-only(引用模块拒绝写)')
def _check_crud_guards():
import asyncio
from pbl_common.crud_factory import CrudBase
from pbl_common.errors import WriteProtectedError, AppendOnlyError
protected = CrudBase(table='rbac_permission', module='rbac')
for coro in (protected.C({'name': 'x'}, tenant_id='t_1'),
protected.U({'name': 'y'}, where={'id': '1'}, tenant_id='t_1'),
protected.D({'id': '1'}, tenant_id='t_1')):
try:
asyncio.get_event_loop().run_until_complete(coro)
except WriteProtectedError as exc:
assert exc.code == 'PBL_E_WRITE_PROTECTED'
except RuntimeError:
# 无事件循环时用新循环重试
try:
asyncio.new_event_loop().run_until_complete(coro)
except WriteProtectedError as exc:
assert exc.code == 'PBL_E_WRITE_PROTECTED'
else:
raise AssertionError('引用模块写操作未被拦截')
else:
raise AssertionError('引用模块写操作未被拦截')
audit = CrudBase(table='pbl_audit_log', module='pbl_common')
try:
asyncio.new_event_loop().run_until_complete(
audit.U({'detail': 'x'}, where={'id': '1'}, tenant_id='t_1'))
except AppendOnlyError as exc:
assert exc.code == 'PBL_E_APPEND_ONLY'
else:
raise AssertionError('审计表 UPDATE 未被 append-only 拦截')
return True
@check('crud_factory: delete 无 where 被拒(禁止全表删除)')
def _check_crud_delete_guard():
import asyncio
from pbl_common.crud_factory import CrudBase
from pbl_common.errors import ParamInvalidError
crud = CrudBase(table='pbl_blueprint', module='pbl_blueprint')
try:
asyncio.new_event_loop().run_until_complete(crud.D(None, tenant_id='t_1'))
except ParamInvalidError:
pass
else:
raise AssertionError('无 where 的 delete 未被拒绝')
return True
# ==========================================================================
# 5. audit.py —— write_audit 契约(QC #4 / 人工介入 ②)
# ==========================================================================
@check('audit: 交付接口为 write_audit(append 为兼容别名),签名含 action/table/row_id/detail/ctx/db_conn')
def _check_audit_signature():
from pbl_common import audit
assert callable(audit.write_audit), 'audit 缺少 write_audit'
assert inspect.iscoroutinefunction(audit.write_audit)
params = list(inspect.signature(audit.write_audit).parameters)
for name in ('action', 'table', 'row_id', 'detail', 'ctx', 'db_conn'):
assert name in params, 'write_audit 签名缺少 %s(与 kernel 历史实现不一致)' % name
assert audit.append is audit.write_audit, 'append 兼容别名缺失'
for name in ('write_audit_batch', 'audit_trail', 'flush_memory_audit',
'build_audit_record', 'write_audit_sync'):
assert callable(getattr(audit, name, None)), 'audit 缺少 %s' % name
return True
@check('audit: 自包含实现,不 import pbl_blueprint.m1b,无静默 except ImportError')
def _check_audit_self_contained():
import pbl_common.audit as audit
src = inspect.getsource(audit)
assert 'pbl_blueprint' not in src, 'audit 不得反向依赖 pbl_blueprint(循环依赖根因)'
assert 'm1b' not in src, 'audit 不得引用 m1b 兼容层'
assert 'except ImportError' not in src, 'audit 不得静默吞 ImportError'
assert 'class write_audit' not in src, 'audit 不得把 write_audit 写成类(占位坏味)'
return True
@check('audit: build_audit_record tenant_id 打头且缺租户即抛')
def _check_audit_record():
from pbl_common.audit import build_audit_record
from pbl_common.errors import ParamInvalidError
rec = build_audit_record('create', table='pbl_blueprint', row_id='bp_1',
detail={'name': 'x'}, tenant_id='t_030')
assert list(rec.keys())[0] == 'tenant_id', '审计记录首键必须是 tenant_id'
assert rec['tenant_id'] == 't_030'
assert rec['action'] == 'create'
assert rec['table_name'] == 'pbl_blueprint'
assert len(rec['id']) <= 32
assert build_audit_record('c', tenant_id='t_030')['action'] == 'create'
assert build_audit_record('U', tenant_id='t_030')['action'] == 'update'
assert build_audit_record('weird_action', tenant_id='t_030')['action'] == 'other'
try:
build_audit_record('create', table='pbl_blueprint')
except ParamInvalidError as exc:
assert exc.code == 'PBL_E_TENANT_MISSING'
else:
raise AssertionError('缺租户的审计写入未被拒绝')
return True
@check('audit: DB 不可用时进内存缓冲而非丢弃,flush 可补写')
def _check_audit_buffer():
import asyncio
from pbl_common import audit
audit.clear_memory_audit()
class _BrokenDb(object):
async def create(self, *a, **kw):
raise RuntimeError('db down')
async def read(self, *a, **kw):
raise RuntimeError('db down')
loop = asyncio.new_event_loop()
res = loop.run_until_complete(
audit.write_audit('create', table='pbl_blueprint', row_id='bp_2',
tenant_id='t_031', db_conn=_BrokenDb()))
assert res.get('buffered') is True, 'DB 失败未进缓冲'
stats = audit.audit_stats()
assert stats['buffered_now'] >= 1
assert stats['failed'] >= 1
flushed = loop.run_until_complete(audit.flush_memory_audit(db_conn=_BrokenDb()))
assert flushed['remaining'] >= 1, 'flush 失败后未回灌缓冲(审计丢失)'
audit.clear_memory_audit()
loop.close()
return True
# ==========================================================================
# 6. api.py —— 契约符号全集重导出(人工介入 ③(a))
# ==========================================================================
@check('api: 契约符号全集可见,verify_contract() 返回空缺失清单')
def _check_api_contract():
from pbl_common import api
missing = api.verify_contract()
assert not missing, 'api 契约符号缺失: %s' % ', '.join(missing)
# 下游常用 import 路径逐个命中
from pbl_common.api import (PBLError, PblError, err, fail, ErrorCode, CODE_TO_HTTP,
TenantMissingError, NotFoundError, DbError,
ParamInvalidError, assert_not_write_protected,
WRITE_PROTECTED_MODULES, TenantContext, with_tenant,
tenant_scope, assert_tenant, normalize_tenant,
check_tenant_column, get_tenant, set_tenant,
Db, get_db, get_module_dbname, new_id, now_str,
sqlExe, CrudBase, tenant_crud, crud_factory,
write_audit, write_audit_batch, audit_trail,
flush_memory_audit, ok, fail_body, paged, api_guard)
assert api.PBLError is PBLError and api.PblError is PblError
return True
@check('api: 响应体 helper 结构统一(ok/fail_body/paged/from_exception)')
def _check_api_response():
from pbl_common.api import ok, fail_body, paged, from_exception
from pbl_common.errors import NotFoundError, PBLError
body = ok({'id': 'x'})
assert body['ok'] is True and body['code'] == 'PBL_E_OK' and body['http_status'] == 200
bad = fail_body('PBL_E_NOT_FOUND', '蓝图不存在')
assert bad['ok'] is False and bad['code'] == 'PBL_E_NOT_FOUND'
assert bad['http_status'] == 404 and bad['data'] is None
pg = paged([{'id': 1}, {'id': 2}], total=9, page=2)
assert pg['data']['total'] == 9 and len(pg['data']['rows']) == 2
exc_body = from_exception(NotFoundError('没有'))
assert exc_body['code'] == 'PBL_E_NOT_FOUND'
assert from_exception(ValueError('boom'))['code'] == 'PBL_E_INTERNAL'
return True
@check('api: 无循环依赖——errors 不 import 任何 pbl_common 内部模块')
def _check_no_cycle():
import pbl_common.errors as errors
src = inspect.getsource(errors)
for mod in ('pbl_common.context', 'pbl_common.dbutil', 'pbl_common.crud_factory',
'pbl_common.audit', 'pbl_common.api', 'pbl_common.kernel'):
assert mod not in src, 'errors.py 反向依赖 %s → 循环依赖' % mod
return True
# ==========================================================================
# 运行器
# ==========================================================================
def run_self_check(verbose=True):
"""执行全部自检,返回 (passed, total, failures)。"""
passed, failures = 0, []
for name, fn in RESULTS:
try:
result = fn()
if result is False:
raise AssertionError('check returned False')
passed += 1
if verbose:
print(' [PASS] %s' % name)
except Exception as exc: # noqa: BLE001
failures.append((name, '%s: %s' % (type(exc).__name__, exc),
traceback.format_exc()))
if verbose:
print(' [FAIL] %s -> %s: %s' % (name, type(exc).__name__, exc))
total = len(RESULTS)
if verbose:
status = 'PASS' if not failures else 'FAIL'
print('SELF_CHECK pbl_common: %s %d/%d' % (status, passed, total))
for name, msg, _tb in failures:
print(' - %s: %s' % (name, msg))
return passed, total, failures
def main():
passed, total, failures = run_self_check(verbose=True)
return 0 if not failures else 1
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main(sys.argv[1:])) sys.exit(main())
__all__ = ['RESULTS', 'check', 'run_self_check', 'main']

View File

@ -1,139 +1,83 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """pbl_common.tenant —— 兼容 shim(QC 退回意见 #7:包内只保留一套实现)。
pbl_common.tenant —— 租户上下文(tenant_id 强制打头,缺失即 fail-closed)
铁律: 历史上 tenant.py 是租户逻辑的**独立实现**,与新交付的 context.py 平行并存,
* PBL 所有读写 SQL 的 WHERE 条件首键必须是 tenant_id 两套符号互相引用缺失 → import 链断裂。本轮定案:
* tenant_id 缺失/为 0/非整数 -> 抛 PBLTenantError,绝不静默放行
* 禁止任何接口以 "全租户" 方式扫描(除 owner.audit 审计角色显式声明) * **唯一实现 = context.py**;
* tenant.py 降级为**薄再导出 shim**(零业务逻辑),保证旧 import 路径
``from pbl_common.tenant import normalize_tenant / assert_tenant / tenant_scope /
with_tenant / check_tenant_column`` 全部继续可用(设计文档 pbl_common.md §5
『接口变更需向后兼容,不删既有签名』)。
依赖方向:tenant(shim) → context → errors,无环。
""" """
import threading from __future__ import annotations
_local = threading.local() from pbl_common.context import ( # noqa: F401 (兼容再导出)
INVALID_TENANT_VALUES,
SYSTEM_TENANT_IDS,
TENANT_ID_PATTERN,
TenantContext,
assert_same_tenant,
assert_tenant,
check_tenant_column,
current_context,
current_tenant,
get_tenant,
has_tenant,
normalize_tenant,
reset_tenant,
run_in_tenant,
set_tenant,
tenant_scope,
tenant_scope_ctx,
with_tenant,
)
from pbl_common.errors import ( # noqa: F401
PBL_E_TENANT,
PBL_E_TENANT_INVALID,
PBL_E_TENANT_MISSING,
PblTenantMissing,
TenantError,
TenantInvalidError,
TenantMissingError,
)
AUDIT_ROLE = 'owner.audit' #: 旧代码里常见的别名(同义再导出,不新增语义)
TenantMissing = TenantMissingError
TenantInvalid = TenantInvalidError
TenantScopeError = TenantInvalidError
normalize_tenant_id = normalize_tenant
require_tenant = assert_tenant
scope_tenant = tenant_scope
get_current_tenant = get_tenant
class PBLTenantError(Exception): def tenant_of(row, column='tenant_id', strict=True):
"""租户上下文缺失/非法(fail-closed)。""" """从数据行取租户并规范化(旧 tenant.py 的常用小工具,保留签名)。"""
code = 'PBL_TENANT_MISSING' value = None
if isinstance(row, dict):
def __init__(self, msg='tenant_id 缺失或非法,拒绝执行'): value = row.get(column)
super(PBLTenantError, self).__init__(msg) else:
self.msg = msg value = getattr(row, column, None)
if value is None and strict:
raise TenantMissingError(message='数据行缺少 %s' % column,
detail={'column': column})
return normalize_tenant(value, strict=strict)
def _norm(tenant_id): __all__ = [
try: 'TenantContext', 'normalize_tenant', 'assert_tenant', 'tenant_scope',
v = int(tenant_id) 'tenant_scope_ctx', 'with_tenant', 'check_tenant_column', 'get_tenant',
except (TypeError, ValueError): 'set_tenant', 'reset_tenant', 'current_context', 'current_tenant',
raise PBLTenantError('tenant_id 非整数:%r' % (tenant_id,)) 'has_tenant', 'assert_same_tenant', 'run_in_tenant',
if v <= 0: 'TENANT_ID_PATTERN', 'SYSTEM_TENANT_IDS', 'INVALID_TENANT_VALUES',
raise PBLTenantError('tenant_id 必须为正整数:%r' % (tenant_id,)) 'TenantError', 'TenantMissingError', 'TenantInvalidError',
return v 'PblTenantMissing', 'PBL_E_TENANT', 'PBL_E_TENANT_MISSING',
'PBL_E_TENANT_INVALID',
'TenantMissing', 'TenantInvalid', 'TenantScopeError',
class TenantContext(object): 'normalize_tenant_id', 'require_tenant', 'scope_tenant',
"""租户上下文对象。scope() 产出的条件字典 tenant_id 恒在首位。""" 'get_current_tenant', 'tenant_of',
]
__slots__ = ('tenant_id', 'user_id', 'role', 'session_id', 'trace_id', 'allow_cross_tenant')
def __init__(self, tenant_id, user_id=0, role='', session_id=0, trace_id='',
allow_cross_tenant=False):
self.tenant_id = _norm(tenant_id)
self.user_id = int(user_id or 0)
self.role = role or ''
self.session_id = int(session_id or 0)
self.trace_id = trace_id or ''
# 仅审计角色可跨租户,且必须显式声明
self.allow_cross_tenant = bool(allow_cross_tenant and self.role == AUDIT_ROLE)
# ---- 条件构造:tenant_id 打头 -------------------------------------
def scope(self, cond=None):
"""把业务条件包进租户作用域,返回的 dict 首键恒为 tenant_id。"""
out = {}
out['tenant_id'] = self.tenant_id
if cond:
for k, v in cond.items():
if k == 'tenant_id':
# 不允许业务侧覆盖租户;跨租户仅审计角色
if self.allow_cross_tenant:
out['tenant_id'] = v
continue
out[k] = v
return out
def assert_writable(self, row):
"""写前校验:目标行的 tenant_id 必须与上下文一致。"""
if not isinstance(row, dict):
raise PBLTenantError('写保护校验对象非法')
tid = row.get('tenant_id')
if tid is None:
raise PBLTenantError('目标行缺 tenant_id,拒绝写入')
if int(tid) != self.tenant_id and not self.allow_cross_tenant:
raise PBLTenantError('跨租户写入被拒绝:%s != %s' % (tid, self.tenant_id))
return True
def to_dict(self):
return {
'tenant_id': self.tenant_id,
'user_id': self.user_id,
'role': self.role,
'session_id': self.session_id,
'trace_id': self.trace_id,
'allow_cross_tenant': self.allow_cross_tenant,
}
def require_tenant(params):
"""
从请求参数/上下文里取 tenant_id 并构造 TenantContext。
缺失即抛 PBLTenantError(fail-closed),绝不回落默认租户。
"""
tid = None
if isinstance(params, dict):
for k in ('tenant_id', 'tenantId', 'tenant'):
if params.get(k) not in (None, ''):
tid = params.get(k)
break
if tid in (None, ''):
tid = getattr(_local, 'tenant_id', None)
if tid in (None, ''):
raise PBLTenantError()
return TenantContext(
tenant_id=tid,
user_id=(params.get('user_id') if isinstance(params, dict) else 0) or getattr(_local, 'user_id', 0),
role=(params.get('role') if isinstance(params, dict) else '') or getattr(_local, 'role', ''),
session_id=(params.get('session_id') if isinstance(params, dict) else 0) or getattr(_local, 'session_id', 0),
trace_id=(params.get('trace_id') if isinstance(params, dict) else '') or getattr(_local, 'trace_id', ''),
)
def set_current(tenant_id, user_id=0, role='', session_id=0, trace_id=''):
"""线程级当前租户(供 dspy/api 入口统一设置)。"""
ctx = TenantContext(tenant_id, user_id=user_id, role=role,
session_id=session_id, trace_id=trace_id)
_local.tenant_id = ctx.tenant_id
_local.user_id = ctx.user_id
_local.role = ctx.role
_local.session_id = ctx.session_id
_local.trace_id = ctx.trace_id
_local.ctx = ctx
return ctx
def get_current(strict=True):
"""取线程级当前租户上下文;strict=True 时缺失即抛。"""
ctx = getattr(_local, 'ctx', None)
if ctx is not None:
return ctx
if strict:
raise PBLTenantError()
return None
def clear_current():
for k in ('tenant_id', 'user_id', 'role', 'session_id', 'trace_id', 'ctx'):
if hasattr(_local, k):
delattr(_local, k)