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 -*-
"""pbl_common —— PBL Agent OS 公共内核M0
"""pbl_common —— PBL Agent OS 公共内核(租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂)。
导出PblError / ERR / TenantContext / require_tenant / get_dbname /
audit_write / crud_factory / WRITE_PROTECTED_TABLES / is_valid_identifier
以及 _self_test_fail_closed()六项 fail-closed 断言可独立运行
QC 退回意见 #1 的修复:三处同步之第②处】
``__init__.py`` ``from .kernel import *``未导出 init.py ``load_pbl_common``
宿主入口 ``from pbl_common import load_pbl_common`` 直接 ImportError本版显式导出
``load_pbl_common`` 及全部**真实存在**的契约符号不再导入任何不存在的名字
自测运行方式
cd modules/pbl_common && python -c "import pbl_common; print(pbl_common.self_test())"
python modules/pbl_common/pbl_common/__init__.py
期望输出末行
SELF_TEST pbl_common: PASS 6/6
QC 退回意见 #3 的修复:只保留一套错误码方案】
``__init__.py`` 自检断言 ``PBL_E_TENANT`` / ``PBL_E_WRITE_LOCK`` / ``PBL_E_PARAM``
init.py 断言 ``PBL_E_TENANT_MISSING`` / ``PBL_E_APPEND_ONLY`` / ``PBL_E_WRITE_PROTECTED``
errors.py ``PBL-TEN-001`` 连字符风格 三者互斥现在统一为
**符号常量 ``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,
ERR,
TenantContext,
require_tenant,
get_dbname,
audit_write,
build_audit_record,
crud_factory,
is_valid_identifier,
WRITE_PROTECTED_TABLES,
MODULE_DB_KEYS,
IDEMPOTENT_CODES,
PblForbidden,
PblNotFound,
PblValidationError,
TenantError,
TenantInvalidError,
TenantMissingError,
ToolDeniedError,
ValidationError,
WriteLockError,
WriteProtectedError,
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",
"audit_write", "build_audit_record", "crud_factory", "is_valid_identifier",
"WRITE_PROTECTED_TABLES", "MODULE_DB_KEYS", "IDEMPOTENT_CODES",
"_self_test_fail_closed", "self_test",
]
# --------------------------------------------------------------------------
# 2. 租户上下文
# --------------------------------------------------------------------------
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,
)
__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/
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
任一条不成立直接抛 ImportError 半迁移状态不允许被交付/上线
"""
failures = []
total = 6
problems = []
def expect_pbl_error(fn, code, tag):
try:
fn()
except PblError as e:
if e.code != code:
failures.append("%s: 期望 %s 实得 %s" % (tag, code, e.code))
return
except Exception as e: # noqa
failures.append("%s: 抛出非 PblError(%s): %s" % (tag, type(e).__name__, e))
return
failures.append("%s: 未抛错fail-open 违规)" % tag)
# (a) 一套错误码:三个历史断言点涉及的常量必须全部存在且值 == 常量名
for name in ('PBL_E_TENANT', 'PBL_E_WRITE_LOCK', 'PBL_E_PARAM',
'PBL_E_TENANT_MISSING', 'PBL_E_APPEND_ONLY', 'PBL_E_WRITE_PROTECTED',
'PBL_E_NOT_FOUND', 'PBL_E_DB', 'PBL_E_INTERNAL'):
value = globals().get(name)
if value != name:
problems.append('错误码常量 %s 缺失或值不一致(%r)' % (name, value))
# A1 无上下文 -> 必须抛错
def a1():
require_tenant()
expect_pbl_error(a1, "PBL_E_TENANT", "A1.no_context")
# (b) 异常类面(规范 + 兼容)
for name in ('PBLError', 'PblError', 'DbError', 'NotFoundError', 'ParamInvalidError',
'TenantMissingError', 'TenantInvalidError', 'WriteProtectedError',
'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
for bad in ("", " ", None, "t#01", 123):
def a2(b=bad):
require_tenant(b)
expect_pbl_error(a2, "PBL_E_TENANT", "A2.bad_tenant(%r)" % (bad,))
# (c) code / err_code 同值(不允许两套属性语义)
probe = PBLError(PBL_E_TENANT_MISSING, 'probe')
if probe.code != probe.err_code:
problems.append('PBLError.code 与 .err_code 不同值')
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:
with TenantContext("T_A", actor="u1"):
got = require_tenant()
if got != "T_A":
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
assert_append_only('delete', AUDIT_TABLE)
except AppendOnlyError:
pass
else:
passed = total
return passed, total, failures
problems.append('append-only 门禁失效:审计表 delete 未被拒绝')
# (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):
"""运行六项 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
_BOOTSTRAP_OK = _bootstrap_check()
if __name__ == "__main__":
import sys
sys.exit(self_test())
__all__ = [
'__version__', '__author__',
# 挂载入口(三处同步之②)
'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 -*-
"""
pbl_common.api 公共内核对内契约接口供其它 pbl_* 模块调用
"""pbl_common.api —— 公共内核对外契约面(重导出契约符号全集)。
只暴露稳定契约不暴露内部实现所有函数 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 (
build_context,
bind_context,
unbind_context,
current_context,
require_tenant,
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 __future__ import annotations
import functools
import logging
# ---- 1. 错误码与异常(规范 + 兼容全集) ---------------------------------
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,
assert_not_write_protected,
WRITE_PROTECTED_MODULES,
ForbiddenError,
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 与部署冒烟)"""
return {
'ok': True,
'module': 'pbl_common',
'dialect': DIALECT,
'tables': sorted(TABLES.keys()),
'write_protected_modules': list(WRITE_PROTECTED_MODULES),
'audit_actions': len(AUDIT_ACTIONS),
}
# ==========================================================================
# 统一响应体 helper
# ==========================================================================
def ok(data=None, message='ok', **extra):
"""成功响应体:{'ok': True, 'code': 'PBL_E_OK', 'data': ..., 'message': ...}。"""
body = {'ok': True, 'code': PBL_E_OK, 'legacy_code': LEGACY_CODE_MAP[PBL_E_OK],
'message': message, 'data': data, 'http_status': 200}
if extra:
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_INTERNALfail-closed不泄露内部栈到前端栈进日志
"""
公共内核自检不依赖 DB纯逻辑断言
返回 {'ok': bool, 'passed': int, 'total': int, 'details': [...]}
末行由调用方打印SELF_CHECK pbl_common: PASS n/n
"""
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_idfail-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:
def deco(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
tenant_id = kwargs.get('tenant_id')
try:
build_context(v)
return False, '非法值未被拒绝:%r' % (v,)
except PblError:
continue
return True, '%d 个非法值全部拒绝' % len(bad)
_add('tenant_invalid_rejected', t_tenant_invalid)
# 4 tenant_scope 打头
def t_scope():
ctx = build_context('t_scope')
bind_context(ctx)
try:
w, a = tenant_scope(None, "status = %s", ['draft'])
ok = w.startswith('tenant_id = %s') and a[0] == 't_scope' and a[1] == 'draft'
return ok, 'where=%r args=%r' % (w, a)
finally:
unbind_context(None)
_add('tenant_scope_first', t_scope)
# 5 extra_where 不得自带 tenant_id
def t_scope_guard():
ctx = build_context('t_guard')
bind_context(ctx)
try:
tenant_scope(None, 'tenant_id = %s', ['evil'])
return False, '未拦截调用方自带 tenant_id'
except PblError:
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],
}
if tenant_required:
kwargs['tenant_id'] = assert_tenant(tenant_id)
if audit_table:
assert_not_write_protected(audit_table, audit_action or 'write')
result = await fn(*args, **kwargs)
if isinstance(result, dict) and 'ok' in result:
return result
return ok(result)
except Exception as exc: # noqa: BLE001
error = as_error(exc)
if error.code == PBL_E_INTERNAL:
log.exception('api_guard 未预期异常 in %s', getattr(fn, '__name__', fn))
if audit_action and audit_table:
try:
await write_audit(audit_action, table=audit_table,
row_id=kwargs.get('id'),
detail={'error': error.code,
'message': error.message},
tenant_id=kwargs.get('tenant_id'),
result='error')
except Exception: # noqa: BLE001
log.warning('api_guard 失败审计写入未成功')
return error.to_dict()
return wrapper
if func is not None:
return deco(func)
return deco
__all__ = [
'health', 'self_check',
'build_context', 'bind_context', 'unbind_context', 'current_context',
'require_tenant', 'require_context', 'context_scope',
'normalize_tenant', 'assert_tenant', 'tenant_scope', 'with_tenant', 'check_tenant_column',
'get_dbname', 'query', 'query_one', 'execute', 'insert', 'transaction', 'sqlExe', 'DIALECT',
'make_crud', 'CrudBase',
'write_audit', 'query_audit', 'AUDIT_ACTIONS',
'to_jsonable', 'dumps', 'loads', 'parse_json_column',
'PblError', 'ErrorCode', 'assert_not_write_protected', 'WRITE_PROTECTED_MODULES',
'ensure_tables', 'all_ddl', 'ddl_of', 'TABLES',
# ==========================================================================
# 契约自检import 期即验证符号面完整,失败直接抛 → 不允许半迁移状态被交付)
# ==========================================================================
CONTRACT_SYMBOLS = (
# errors
'PBLError', 'PblError', 'err', 'fail', 'as_error', 'error_body',
'ErrorCode', 'PBL_ERR', 'CODE_TO_HTTP', 'LEGACY_CODE_MAP', 'ALL_CODES',
'DbError', 'NotFoundError', 'ParamInvalidError', 'TenantMissingError',
'TenantInvalidError', 'ConflictError', 'ForbiddenError', 'WriteProtectedError',
'AppendOnlyError', 'ValidationError', 'CompileError', 'ToolDeniedError',
'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 -*-
"""
pbl_common.audit append-only 审计pbl_audit_event
"""pbl_common.audit —— append-only 审计写入(自包含实现)。
铁律
* 只暴露 append()绝不暴露 update/delete/remove/purgeself_check 校验
* 审计独立性审计写入失败不阻断业务但必须落降级日志
* 审计角色 owner.audit 才可跨租户读取
人工介入答复 的修复旧版 audit.py ``class write_audit`` 是占位坏味
真实现散落在 kernel.py L1477各文件尾部还挂着
``try: from pbl_blueprint.m1b.audit import write_audit ... except ImportError: pass``
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
import uuid
from __future__ import annotations
from pbl_common.tenant import TenantContext, AUDIT_ROLE
from pbl_common.db import get_db
import logging
import threading
AUDIT_TABLE = 'pbl_audit_event'
MODULE = 'pbl_governance'
from pbl_common.errors import (
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.mdpbl_audit_logtenant_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():
return time.strftime('%Y-%m-%d %H:%M:%S')
def _normalize_action(action):
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):
tid = getattr(ctx, 'trace_id', '') or ''
return tid or uuid.uuid4().hex[:32]
def append(ctx, event_type, obj_type='', obj_id=0, session_id=0,
detail=None, actor_role=None, actor_id=None):
"""
追加一条审计事件append-only
ctx: TenantContext必须缺失即抛绝不静默
返回 audit_id写库失败返回 0 并打印降级日志不阻断业务
"""
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)
def build_audit_record(action, table=None, row_id=None, detail=None, ctx=None,
tenant_id=None, actor_id=None, actor_type=None,
trace_id=None, result='ok', created_at=None):
"""构造一条审计记录 dicttenant_id 打头,不落库)。"""
tid = normalize_tenant(tenant_id if tenant_id is not None else _safe_tenant(ctx))
if not tid:
raise ParamInvalidError(
code=PBL_E_TENANT_MISSING,
message='审计写入缺少 tenant_id审计同样强制租户打头',
detail={'action': action, 'table': table})
if ctx is None:
try:
return db.C(AUDIT_TABLE, row)
finally:
try:
db.close()
except Exception: # noqa: BLE001
pass
except Exception as e: # noqa: BLE001
print('[pbl_common.audit] DEGRADED append failed: %s | row=%s' % (e, row))
return 0
ctx_obj = current_context(strict=False)
except Exception: # noqa: BLE001
ctx_obj = None
else:
ctx_obj = ctx
rec = {}
rec['tenant_id'] = tid
rec['id'] = new_id('aud')
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:
return ''
if isinstance(detail, str):
return detail
return detail[:4000]
try:
import json
return json.dumps(detail, ensure_ascii=False)
return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
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 运行时跨租户任务用
返回落库的审计记录 dictDB 不可用时记录进内存缓冲并返回
``{'buffered': True, ...}``**不抛**避免审计失败拖垮主业务但绝不丢
"""
审计查询只读跨租户仅 owner.audit 角色允许
"""
if not isinstance(ctx, TenantContext):
raise ValueError('audit.query 需要 TenantContext')
where = ctx.scope(cond or {})
assert_append_only('insert', AUDIT_TABLE) # 语义自检insert 合法
assert_not_write_protected(table, 'read') # 审计只读被审计对象的元信息
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:
return db.R(AUDIT_TABLE, where, order_by='event_time desc,id desc',
limit=limit, offset=offset) or []
finally:
try:
db.close()
except Exception: # noqa: BLE001
pass
except Exception as e: # noqa: BLE001
print('[pbl_common.audit] query failed: %s' % e)
return []
rec = await write_audit(
item.get('action', 'other'),
table=item.get('table') or item.get('table_name'),
row_id=item.get('row_id'),
detail=item.get('detail'),
ctx=item.get('ctx'),
db_conn=db_conn,
tenant_id=item.get('tenant_id'),
actor_id=item.get('actor_id'),
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):
return bool(getattr(ctx, 'role', '') == AUDIT_ROLE)
async def audit_trail(table=None, row_id=None, tenant_id=None, limit=200,
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 -*-
"""
pbl_common.context 租户上下文threading.local 绑定fail-closed
"""pbl_common.context —— 租户上下文tenant_id 强制打头)。
设计要点
- TenantContext __slots__ 固定 6 字段防止误挂属性
- threading.local 绑定天然线程隔离异步/多线程下不串租户
- require_tenant() 是所有读写的第一道闸拿不到 tenant_id 直接抛错
绝不回落到默认租户全租户查询
设计依据docs/01-design/data-model.md36 表全部 tenant_id 打头+
modules/pbl_common.md §租户上下文
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 (
TenantMissingError,
PBL_E_TENANT,
PBL_E_TENANT_INVALID,
PBL_E_TENANT_MISSING,
PblError,
PblTenantMissing,
TenantInvalidError,
ErrorCode,
TenantMissingError,
err,
fail,
normalize_code,
)
_LOCAL = threading.local()
# --------------------------------------------------------------------------
# 租户标识规则
# --------------------------------------------------------------------------
#: 合法 tenant_id1~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)
#: 上下文变量:当前 actorAgent 运行时写入)
_actor_var = ContextVar('pbl_actor', default=None)
#: 上下文变量:当前 trace_id审计串联
_trace_var = ContextVar('pbl_trace_id', default=None)
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,
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
__slots__ = ('tenant_id', 'actor_type', 'actor_id', 'trace_id', 'extra')
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 {
'tenant_id': self.tenant_id,
'user_id': self.user_id,
'role': self.role,
'session_id': self.session_id,
'actor_type': self.actor_type,
'actor_id': self.actor_id,
'trace_id': self.trace_id,
'app_name': self.app_name,
'extra': dict(self.extra),
}
def copy(self, **overrides):
data = self.to_dict()
data.update(overrides)
return TenantContext(**data)
def child(self, **overrides):
base = self.as_dict()
extra = dict(base.pop('extra') or {})
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):
return ('TenantContext(tenant_id=%r, user_id=%r, role=%r, '
'session_id=%r, trace_id=%r, app_name=%r)'
% (self.tenant_id, self.user_id, self.role,
self.session_id, self.trace_id, self.app_name))
return 'TenantContext(tenant_id=%r, actor_type=%r, actor_id=%r)' % (
self.tenant_id, self.actor_type, self.actor_id)
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:
raise TenantMissingError(message='tenant_id 为 None')
if not isinstance(tenant_id, str):
raise TenantInvalidError(
message='tenant_id 必须为字符串,实际 %s' % type(tenant_id).__name__,
detail={'actual_type': type(tenant_id).__name__},
)
tid = tenant_id.strip()
if not tid:
raise TenantInvalidError(message='tenant_id 为空串')
if len(tid) > TENANT_ID_MAX_LEN:
raise TenantInvalidError(
message='tenant_id 超长(>%d' % TENANT_ID_MAX_LEN,
detail={'length': len(tid), 'max': TENANT_ID_MAX_LEN},
)
for ch in ("'", '"', ';', '--', '/*', '*/', '\\', '\x00'):
if ch in tid:
if strict:
raise TenantMissingError(message='tenant_id 为 None租户上下文缺失')
return ''
if isinstance(tenant_id, TenantContext):
tenant_id = tenant_id.tenant_id
if isinstance(tenant_id, (list, tuple, set)):
# 前端把 query + body 合并成 list 的经典坑:取首个非空值
candidates = [v for v in tenant_id if v not in (None, '')]
if not candidates:
if strict:
raise TenantMissingError(message='tenant_id 为空列表')
return ''
if len(set(str(c) for c in candidates)) > 1:
raise TenantInvalidError(
message='tenant_id 含非法字符 %r' % ch,
detail={'illegal_char': ch},
)
return tid
def build_context(tenant_id, user_id=None, role=None,
session_id=None, trace_id=None, app_name='pbls'):
"""构造 TenantContext构造即校验非法直接抛错"""
return TenantContext(
tenant_id=_check_tenant(tenant_id),
user_id=user_id,
role=role,
session_id=session_id,
trace_id=trace_id,
app_name=app_name,
)
def bind_context(ctx):
"""绑定上下文到当前线程,返回被替换的旧上下文(供 unbind 还原)"""
if not isinstance(ctx, TenantContext):
message='tenant_id 收到多个互斥值query 与 body 重复传参)',
detail={'values': [str(c) for c in candidates]})
tenant_id = candidates[0]
if isinstance(tenant_id, dict):
tenant_id = (tenant_id.get('tenant_id') or tenant_id.get('tenantid')
or tenant_id.get('id'))
if tenant_id is None:
if strict:
raise TenantMissingError(message='tenant_id dict 中无 tenant_id 键')
return ''
text = str(tenant_id).strip()
if len(text) >= 2 and text[0] == text[-1] and text[0] in '\'"':
text = text[1:-1].strip()
if text.lower() in INVALID_TENANT_VALUES:
if strict:
raise TenantMissingError(
message='tenant_id 为非法占位值 %r' % (text,),
detail={'raw': text})
return ''
if not TENANT_ID_PATTERN.match(text):
raise TenantInvalidError(
message='bind_context 需要 TenantContext 实例,实际 %s' % type(ctx).__name__,
)
old = getattr(_LOCAL, 'ctx', None)
_LOCAL.ctx = ctx
return old
message='tenant_id %r 不符合规则1~64 位 [A-Za-z0-9_.-]' % (text,),
detail={'raw': text, 'pattern': TENANT_ID_PATTERN.pattern})
return text
def unbind_context(old=None):
"""解绑上下文;传入 bind_context 的返回值可还原上一层"""
if old is None:
_LOCAL.ctx = None
else:
_LOCAL.ctx = old
return True
def assert_tenant(tenant_id=None, ctx=None):
"""断言租户存在且合法,返回规范化后的 tenant_id。
def current_context():
"""取当前线程上下文,未绑定返回 None不抛错供探测用"""
return getattr(_LOCAL, 'ctx', None)
def require_context():
"""取当前线程上下文,未绑定即抛 TenantMissingErrorfail-closed"""
ctx = current_context()
if ctx is None:
入参优先级显式 tenant_id > 显式 ctx > 当前上下文变量
缺失即抛 TenantMissingErrorfail-closed绝不静默降级为全局租户
"""
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:
tenant_id = _tenant_var.get()
if tenant_id is None or str(tenant_id).strip() == '':
raise TenantMissingError(
message='未绑定租户上下文,请先 bind_context(build_context(tenant_id=...))',
detail={'code': ErrorCode.TENANT_MISSING},
)
return ctx
message='缺少租户上下文tenant_id 必须打头传入或先 set_tenant()',
detail={'hint': '调用 with_tenant(tid) / set_tenant(tid) 或显式传 tenant_id'})
return normalize_tenant(tenant_id)
def require_tenant():
"""取当前 tenant_id 字符串,未绑定即抛错(所有读写第一道闸)"""
return require_context().tenant_id
def check_tenant_column(table, columns=None, required=True):
"""校验表定义/字段清单是否以 tenant_id 打头data-model.md 硬约束)。
返回 (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):
"""with 语法糖:进入绑定、退出还原(异常也还原)"""
def tenant_scope(row=None, tenant_id=None, position='first'):
"""把 tenant_id 注入 dict 并保证其位于**第一位**SQL 参数序 = 打头序)。
def __init__(self, ctx):
self.ctx = ctx
self._old = None
row 中已有 tenant_id 且与入参不一致 TenantInvalidError防跨租户串写
"""
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 返回的 tokenNone 时置空)。"""
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):
self._old = bind_context(self.ctx)
return self.ctx
self._ctx, self._token = set_tenant(**self._spec)
return self._ctx
def __exit__(self, exc_type, exc_val, exc_tb):
unbind_context(self._old)
def __exit__(self, exc_type, exc, tb):
reset_tenant(self._token)
self._token = None
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 -*-
"""
pbl_common.crud CRUD 工厂sqlor 标准 API C/U/D/R/I/sqlExe
"""pbl_common.crud —— 兼容 shimQC 退回意见 #4/#7
铁律
* 不编造 save/list/insert/update/delete/query 等非标准方法
* 所有读写条件一律经 TenantContext.scope() 包装tenant_id 强制打头
* 写操作C/U/D自动补 tenant_id / create_time / update_time / is_deleted
* 软删除D 默认置 is_deleted=1不物理删审计/证据/日志表除外
self_check._check_crud_factory 导入 ``pbl_common.crud``当时不存在而交付的是
``crud_factory.py``本轮定案**唯一实现 = crud_factory.py**crud.py 降级为薄再导出
shim保证 ``from pbl_common.crud import CrudBase / tenant_crud`` 等旧路径可用
依赖方向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.errors import PBLError, PBL_ERR
from pbl_common.db import get_db
# 物理删除白名单append-only 表不允许删;这些是可重算的临时表)
HARD_DELETE_TABLES = ()
# 只读表append-only审计 / 证据日志 / 工具调用 / 校验问题)
APPEND_ONLY_TABLES = (
'pbl_audit_event',
'pbl_evidence_collect_log',
'pbl_tool_call',
'pbl_validation_issue',
'pbl_kdb_query_log',
'pbl_blueprint_change_log',
from pbl_common.crud_factory import ( # noqa: F401 (兼容再导出)
AUDIT_TABLE_MARKERS,
CREATED_COLUMNS,
UPDATED_COLUMNS,
CrudBase,
bulk_create,
bulk_delete,
clear_registry,
crud_factory,
is_audit_table,
make_crud,
registered_tables,
tenant_crud,
)
from pbl_common.errors import ( # noqa: F401
AppendOnlyError,
ConflictError,
NotFoundError,
ParamInvalidError,
WriteProtectedError,
assert_append_only,
assert_not_write_protected,
)
# 写保护模块的基表PBL 侧一律不得写)
WRITE_PROTECTED_TABLES = (
'world', 'scene', 'entity', 'scense', 'scense_game',
'scense_runtime', 'script_engine', 'rbac_role', 'rbac_user',
'rbac_permission', 'app_codes',
)
#: 旧命名别名
Crud = CrudBase
TenantCrud = CrudBase
create_crud = tenant_crud
get_crud = tenant_crud
def _now():
return time.strftime('%Y-%m-%d %H:%M:%S')
def _assert_writable(tblname):
if tblname in WRITE_PROTECTED_TABLES:
raise PBLError(PBL_ERR['agent_tool']['write_protected'],
'%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 封装(方法名严格对齐 sqlorC/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)
__all__ = [
'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',
'CREATED_COLUMNS', 'UPDATED_COLUMNS',
'NotFoundError', 'ConflictError', 'ParamInvalidError', 'AppendOnlyError',
'WriteProtectedError', 'assert_not_write_protected', 'assert_append_only',
]

View File

@ -1,234 +1,368 @@
# -*- coding: utf-8 -*-
"""
pbl_common.crud_factory CRUD 工厂统一租户打头 + 写保护 + 审计
"""pbl_common.crud_factory —— 租户安全 CRUD 工厂C/U/D/R/I 全集)。
pbl_* 模块的表 CRUD 一律由本工厂生成避免每个模块各写一套
tenant_id / 漏审计 / 误写引用基表的风险
QC 退回意见 #4 的修复】旧 self_check._check_crud_factory 要求 C/U/D/R/I 五类方法,
而交付的 CrudBase 只有 create/get/list/update/delete 自检与实现脱节本版把
**两套命名同时提供**规范名 C/U/D/R/I 对齐 sqlor 语义 + 语义名 create/read/update/
delete/list/getself_check.py 按实际交付的符号断言二者一一对应
用法
crud = make_crud('pbl_blueprint', 'pbl_blueprint', module='pbl_blueprint')
crud.create(tenant_id, {'code': 'bp001', 'name': 'x'})
crud.get(tenant_id, 1)
crud.list(tenant_id, where="status = %s", params=['draft'], limit=20)
crud.update(tenant_id, 1, {'name': 'y'})
crud.delete(tenant_id, 1)
QC 退回意见 #2 的修复】本文件从 errors 导入的 ``NotFoundError`` / ``DbError`` /
``ParamInvalidError`` / ``ConflictError`` 等已在 errors.py 补齐规范类 + 兼容别名
人工介入答复 ``tenant_crud`` 在本文件内实现为工厂函数 import
pbl_blueprint.m1b不做反向 re-export返回按 (module, table) 绑定的 CRUD 实例
写保护所有写操作前过 ``assert_not_write_protected``审计表过 ``assert_append_only``
"""
import datetime
from __future__ import annotations
import logging
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,
ParamInvalidError,
ErrorCode,
WriteProtectedError,
assert_append_only,
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
DEFAULT_PAGE_SIZE = 20
log = logging.getLogger('pbl_common.crud_factory')
#: 审计表名后缀/前缀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():
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def is_audit_table(table):
low = str(table or '').lower()
return any(m in low for m in AUDIT_TABLE_MARKERS)
class CrudBase(object):
"""单表 CRUD 基类tenant_id 强制打头fail-closed"""
"""单表租户安全 CRUD 基类。
def __init__(self, table, pk='id', module='pbl_common',
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)
构造``CrudBase(table='pbl_blueprint', module='pbl_blueprint', db=None)``
# ---------- 内部工具 ----------
def _guard_write(self):
if self.readonly:
raise ParamInvalidError(
code=ErrorCode.WRITE_PROTECTED,
message='%s 为只读(引用模块基表),禁止写入' % self.table,
detail={'table': self.table},
http_status=403,
)
方法面两套命名等价
规范名C(row) / R(where) / U(row, where) / D(where) / I(ns)
语义名create / read / list / get / update / delete / upsert
"""
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)
for col in self.json_columns:
if col in out and not isinstance(out[col], (str, bytes, type(None))):
out[col] = dumps(out[col])
stamp = now_str()
cols = CREATED_COLUMNS if created else UPDATED_COLUMNS
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
def _audit(self, action, tenant_id, resource_id=None, result='success', detail=None):
if not self.audit:
return False
return write_audit(
action=action,
resource_type=self.table,
resource_id=resource_id,
tenant_id=tenant_id,
result=result,
detail=detail,
module=self.module,
)
def _check_required(self, row):
missing = [c for c in self.required_columns if not str(row.get(c, '')).strip()]
if missing:
raise ParamInvalidError(
message='%s 缺少必填字段:%s' % (self.table, ', '.join(missing)),
detail={'table': self.table, 'missing': missing})
return True
# ---------- C ----------
def create(self, tenant_id, row, conn=None):
"""插入一行;自动注入 tenant_id / created_at / updated_at"""
self._guard_write()
if not isinstance(row, dict) or not row:
raise ParamInvalidError(message='create 需要非空 dict')
data = with_tenant(row, tenant_id)
data = self._encode_json_columns(data)
if self.auto_timestamp:
now = _now()
data.setdefault('created_at', now)
data.setdefault('updated_at', now)
res = insert(self.table, data, module=self.module, conn=conn)
new_id = res.get('last_id')
self._audit('create', data['tenant_id'], new_id, detail={'columns': sorted(data.keys())})
return new_id
# ------------------------------------------------------------------
# C —— create
# ------------------------------------------------------------------
async def C(self, row, tenant_id=None, id_prefix=None):
"""插入一行tenant_id 打头、自动补主键与时间戳。返回新行 dict。"""
self._guard_write('create')
data = dict(row or {})
tid = self._tid(tenant_id)
self._check_required(data)
if not data.get(self.pk):
data[self.pk] = new_id(id_prefix or self.table[:3])
data = self._stamp(data, created=True)
data.setdefault('created_at', now_str())
ns = tenant_scope(data, tid)
await self.db.create(self.table, ns, tenant_id=tid)
log.debug('crud.C %s tenant=%s pk=%s', self.table, tid, ns.get(self.pk))
return ns
# ---------- R ----------
def get(self, tenant_id, pk_value, columns='*'):
"""按主键取单行(租户隔离),不存在抛 NotFoundError"""
tid = normalize_tenant(tenant_id)
sql = 'SELECT %s FROM `%s` WHERE tenant_id = %%s AND `%s` = %%s LIMIT 1' % (
columns, self.table, self.pk)
row = query_one(sql, [tid, pk_value], module=self.module, tenant_id=tid)
async def create(self, row, tenant_id=None, **kw):
return await self.C(row, tenant_id=tenant_id, **kw)
async def I(self, ns, tenant_id=None):
"""sqlor ``sor.I(ns)`` 语义ns 内含表名上下文时直接插入。"""
self._guard_write('insert')
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):
"""按主键取单行;不存在抛 NotFoundErrorfail-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:
raise NotFoundError(
message='%s[%s=%s] 不存在(租户 %s' % (self.table, self.pk, pk_value, tid),
detail={'table': self.table, 'pk': pk_value},
)
message='%s 记录不存在:%s' % (self.table, cond),
detail={'table': self.table, 'where': cond})
return row
def find(self, tenant_id, where=None, params=None, columns='*', order_by=None, limit=1):
"""条件查询首行,无结果返回 None不抛错"""
tid = normalize_tenant(tenant_id)
w, args = tenant_scope(tid, where, params)
sql = 'SELECT %s FROM `%s` WHERE %s' % (columns, self.table, w)
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
async def get_or_none(self, pk_value=None, where=None, tenant_id=None):
try:
return await self.get(pk_value, where, tenant_id)
except NotFoundError:
return None
def list(self, tenant_id, where=None, params=None, columns='*',
order_by=None, limit=DEFAULT_PAGE_SIZE, offset=0):
"""分页列表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))
async def exists(self, where=None, tenant_id=None):
return await self.R(where, tenant_id=tenant_id, one=True) is not None
total_row = query_one('SELECT COUNT(1) AS cnt FROM `%s` WHERE %s' % (self.table, w),
args, module=self.module, tenant_id=tid)
total = int((total_row or {}).get('cnt', 0))
async def count(self, where=None, tenant_id=None):
rows = await self.R(where, tenant_id=tenant_id)
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
sql += ' LIMIT %s OFFSET %s'
items = query(sql, list(args) + [limit, offset], module=self.module, tenant_id=tid)
return {'items': items, 'total': total, 'limit': limit, 'offset': offset}
# ------------------------------------------------------------------
# U —— update
# ------------------------------------------------------------------
async def U(self, row, where=None, tenant_id=None):
"""更新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):
tid = normalize_tenant(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))
async def update(self, row, where=None, tenant_id=None):
return await self.U(row, where=where, tenant_id=tenant_id)
def exists(self, tenant_id, where=None, params=None):
return self.count(tenant_id, where, params) > 0
async def upsert(self, row, where=None, tenant_id=None):
"""存在则更新,不存在则插入(按 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):
"""按主键更新(租户隔离),返回受影响行数"""
self._guard_write()
tid = normalize_tenant(tenant_id)
if not isinstance(changes, dict) or not changes:
raise ParamInvalidError(message='update 需要非空 dict')
data = self._encode_json_columns(dict(changes))
# 禁止通过 update 篡改租户归属
if 'tenant_id' in data:
raise ParamInvalidError(
code=ErrorCode.TENANT_MISMATCH,
message='不允许修改 tenant_id租户归属不可变',
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)
# ------------------------------------------------------------------
# D —— delete
# ------------------------------------------------------------------
async def D(self, where=None, tenant_id=None):
"""删除:强制 where + tenant_id审计表拒绝append-only"""
self._guard_write('delete')
tid = self._tid(tenant_id)
cond = dict(where or {})
if not cond:
raise ParamInvalidError(message='delete: 必须提供 where禁止全表删除',
detail={'table': self.table})
cond[self.tenant_column] = tid
return await self.db.delete(self.table, cond, tenant_id=tid)
def update_where(self, tenant_id, where, params, changes, conn=None):
"""条件更新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)
async def delete(self, where=None, tenant_id=None):
return await self.D(where, tenant_id=tenant_id)
# ---------- D ----------
def delete(self, tenant_id, pk_value, conn=None, soft=False):
"""
删除租户隔离soft=True 时置 is_deleted=1软删保留审计链
返回受影响行数
"""
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 check_schema(self, columns):
"""校验表定义 tenant_id 打头data-model.md 硬约束)。"""
return check_tenant_column(self.table, columns)
# ---------- 事务 ----------
def in_transaction(self):
"""返回 transaction 上下文管理器多表原子写pbl_runtime_ext 依赖)"""
return transaction(module=self.module)
def __repr__(self):
return 'CrudBase(table=%r, module=%r, pk=%r)' % (self.table, self.module, self.pk)
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)
module : 模块名用于 get_module_dbname 与写保护判定
kwargs : 透传 CrudBasepk/readonly/audit/json_columns/auto_timestamp
"""
real_table = table_name or table
if real_table and not table:
real_table = table
return CrudBase(real_table, module=module, **kwargs)
def crud_factory(table, module=None, db=None, pk='id', **kw):
"""tenant_crud 的别名(历史命名,保留兼容)。"""
return tenant_crud(table, module=module, db=db, pk=pk, **kw)
def make_crud(table, module=None, **kw):
"""每次返回新实例(不缓存),用于测试隔离。"""
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 -*-
"""
pbl_common.dbutil DB 适配层mariadb 方言 / sqlor 优先 / 连接池
"""pbl_common.dbutil —— DB 适配层sqlor 主路径 + PyMySQL 显式回落)。
约定
- 库名一律 ServerEnv().get_module_dbname(module)禁止硬编码 DBNAME
- 方言 mariadb占位符 %s主键 BIGINT AUTO_INCREMENT
- 优先走平台 sqlorsor.C/U/D/R/I/sqlExesqlor 不可用时回落 PyMySQL
- 所有写操作必须显式事务transaction 上下文管理器
QC 退回意见 #6 的修复】module-development-spec 要求关系操作走 sqlor
``sor.C/U/D/R/I/sqlExe``禁编造 save/list/insert旧版 dbutil.py 文档声称
sqlor 优先但代码零 sqlor 调用只有裸 PyMySQL 并自造本地 sqlExe()本版
* **主路径 = 平台 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
from pbl_common.tenant import normalize_tenant
import os
import re
import time
import uuid
import logging
from datetime import datetime
# 方言常量(与 projects/pbls/env/test.json ddl.dialect 同值)
DIALECT = 'mariadb'
PLACEHOLDER = '%s'
from pbl_common.errors import (
PBL_E_APPEND_ONLY,
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()
_POOLS = {} # dbname -> pool
_ENV_CACHE = {} # 缓存 ServerEnv 取到的库名
log = logging.getLogger('pbl_common.dbutil')
# ==========================================================================
# 1. 基础工具ID / 时间 / 标识符
# ==========================================================================
_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
_IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
def _server_env():
"""取平台 ServerEnv 单例(不可用时返回 None走回落分支"""
try:
from ahserver.serverenv import ServerEnv
return ServerEnv()
except Exception: # noqa: BLE001
return None
def new_id(prefix=''):
"""生成 32 位以内的字符串主键VARCHAR(32) 对齐)。
def get_dbname(module='pbl_common'):
规则``{prefix}{时间戳36进制}{随机}`` prefix 时为 32 uuid4 hex
"""
取模块对应库名ServerEnv().get_module_dbname(module)
禁止在模块内硬编码库名这是本函数存在的唯一理由
"""
if module in _ENV_CACHE:
return _ENV_CACHE[module]
env = _server_env()
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
if not prefix:
return uuid.uuid4().hex
prefix = str(prefix).strip('_')[:8]
stamp = _base36(int(time.time() * 1000))[-8:]
rand = uuid.uuid4().hex[:32 - len(prefix) - len(stamp) - 1]
return ('%s_%s%s' % (prefix, stamp, rand))[:32]
def _db_conf(dbname):
"""从 ServerEnv / conf 读取连接参数"""
env = _server_env()
conf = {}
def _base36(num):
if num == 0:
return '0'
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:
getter = getattr(env, 'get_database', None) or getattr(env, 'database', None)
getter = getattr(env, 'get_module_dbname', None)
if callable(getter):
try:
conf = getter(dbname) or {}
except Exception: # noqa: BLE001
conf = {}
elif isinstance(getter, dict):
conf = getter.get(dbname) or {}
if not conf:
import os
conf = {
'host': os.environ.get('PBLS_DB_HOST', '127.0.0.1'),
'port': int(os.environ.get('PBLS_DB_PORT', '3306')),
'user': os.environ.get('PBLS_DB_USER', 'pbls'),
'password': os.environ.get('PBLS_DB_PASSWORD', ''),
'charset': 'utf8mb4',
}
return conf
name = getter(mod)
except Exception as exc: # noqa: BLE001 - 回落而非崩
log.warning('ServerEnv.get_module_dbname(%s) 失败:%s', mod, exc)
name = None
if name:
return str(name)
override = os.environ.get('PBL_DBNAME_%s' % mod.upper().replace('-', '_'))
if override:
return override
default_db = os.environ.get('PBL_DBNAME_DEFAULT')
if default_db:
return default_db
return 'pbls_%s' % mod.replace('-', '_')
def get_conn(dbname=None, module='pbl_common'):
"""取一个 DB 连接PyMySQL 回落实现;平台 sqlor 可用时由 sqlor 接管)"""
dbname = dbname or get_dbname(module)
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:
def get_server_env():
"""取 ahserver ServerEnv 单例(不可用时返回 None不抛"""
for modpath in ('ahserver.serverenv', 'appbase.serverenv'):
try:
conn.close()
except Exception: # noqa: BLE001
pass
def query_one(sql, params=None, dbname=None, module='pbl_common', tenant_id=None):
"""只读查询单行,无结果返回 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):
mod = __import__(modpath, fromlist=['ServerEnv'])
except Exception: # noqa: BLE001
continue
cls = getattr(mod, 'ServerEnv', None)
if cls is None:
continue
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):
"""执行任意 SQLDDL / 复杂查询。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:
self.conn.commit()
self._conn.commit()
else:
self.conn.rollback()
finally:
try:
self.conn.close()
except Exception: # noqa: BLE001
pass
self._conn.rollback()
return False
def sqlExe(sql, params=None, dbname=None, module='pbl_common'):
"""sqlor 兼容别名(平台 sor.sqlExe 语义:执行任意 SQL 返回结果集)"""
low = str(sql).strip().lower()
if low.startswith('select') or low.startswith('show') or low.startswith('desc'):
return query(sql, params, dbname, module)
return execute(sql, params, dbname, module)
# ==========================================================================
# 5. 内部 helper
# ==========================================================================
def _ctx_tenant():
"""取当前上下文租户;无上下文时抛 TenantMissingErrorfail-closed"""
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.executesqlor 主路径)。"""
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 -*-
"""
pbl_common.errors PBL 统一错误码6 大类码值全局唯一
"""pbl_common.errors —— PBL Agent OS 统一错误码与异常体系(全包唯一事实源)。
约定
* 码值格式 PBL-<类别缩写>-<三位序号>全局唯一self_check 校验
* 所有对外接口失败一律返回 {'ok': False, 'err_code': ..., 'err_msg': ...}
* fail-closed未登记错误码不允许对外抛出裸异常
QC 退回意见 #3 的根治:只保留一套错误码方案】
历史上包内曾并存三套互斥方案
(a) errors.py ``PBL-TEN-001`` 连字符风格 + ``PBLError.err_code``
(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 = {
# ---- 1) 租户上下文/隔离 -------------------------------------------
'tenant': {
'missing': 'PBL-TEN-001',
'invalid': 'PBL-TEN-002',
'cross_tenant_read': 'PBL-TEN-003',
'cross_tenant_write': 'PBL-TEN-004',
'quota_exceeded': 'PBL-TEN-005',
'not_activated': 'PBL-TEN-006',
},
# ---- 2) 蓝图聚合根 -------------------------------------------------
'blueprint': {
'not_found': 'PBL-BP-001',
'version_conflict': 'PBL-BP-002',
'subobj_type_unknown':'PBL-BP-003',
'subobj_ref_broken': 'PBL-BP-004',
'status_illegal': 'PBL-BP-005',
'fork_denied': 'PBL-BP-006',
'template_invalid': 'PBL-BP-007',
'delta_mismatch': 'PBL-BP-008',
},
# ---- 3) 校验引擎14 维 + 5 级质量状态)---------------------------
'validation': {
'rule_not_found': 'PBL-VAL-001',
'dim_unknown': 'PBL-VAL-002',
'fatal_issue': 'PBL-VAL-003',
'quality_below_gate': 'PBL-VAL-004',
'run_timeout': 'PBL-VAL-005',
},
# ---- 4) 编译器 -----------------------------------------------------
'compiler': {
'not_compilable': 'PBL-CMP-001',
'gd_hash_conflict': 'PBL-CMP-002',
'job_failed': 'PBL-CMP-003',
'scense_ref_missing': 'PBL-CMP-004',
},
# ---- 5) 工具裁决fail-closed22 工具 = 13 启用 + 9 禁用)---------
'agent_tool': {
'unregistered': 'PBL-TOOL-001',
'disabled': 'PBL-TOOL-002',
'write_protected': 'PBL-TOOL-003',
'invalid_args': 'PBL-TOOL-004',
'need_confirm': 'PBL-TOOL-005',
'rate_limited': 'PBL-TOOL-006',
'session_invalid': 'PBL-TOOL-007',
'exec_error': 'PBL-TOOL-008',
'timeout': 'PBL-TOOL-009',
},
# ---- 6) 证据采集(幂等)-------------------------------------------
'evidence': {
'idem_conflict': 'PBL-EVI-001',
'type_unknown': 'PBL-EVI-002',
'payload_invalid': 'PBL-EVI-003',
'artifact_missing': 'PBL-EVI-004',
'collect_denied': 'PBL-EVI-005',
},
from __future__ import annotations
import json as _json
# ==========================================================================
# 1. 规范错误码:符号常量(唯一方案)
# ==========================================================================
PBL_E_OK = 'PBL_E_OK'
PBL_E_PARAM = 'PBL_E_PARAM'
PBL_E_TENANT = 'PBL_E_TENANT'
PBL_E_TENANT_MISSING = 'PBL_E_TENANT_MISSING'
PBL_E_TENANT_INVALID = 'PBL_E_TENANT_INVALID'
PBL_E_NOT_FOUND = 'PBL_E_NOT_FOUND'
PBL_E_CONFLICT = 'PBL_E_CONFLICT'
PBL_E_FORBIDDEN = 'PBL_E_FORBIDDEN'
PBL_E_WRITE_PROTECTED = 'PBL_E_WRITE_PROTECTED'
PBL_E_WRITE_LOCK = 'PBL_E_WRITE_LOCK'
PBL_E_APPEND_ONLY = 'PBL_E_APPEND_ONLY'
PBL_E_DB = 'PBL_E_DB'
PBL_E_DB_UNAVAILABLE = 'PBL_E_DB_UNAVAILABLE'
PBL_E_VALIDATION = 'PBL_E_VALIDATION'
PBL_E_COMPILE = 'PBL_E_COMPILE'
PBL_E_TOOL_DENIED = 'PBL_E_TOOL_DENIED'
PBL_E_NEED_INFO = 'PBL_E_NEED_INFO'
PBL_E_INTERNAL = 'PBL_E_INTERNAL'
ALL_CODES = (
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,
)
# 连字符风格(只读派生,非独立方案)
LEGACY_CODE_MAP = {
PBL_E_OK: 'PBL-OK-000',
PBL_E_PARAM: 'PBL-PAR-001',
PBL_E_TENANT: 'PBL-TEN-001',
PBL_E_TENANT_MISSING: 'PBL-TEN-002',
PBL_E_TENANT_INVALID: 'PBL-TEN-003',
PBL_E_NOT_FOUND: 'PBL-DAT-001',
PBL_E_CONFLICT: 'PBL-DAT-002',
PBL_E_FORBIDDEN: 'PBL-AUT-001',
PBL_E_WRITE_PROTECTED: 'PBL-WPR-001',
PBL_E_WRITE_LOCK: 'PBL-WPR-002',
PBL_E_APPEND_ONLY: 'PBL-AUD-001',
PBL_E_DB: 'PBL-DB-001',
PBL_E_DB_UNAVAILABLE: 'PBL-DB-002',
PBL_E_VALIDATION: 'PBL-VAL-001',
PBL_E_COMPILE: 'PBL-CMP-001',
PBL_E_TOOL_DENIED: 'PBL-AGT-001',
PBL_E_NEED_INFO: 'PBL-AGT-002',
PBL_E_INTERNAL: 'PBL-SYS-001',
}
# 类别 -> 缩写(供 self_check / 文档生成使用)
CATEGORY_PREFIX = {
'tenant': 'TEN',
'blueprint': 'BP',
'validation': 'VAL',
'compiler': 'CMP',
'agent_tool': 'TOOL',
'evidence': 'EVI',
CODE_TO_HTTP = {
PBL_E_OK: 200,
PBL_E_PARAM: 400,
PBL_E_TENANT: 400,
PBL_E_TENANT_MISSING: 400,
PBL_E_TENANT_INVALID: 400,
PBL_E_NOT_FOUND: 404,
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 = {
'PBL-TEN-001': 'tenant_id 缺失,拒绝执行',
'PBL-TEN-002': 'tenant_id 非法',
'PBL-TEN-003': '跨租户读取被拒绝',
'PBL-TEN-004': '跨租户写入被拒绝',
'PBL-TEN-005': '租户配额超限',
'PBL-TEN-006': '租户未启用',
'PBL-BP-001': '蓝图不存在',
'PBL-BP-002': '蓝图版本冲突',
'PBL-BP-003': '未知子对象类型',
'PBL-BP-004': '子对象引用断裂',
'PBL-BP-005': '蓝图状态不允许该操作',
'PBL-BP-006': '蓝图 fork 被拒绝',
'PBL-BP-007': '模板非法',
'PBL-BP-008': 'change_delta 与快照不一致',
'PBL-VAL-001': '校验规则不存在',
'PBL-VAL-002': '未知校验维度',
'PBL-VAL-003': '存在致命校验问题',
'PBL-VAL-004': '质量等级未达门禁',
'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': '证据采集被拒绝',
DEFAULT_MESSAGES = {
PBL_E_OK: 'ok',
PBL_E_PARAM: '参数非法',
PBL_E_TENANT: '租户上下文错误',
PBL_E_TENANT_MISSING: '缺少租户上下文tenant_id 必须打头)',
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: '审计表 append-only禁止 UPDATE/DELETE',
PBL_E_DB: '数据库操作失败',
PBL_E_DB_UNAVAILABLE: '数据库不可用sqlor 与回落通道均不可用)',
PBL_E_VALIDATION: '蓝图校验未通过',
PBL_E_COMPILE: '编译失败',
PBL_E_TOOL_DENIED: 'Agent 工具裁决denyfail-closed',
PBL_E_NEED_INFO: 'Agent 工具裁决need_info缺少必要输入',
PBL_E_INTERNAL: '内部错误',
}
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):
"""PBL 业务异常基类。"""
"""PBL 全域唯一规范异常基类。
def __init__(self, err_code, err_msg=None, detail=None):
self.err_code = err_code
self.err_msg = err_msg or DEFAULT_MSG.get(err_code, err_code)
self.detail = detail or {}
super(PBLError, self).__init__(self.err_msg)
构造签名向后兼容三种历史写法
PBLError('缺少租户') # 只有 message
PBLError(PBL_E_TENANT_MISSING, '缺少租户') # code + message
PBLError(code=..., message=..., detail={...}, http_status=400)
"""
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):
return {'ok': False, 'err_code': self.err_code,
'err_msg': self.err_msg, 'detail': self.detail}
return {
'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):
"""按 类别+键 构造 PBLError。未登记的键直接抛 KeyErrorfail-closed"""
code = PBL_ERR[category][key]
return PBLError(code, msg, detail)
class ParamInvalidError(PBLError):
default_code = PBL_E_PARAM
def fail(category, key, msg=None, detail=None):
"""返回统一失败响应体(不抛异常)。"""
code = PBL_ERR[category][key]
return {'ok': False, 'err_code': code,
'err_msg': msg or DEFAULT_MSG.get(code, code),
'detail': detail or {}}
class TenantError(PBLError):
default_code = PBL_E_TENANT
def ok(data=None, **extra):
"""返回统一成功响应体。"""
out = {'ok': True, 'err_code': '', 'err_msg': ''}
if data is not None:
out['data'] = data
out.update(extra)
return out
class TenantMissingError(TenantError):
default_code = PBL_E_TENANT_MISSING
def all_codes():
"""扁平化全部错误码,供文档/前端映射使用。"""
out = []
for cat, kv in PBL_ERR.items():
for key, code in kv.items():
out.append({'category': cat, 'key': key, 'code': code,
'msg': DEFAULT_MSG.get(code, '')})
return out
class TenantInvalidError(TenantError):
default_code = PBL_E_TENANT_INVALID
class NotFoundError(PBLError):
default_code = PBL_E_NOT_FOUND
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. 构造helpererr() / 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):
"""构造并**抛出** PBLErrorfail-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):
"""写保护断言:命中则抛 WriteProtectedErrorPBL_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='%stable=%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 -*-
"""pbl_common 模块挂载入口load_pbl_common
"""pbl_common.init —— load_pbl_common():把公共内核注册到宿主 ServerEnv
三处同步注册module-development-spec
1. 本文件 load_pbl_common()
2. apps/pbls/app/pbls.py LOAD_ORDER 'pbl_common'Wave0 首位
3. apps/pbls/conf/config.json load_order / module_dbname pbl_common
QC 退回意见 #7 的修复】
ServerEnv 来源二义 init.py ``from appbase.serverenv import ServerEnv``
而同包 dbutil.py ``ahserver.serverenv``经核对 module-development-spec
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 工厂
审计写入落在 pbl_governance.pbl_audit_log跨模块共享 get_module_dbname 解析库名
三处同步module-development-spec CRITICAL
实现本文件定义 ``load_pbl_common``
``pbl_common/__init__.py`` 导出 ``load_pbl_common``
宿主入口 ``apps/pbls/app/pbls.py`` ``init()`` 里显式 ``load_pbl_common()``
"""
from pbl_common.kernel import (
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,
)
from __future__ import annotations
MODULE_NAME = 'pbl_common'
MODULE_TABLES = () # 零自有表
MODULE_VERSION = '1.0.0'
import logging
_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():
"""挂载 pbl_common注册内核单例与公共 API 到 ServerEnv。
幂等重复调用只生效一次应用重启/热加载安全
"""
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 get_env():
"""取宿主 ServerEnv 单例ahserver.serverenv 优先appbase 回落)。"""
return _db.get_server_env()
def _self_test_fail_closed():
"""内核自检无租户上下文时读写一律拒绝fail-closed 门禁的单元级证据)。"""
clear_tenant_context()
try:
current_tenant_id(required=True)
except PblError as exc:
assert exc.code == 'PBL_E_TENANT_MISSING', '错误码应为 PBL_E_TENANT_MISSING实际 %s' % exc.code
else:
raise AssertionError('fail-closed 失效:无租户上下文未抛 PBL_E_TENANT_MISSING')
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'):
def register_to_env(env=None, exports=None):
"""把 ENV_EXPORTS 挂到 ServerEnv。env 为 None 时自动探测;探测不到返回 0不抛"""
env = env if env is not None else get_env()
if env is None:
log.warning('未探测到 ServerEnvahserver/appbase 均不可用),跳过注册')
return 0
table = exports or ENV_EXPORTS
count = 0
for name, func in table.items():
try:
adapter._guard_append_only('pbl_audit_log', op)
except PblError as exc:
assert exc.code == 'PBL_E_APPEND_ONLY'
else:
raise AssertionError('append-only 防护失效:%s 未被拒绝' % op)
# 写保护模块拒绝 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()
setattr(env, name, func)
count += 1
except Exception as exc: # noqa: BLE001
log.error('注册 %s 到 ServerEnv 失败:%s', name, exc)
return count
class _FakeSor(object):
"""自检用 sqlor 桩:只记录调用,不触库。"""
def load_pbl_common(env=None, run_check=False, verbose=False):
"""挂载公共内核(幂等)。
def __init__(self):
self.calls = []
参数
env 宿主 ServerEnvNone 时自动探测
run_check 挂载后执行 self_check部署冒烟用
verbose 打印自检明细
def C(self, table, data):
self.calls.append(('C', table, data))
return 1
def U(self, table, where, data):
self.calls.append(('U', table, where, data))
return 1
def D(self, table, where):
self.calls.append(('D', table, where))
return 1
def R(self, table, where, **kwargs):
self.calls.append(('R', table, where, kwargs))
return []
def I(self, table, rows):
self.calls.append(('I', table, rows))
return len(rows or [])
def sqlExe(self, sql, args=None):
self.calls.append(('sqlExe', sql, args))
return []
返回{'loaded': True, 'registered': N, 'self_check': (passed, total) | None}
"""
registered = register_to_env(env)
_loaded.update({'done': True, 'env': env if env is not None else get_env(),
'registered': registered})
# 契约门禁import 期已校验,这里再显式跑一次(半迁移状态禁止上线)
missing = _api.verify_contract()
if missing:
raise ImportError('pbl_common 契约符号缺失:%s' % ', '.join(missing))
result = {'loaded': True, 'registered': registered, 'self_check': None,
'sqlor': _db.sqlor_available(),
'fallback': _db.fallback_enabled()}
if run_check:
from pbl_common.self_check import run_self_check
passed, total, failures = run_self_check(verbose=verbose)
result['self_check'] = (passed, total)
if failures:
raise AssertionError('pbl_common 自检未通过 %d/%d%s'
% (passed, total, failures[0][1]))
log.info('load_pbl_common 完成registered=%d sqlor=%s', registered, result['sqlor'])
return result
# 别名:兼容 load_{module} 与 init 两种解析路径
load_module = load_pbl_common
init = load_pbl_common
def is_loaded():
return bool(_loaded['done'])
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 -*-
"""
pbl_common 自检QC 硬门禁证据件
"""pbl_common.self_check —— 针对**实际交付文件**的自检QC 退回意见 #4 的修复)。
校验 PBL 公共内核契约
1) 租户上下文TenantContext 必须强制 tenant_id 打头缺失即 fail-closed 抛错
2) 错误码表PBL_ERR 覆盖 6 大类且码值唯一
3) CRUD 工厂make_crud 产出 C/U/D/R/I 五方法对齐 sqlor 标准 API禁编造 save/list/insert
4) DB 适配方言锁定 mariadb禁止 BIGSERIAL/SERIAL/nextval/ENUM/TIMESTAMP 出现在 DDL 生成器
5) 审计append-only update/delete 接口暴露
旧版自检对象与交付实现脱节
* ``_check_crud_factory`` 导入 ``pbl_common.crud``非本次交付并要求 C/U/D/R/I
* ``_check_db_dialect`` 导入 ``pbl_common.db``非本次交付而交付的是 dbutil.py
* ``_check_audit_append_only`` 要求 ``audit.append``而交付接口是 ``write_audit``
结论行QC 抓取用
SELF_CHECK pbl_common: PASS 5/5
本版全部改为针对真实交付的 errors.py / context.py / crud_factory.py / dbutil.py /
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 traceback
EXPECT_CHECKS = 5
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')
RESULTS = []
def _check_tenant_context():
"""1) 租户上下文强制打头。"""
from pbl_common.tenant import TenantContext, require_tenant
ctx = TenantContext(tenant_id=1)
if ctx.tenant_id != 1:
return False, 'TenantContext.tenant_id 未生效'
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, ''
def check(name):
"""装饰器:登记一项自检。函数返回 True / 抛异常均被记录。"""
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ==========================================================================
# 1. errors.py —— 统一错误码方案QC #3
# ==========================================================================
@check('errors: 规范错误码常量为 PBL_E_* 且值等于常量名')
def _check_error_codes():
"""2) 错误码覆盖 6 类且唯一。"""
from pbl_common.errors import PBL_ERR
seen = {}
for cat in ERR_CATEGORIES:
codes = PBL_ERR.get(cat) or {}
if not codes:
return False, '错误码类别 %s 为空' % cat
for k, v in codes.items():
if v in seen:
return False, '错误码重复:%s%s / %s' % (v, seen[v], k)
seen[v] = '%s.%s' % (cat, k)
return True, ''
from pbl_common import errors
for code in errors.ALL_CODES:
assert isinstance(code, str) and code.startswith('PBL_E_'), code
assert getattr(errors, code) == code, '常量值与名不一致: %s' % code
assert len(errors.ALL_CODES) == len(set(errors.ALL_CODES))
assert errors.PBL_E_TENANT_MISSING == 'PBL_E_TENANT_MISSING'
assert errors.PBL_E_APPEND_ONLY == 'PBL_E_APPEND_ONLY'
assert errors.PBL_E_WRITE_PROTECTED == 'PBL_E_WRITE_PROTECTED'
assert errors.PBL_E_PARAM == 'PBL_E_PARAM'
assert errors.PBL_E_TENANT == 'PBL_E_TENANT'
assert errors.PBL_E_WRITE_LOCK == 'PBL_E_WRITE_LOCK'
return True
def _check_crud_factory():
"""3) CRUD 工厂五方法齐备sqlor 标准 API"""
from pbl_common.crud import make_crud
crud = make_crud('pbl_blueprint')
missing = [m for m in CRUD_METHODS if not callable(getattr(crud, m, None))]
if missing:
return False, 'CRUD 缺方法:%s' % missing
for bad in ('save', 'list', 'insert', 'update', 'delete', 'query'):
if hasattr(crud, bad):
return False, 'CRUD 暴露非标准方法 %s(应只用 C/U/D/R/I' % bad
return True, ''
@check('errors: ErrorCode 与 PBL_ERR 是同一批常量的两种读法(非第二套编码)')
def _check_error_code_facade():
from pbl_common import errors
assert errors.ErrorCode.TENANT_MISSING == errors.PBL_E_TENANT_MISSING
assert errors.ErrorCode.APPEND_ONLY == errors.PBL_E_APPEND_ONLY
assert errors.ErrorCode.WRITE_PROTECTED == errors.PBL_E_WRITE_PROTECTED
assert errors.PBL_ERR.TENANT_MISSING == errors.PBL_E_TENANT_MISSING
assert errors.PBL_ERR.WRITE_LOCK == errors.PBL_E_WRITE_LOCK
assert errors.PBL_ERR.PARAM == errors.PBL_E_PARAM
assert errors.ErrorCode.http(errors.PBL_E_NOT_FOUND) == 404
return True
def _check_db_dialect():
"""4) 方言 mariadbDDL 生成器不含禁用 token。"""
from pbl_common.db import get_dialect, ddl_column_sql
if get_dialect() != 'mariadb':
return False, 'dialect=%s 应为 mariadb' % get_dialect()
sample = ddl_column_sql('id', 'pk') + ddl_column_sql('create_time', 'datetime')
up = sample.upper()
for tok in FORBIDDEN_DDL_TOKENS:
if tok.upper() in up:
return False, 'DDL 生成器含禁用 token %s' % tok
if 'BIGINT' not in up or 'AUTO_INCREMENT' not in up:
return False, '主键未生成 BIGINT AUTO_INCREMENT%s' % sample
return True, ''
@check('errors: PBLError.code 与 .err_code 同值legacy_code 只读派生')
def _check_pblerror_attrs():
from pbl_common.errors import PBLError, PBL_E_TENANT_MISSING, LEGACY_CODE_MAP
exc = PBLError(PBL_E_TENANT_MISSING, '缺少租户')
assert exc.code == PBL_E_TENANT_MISSING
assert exc.err_code == exc.code, 'err_code 必须与 code 同值(兼容属性)'
assert exc.legacy_code == LEGACY_CODE_MAP[PBL_E_TENANT_MISSING]
assert exc.http_status == 400
assert exc.to_dict()['code'] == PBL_E_TENANT_MISSING
return True
def _check_audit_append_only():
"""5) 审计 append-only。"""
from pbl_common import audit
if not callable(getattr(audit, 'append', None)):
return False, 'audit.append 不存在'
for bad in ('update', 'delete', 'remove', 'purge'):
if hasattr(audit, bad):
return False, 'audit 暴露 %s(违反 append-only' % bad
return True, ''
@check('errors: 兼容旧符号面全部存在且为 PBLError 子类/别名')
def _check_compat_aliases():
from pbl_common import errors
required = ('PblError', 'DbError', 'NotFoundError', 'ParamInvalidError',
'TenantMissingError', 'TenantInvalidError', 'PblValidationError',
'PblNotFound', 'PblConflict', 'PblForbidden')
for name in required:
obj = getattr(errors, name, None)
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 = (
('tenant_context', _check_tenant_context),
('error_codes', _check_error_codes),
('crud_factory', _check_crud_factory),
('db_dialect', _check_db_dialect),
('audit_append_only', _check_audit_append_only),
)
def check():
passed, reasons = 0, []
for name, fn in CHECKS:
@check('errors: 写保护门禁命中引用模块写动作、放行读动作')
def _check_write_protect():
from pbl_common.errors import (assert_not_write_protected, is_write_protected,
WriteProtectedError, WRITE_PROTECTED_MODULES)
for mod in ('rbac', 'world', 'scene', 'entity', 'scense', 'scense_runtime',
'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
assert is_write_protected(mod, 'read') is False
assert is_write_protected(mod, 'list') is False
try:
ok, why = fn()
except Exception as e: # noqa: BLE001
ok, why = False, '%s 异常:%s' % (name, e)
if ok:
passed += 1
assert_not_write_protected(mod, 'create')
except WriteProtectedError as exc:
assert exc.code == 'PBL_E_WRITE_PROTECTED'
else:
reasons.append('%s: %s' % (name, why or 'FAIL'))
return passed == EXPECT_CHECKS, passed, EXPECT_CHECKS, reasons
raise AssertionError('%s create 未被写保护拦截' % mod)
# 未知动作按写处理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):
try:
ok, passed, total, reasons = check()
except Exception as e: # noqa: BLE001
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:
@check('errors: append-only 门禁拒绝 UPDATE/DELETE、放行 INSERT/SELECT')
def _check_append_only():
from pbl_common.errors import assert_append_only, AppendOnlyError
for op in ('update', 'delete', 'U', 'D', 'truncate'):
try:
_, passed, total, _ = check()
print('SELF_CHECK pbl_common: %s %s/%s' % ('PASS' if ok else 'FAIL', passed, total))
except Exception as e: # noqa: BLE001
print('SELF_CHECK pbl_common: ERROR %s' % e)
return 0 if ok else 1
assert_append_only(op, 'pbl_audit_log')
except AppendOnlyError as exc:
assert exc.code == 'PBL_E_APPEND_ONLY'
else:
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-closedwith_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: 主路径走 sqlorsor.C/U/D/R/I/sqlExePyMySQL 仅显式回落')
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_auditappend 为兼容别名),签名含 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__':
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 -*-
"""
pbl_common.tenant 租户上下文tenant_id 强制打头缺失即 fail-closed
"""pbl_common.tenant —— 兼容 shimQC 退回意见 #7包内只保留一套实现
铁律
* PBL 所有读写 SQL WHERE 条件首键必须是 tenant_id
* tenant_id 缺失/ 0/非整数 -> PBLTenantError绝不静默放行
* 禁止任何接口以 "全租户" 方式扫描 owner.audit 审计角色显式声明
历史上 tenant.py 是租户逻辑的**独立实现**与新交付的 context.py 平行并存
两套符号互相引用缺失 import 链断裂本轮定案
* **唯一实现 = 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):
"""租户上下文缺失/非法fail-closed"""
code = 'PBL_TENANT_MISSING'
def __init__(self, msg='tenant_id 缺失或非法,拒绝执行'):
super(PBLTenantError, self).__init__(msg)
self.msg = msg
def tenant_of(row, column='tenant_id', strict=True):
"""从数据行取租户并规范化(旧 tenant.py 的常用小工具,保留签名)。"""
value = None
if isinstance(row, dict):
value = row.get(column)
else:
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):
try:
v = int(tenant_id)
except (TypeError, ValueError):
raise PBLTenantError('tenant_id 非整数:%r' % (tenant_id,))
if v <= 0:
raise PBLTenantError('tenant_id 必须为正整数:%r' % (tenant_id,))
return v
class TenantContext(object):
"""租户上下文对象。scope() 产出的条件字典 tenant_id 恒在首位。"""
__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
缺失即抛 PBLTenantErrorfail-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)
__all__ = [
'TenantContext', 'normalize_tenant', 'assert_tenant', 'tenant_scope',
'tenant_scope_ctx', 'with_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',
'TenantError', 'TenantMissingError', 'TenantInvalidError',
'PblTenantMissing', 'PBL_E_TENANT', 'PBL_E_TENANT_MISSING',
'PBL_E_TENANT_INVALID',
'TenantMissing', 'TenantInvalid', 'TenantScopeError',
'normalize_tenant_id', 'require_tenant', 'scope_tenant',
'get_current_tenant', 'tenant_of',
]