approve: [M1b] pbl_blueprint 模板/子对象扩展与关联表
This commit is contained in:
parent
e59d537691
commit
82e5e1d9f2
@ -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',
|
||||
]
|
||||
|
||||
@ -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_INTERNAL(fail-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_id(fail-closed 失效)'
|
||||
except PblError as e:
|
||||
if e.code == ErrorCode.TENANT_MISSING:
|
||||
return True, 'code=%s' % e.code
|
||||
return False, '错误码不符:%s' % e.code
|
||||
_add('tenant_missing_fail_closed', t_tenant_missing)
|
||||
|
||||
# 2 上下文绑定/还原
|
||||
def t_bind_unbind():
|
||||
ctx = build_context('t_demo', user_id='u1', role='teacher', trace_id='tr1')
|
||||
old = bind_context(ctx)
|
||||
ok1 = require_tenant() == 't_demo'
|
||||
unbind_context(old)
|
||||
ok2 = current_context() is None
|
||||
return (ok1 and ok2), 'bind=%s unbind=%s' % (ok1, ok2)
|
||||
_add('context_bind_unbind', t_bind_unbind)
|
||||
|
||||
# 3 非法 tenant_id 全拒
|
||||
def t_tenant_invalid():
|
||||
bad = [None, '', ' ', 123, 'x' * 65, "a'b", 'a;b', 'a--b']
|
||||
for v in bad:
|
||||
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',
|
||||
]
|
||||
|
||||
@ -1,101 +1,309 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_common.audit —— append-only 审计(pbl_audit_event)
|
||||
"""pbl_common.audit —— append-only 审计写入(自包含实现)。
|
||||
|
||||
铁律:
|
||||
* 只暴露 append(),绝不暴露 update/delete/remove/purge(self_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.md:pbl_audit_log,tenant_id 打头)
|
||||
AUDIT_TABLE = 'pbl_audit_log'
|
||||
|
||||
#: 合法动作枚举(超出即按 other 记录,不拒绝——审计宁可多记不可漏记)
|
||||
AUDIT_ACTIONS = (
|
||||
'create', 'read', 'update', 'delete', 'login', 'logout',
|
||||
'validate', 'compile', 'tool_call', 'tool_deny', 'seed', 'deploy',
|
||||
'export', 'import', 'fork', 'publish', 'archive', 'other',
|
||||
)
|
||||
|
||||
#: 内存缓冲上限(超出丢最旧,但先尝试 flush)
|
||||
MEMORY_BUFFER_LIMIT = 5000
|
||||
|
||||
_buffer = []
|
||||
_buffer_lock = threading.Lock()
|
||||
_stats = {'written': 0, 'buffered': 0, 'flushed': 0, 'failed': 0}
|
||||
|
||||
|
||||
def _now():
|
||||
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):
|
||||
"""构造一条审计记录 dict(tenant_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 运行时跨租户任务用)。
|
||||
|
||||
返回:落库的审计记录 dict;DB 不可用时记录进内存缓冲并返回
|
||||
``{'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',
|
||||
]
|
||||
|
||||
@ -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.md(36 表全部 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_id:1~64 位,字母/数字/下划线/中划线/点(与 VARCHAR(64) 对齐)
|
||||
TENANT_ID_PATTERN = re.compile(r'^[A-Za-z0-9_.\-]{1,64}$')
|
||||
|
||||
# tenant_id 合法长度上限(varchar(64),与 data-model.md 对齐)
|
||||
TENANT_ID_MAX_LEN = 64
|
||||
#: 系统级保留租户(平台自身运维用,业务代码不得当普通租户写入)
|
||||
SYSTEM_TENANT_IDS = frozenset({'0', 'system', 'platform', '__system__'})
|
||||
|
||||
#: 明确非法的伪租户值(前端常传的空占位)
|
||||
INVALID_TENANT_VALUES = frozenset({
|
||||
'', 'none', 'null', 'nil', 'undefined', 'nan', 'unknown',
|
||||
'-', '--', 'tbd', 'todo', 'placeholder', '待明确', '占位',
|
||||
})
|
||||
|
||||
#: 上下文变量:当前请求/任务的租户
|
||||
_tenant_var = ContextVar('pbl_tenant_id', default=None)
|
||||
#: 上下文变量:当前 actor(Agent 运行时写入)
|
||||
_actor_var = ContextVar('pbl_actor', default=None)
|
||||
#: 上下文变量:当前 trace_id(审计串联)
|
||||
_trace_var = ContextVar('pbl_trace_id', default=None)
|
||||
|
||||
|
||||
class TenantContext(object):
|
||||
"""租户上下文(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():
|
||||
"""取当前线程上下文,未绑定即抛 TenantMissingError(fail-closed)"""
|
||||
ctx = current_context()
|
||||
if ctx is None:
|
||||
入参优先级:显式 tenant_id > 显式 ctx > 当前上下文变量。
|
||||
缺失即抛 TenantMissingError(fail-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 返回的 token;None 时置空)。"""
|
||||
if token is not None:
|
||||
try:
|
||||
_tenant_var.reset(token)
|
||||
return True
|
||||
except (ValueError, LookupError):
|
||||
pass
|
||||
_tenant_var.set(None)
|
||||
return False
|
||||
|
||||
|
||||
def get_tenant(strict=True):
|
||||
"""取当前上下文 tenant_id。strict=True 缺失即抛(默认 fail-closed)。"""
|
||||
tid = _tenant_var.get()
|
||||
if strict:
|
||||
return assert_tenant(tid)
|
||||
return normalize_tenant(tid, strict=False) if tid else ''
|
||||
|
||||
|
||||
def current_tenant(strict=True):
|
||||
"""get_tenant 的语义化别名。"""
|
||||
return get_tenant(strict=strict)
|
||||
|
||||
|
||||
def current_context(strict=True):
|
||||
"""取当前完整 TenantContext(无上下文且 strict 时抛 TenantMissingError)。"""
|
||||
tid = _tenant_var.get()
|
||||
if tid is None or str(tid).strip() == '':
|
||||
if strict:
|
||||
raise TenantMissingError(message='当前无租户上下文')
|
||||
return None
|
||||
actor = _actor_var.get() or {}
|
||||
return TenantContext(tid, actor_type=actor.get('actor_type', 'human'),
|
||||
actor_id=actor.get('actor_id'), trace_id=_trace_var.get())
|
||||
|
||||
|
||||
def has_tenant():
|
||||
tid = _tenant_var.get()
|
||||
return bool(tid and str(tid).strip() != '')
|
||||
|
||||
|
||||
class with_tenant(object):
|
||||
"""上下文管理器 / 装饰器双用:在作用域内绑定租户。
|
||||
|
||||
用法::
|
||||
|
||||
with with_tenant('t_001') as ctx:
|
||||
await create_blueprint(...)
|
||||
|
||||
@with_tenant('t_001')
|
||||
async def handler(...): ...
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_id, actor_type='human', actor_id=None,
|
||||
trace_id=None, extra=None):
|
||||
self._spec = dict(tenant_id=tenant_id, actor_type=actor_type,
|
||||
actor_id=actor_id, trace_id=trace_id, extra=extra)
|
||||
self._token = None
|
||||
self._ctx = None
|
||||
self._func = None
|
||||
if callable(tenant_id):
|
||||
# @with_tenant 无参装饰函数用法
|
||||
self._func = tenant_id
|
||||
self._spec['tenant_id'] = None
|
||||
|
||||
# ---- with 语义 -------------------------------------------------------
|
||||
def __enter__(self):
|
||||
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',
|
||||
]
|
||||
|
||||
@ -1,169 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_common.crud —— CRUD 工厂(sqlor 标准 API:仅 C/U/D/R/I/sqlExe)
|
||||
"""pbl_common.crud —— 兼容 shim(QC 退回意见 #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 封装(方法名严格对齐 sqlor:C/U/D/R/I)。"""
|
||||
|
||||
def __init__(self, tblname, module=None, pk='id', soft_delete=True,
|
||||
tenant_field='tenant_id'):
|
||||
self.tblname = tblname
|
||||
self.module = module
|
||||
self.pk = pk
|
||||
self.soft_delete = soft_delete and tblname not in APPEND_ONLY_TABLES
|
||||
self.tenant_field = tenant_field
|
||||
self._db = None
|
||||
|
||||
# ---- DB 句柄 -------------------------------------------------------
|
||||
@property
|
||||
def db(self):
|
||||
if self._db is None:
|
||||
self._db = get_db(self.module)
|
||||
return self._db
|
||||
|
||||
def close(self):
|
||||
if self._db is not None:
|
||||
try:
|
||||
self._db.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._db = None
|
||||
|
||||
# ---- C:新增 -------------------------------------------------------
|
||||
def C(self, params):
|
||||
"""新增一行。params 必须含 tenant_id(缺失即 fail-closed)。"""
|
||||
_assert_writable(self.tblname)
|
||||
ctx = _ctx(params)
|
||||
row = dict(params)
|
||||
row[self.tenant_field] = ctx.tenant_id
|
||||
row.setdefault('create_time', _now())
|
||||
row.setdefault('update_time', _now())
|
||||
row.setdefault('create_user', ctx.user_id)
|
||||
if self.soft_delete:
|
||||
row.setdefault('is_deleted', 0)
|
||||
row.pop(self.pk, None)
|
||||
return self.db.C(self.tblname, row)
|
||||
|
||||
# ---- U:更新 -------------------------------------------------------
|
||||
def U(self, params, cond=None):
|
||||
"""按条件更新。cond 会被 scope() 包进租户作用域。"""
|
||||
_assert_writable(self.tblname)
|
||||
ctx = _ctx(params)
|
||||
row = dict(params)
|
||||
row.pop(self.tenant_field, None) # 租户列不可改
|
||||
row.pop(self.pk, None)
|
||||
row['update_time'] = _now()
|
||||
row['update_user'] = ctx.user_id
|
||||
where = ctx.scope(cond or {})
|
||||
if self.soft_delete and 'is_deleted' not in where:
|
||||
where['is_deleted'] = 0
|
||||
return self.db.U(self.tblname, row, where)
|
||||
|
||||
# ---- D:删除(默认软删)-------------------------------------------
|
||||
def D(self, params, cond=None):
|
||||
_assert_writable(self.tblname)
|
||||
if self.tblname in APPEND_ONLY_TABLES:
|
||||
raise PBLError(PBL_ERR['agent_tool']['write_protected'],
|
||||
'表 %s 为 append-only,禁止删除' % self.tblname)
|
||||
ctx = _ctx(params)
|
||||
where = ctx.scope(cond or {})
|
||||
if not self.soft_delete or self.tblname in HARD_DELETE_TABLES:
|
||||
return self.db.D(self.tblname, where)
|
||||
return self.db.U(self.tblname,
|
||||
{'is_deleted': 1, 'update_time': _now(),
|
||||
'update_user': ctx.user_id},
|
||||
where)
|
||||
|
||||
# ---- R:查询(单行/列表)------------------------------------------
|
||||
def R(self, params, cond=None, fields=None, order_by=None,
|
||||
limit=None, offset=0, one=False):
|
||||
"""租户作用域查询。one=True 返回单行 dict,否则返回 list。"""
|
||||
ctx = _ctx(params)
|
||||
where = ctx.scope(cond or {})
|
||||
if self.soft_delete and 'is_deleted' not in where:
|
||||
where['is_deleted'] = 0
|
||||
rows = self.db.R(self.tblname, where, fields=fields,
|
||||
order_by=order_by, limit=limit, offset=offset)
|
||||
rows = rows or []
|
||||
if one:
|
||||
return rows[0] if rows else None
|
||||
return rows
|
||||
|
||||
# ---- I:原生 SQL(只读统计/复杂联查)------------------------------
|
||||
def I(self, sql, args=None, params=None):
|
||||
"""
|
||||
执行原生 SELECT。调用方必须自行把 tenant_id 放进 WHERE 首条件;
|
||||
非 SELECT 语句一律拒绝(写操作请走 C/U/D)。
|
||||
"""
|
||||
s = (sql or '').strip().lower()
|
||||
if not s.startswith('select'):
|
||||
raise PBLError(PBL_ERR['agent_tool']['write_protected'],
|
||||
'I() 仅允许 SELECT,写操作请用 C/U/D')
|
||||
if params is not None:
|
||||
ctx = _ctx(params)
|
||||
if 'tenant_id' not in s:
|
||||
raise PBLError(PBL_ERR['tenant']['missing'],
|
||||
'原生 SQL 必须显式带 tenant_id 条件')
|
||||
args = tuple([ctx.tenant_id] + list(args or ()))
|
||||
return self.db.I(sql, args or ())
|
||||
|
||||
|
||||
def make_crud(tblname, module=None, **kw):
|
||||
"""工厂:make_crud('pbl_blueprint') -> Crud 实例(含 C/U/D/R/I)。"""
|
||||
return Crud(tblname, module=module, **kw)
|
||||
__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',
|
||||
]
|
||||
|
||||
@ -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/get),self_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):
|
||||
"""按主键取单行;不存在抛 NotFoundError(fail-closed,不返回 None 掩盖)。"""
|
||||
cond = dict(where or {})
|
||||
if pk_value is not None:
|
||||
cond[self.pk] = pk_value
|
||||
if not cond:
|
||||
raise ParamInvalidError(message='get: 必须提供主键或 where',
|
||||
detail={'table': self.table})
|
||||
row = await self.R(cond, tenant_id=tenant_id, one=True)
|
||||
if row is None:
|
||||
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 : 透传 CrudBase(pk/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',
|
||||
]
|
||||
|
||||
@ -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
|
||||
- 优先走平台 sqlor(sor.C/U/D/R/I/sqlExe);sqlor 不可用时回落 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):
|
||||
"""执行任意 SQL(DDL / 复杂查询)。params 支持 dict 或 list。"""
|
||||
sql_text = str(sql or '').strip()
|
||||
if not sql_text:
|
||||
raise ParamInvalidError(message='execute: SQL 为空')
|
||||
_reject_forbidden_sql(sql_text)
|
||||
args = _to_args(params)
|
||||
ctx = self._sqlor_context()
|
||||
if ctx is not None:
|
||||
self.channel = 'sqlor'
|
||||
async with ctx as sor:
|
||||
return await sor.sqlExe(sql_text, args)
|
||||
head = sql_text.split(None, 1)[0].lower()
|
||||
if head == 'select' or sql_text.lower().startswith('show'):
|
||||
return self._query_sync(sql_text, args)
|
||||
return self._execute_sync(sql_text, args)
|
||||
|
||||
# 兼容别名:旧代码调 db.sqlExe(...)(大写 E)
|
||||
async def sqlExe(self, sql, params=None): # noqa: N802 - 对齐 sqlor 命名
|
||||
return await self.execute(sql, params)
|
||||
|
||||
# ---- 事务 ------------------------------------------------------------
|
||||
async def transaction(self):
|
||||
return _Transaction(self)
|
||||
|
||||
# ---- 同步底层(仅回落通道使用) --------------------------------------
|
||||
def _query_sync(self, sql, args):
|
||||
conn = self._pymysql_conn or self._pymysql_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, args or [])
|
||||
cols = [d[0] for d in (cur.description or [])]
|
||||
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
|
||||
def _execute_sync(self, sql, args):
|
||||
conn = self._pymysql_conn or self._pymysql_connect()
|
||||
with conn.cursor() as cur:
|
||||
affected = cur.execute(sql, args or [])
|
||||
conn.commit()
|
||||
return affected
|
||||
|
||||
def close(self):
|
||||
if self._pymysql_conn is not None:
|
||||
try:
|
||||
self._pymysql_conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._pymysql_conn = None
|
||||
|
||||
|
||||
class _Transaction(object):
|
||||
"""最小事务上下文:sqlor 通道下退化为顺序执行(sqlor 自带连接管理)。"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self._conn = None
|
||||
|
||||
async def __aenter__(self):
|
||||
if self.db.channel == 'pymysql' or self.db._pymysql_conn is not None:
|
||||
self._conn = self.db._pymysql_conn or self.db._pymysql_connect()
|
||||
self._conn.begin()
|
||||
return self.db
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
if self._conn is not None:
|
||||
if exc_type is None:
|
||||
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():
|
||||
"""取当前上下文租户;无上下文时抛 TenantMissingError(fail-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.execute(sqlor 主路径)。"""
|
||||
return await get_db(module=module, dbname=dbname).execute(sql, params)
|
||||
|
||||
|
||||
async def query(sql, params=None, module=None, dbname=None):
|
||||
return await get_db(module=module, dbname=dbname).execute(sql, params)
|
||||
|
||||
|
||||
async def fetch_one(table, where=None, tenant_id=None, module=None, dbname=None):
|
||||
rows = await get_db(module=module, dbname=dbname).read(
|
||||
table, where, tenant_id=tenant_id)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
async def fetch_all(table, where=None, tenant_id=None, module=None, dbname=None):
|
||||
return await get_db(module=module, dbname=dbname).read(
|
||||
table, where, tenant_id=tenant_id)
|
||||
|
||||
|
||||
async def insert_row(table, row, tenant_id=None, module=None, dbname=None):
|
||||
return await get_db(module=module, dbname=dbname).create(table, row, tenant_id)
|
||||
|
||||
|
||||
async def update_row(table, row, where=None, tenant_id=None, module=None, dbname=None):
|
||||
return await get_db(module=module, dbname=dbname).update(table, row, where, tenant_id)
|
||||
|
||||
|
||||
async def delete_row(table, where=None, tenant_id=None, module=None, dbname=None):
|
||||
return await get_db(module=module, dbname=dbname).delete(table, where, tenant_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'Db', 'get_db', 'get_module_dbname', 'get_server_env',
|
||||
'sqlor_available', 'fallback_enabled',
|
||||
'new_id', 'now_str', 'cur_date_string', 'today_str',
|
||||
'safe_ident', 'quote_ident',
|
||||
'sqlExe', 'query', 'fetch_one', 'fetch_all',
|
||||
'insert_row', 'update_row', 'delete_row',
|
||||
# 兼容再导出
|
||||
'DbError', 'DbUnavailableError', 'ParamInvalidError', 'AppendOnlyError',
|
||||
'assert_not_write_protected', 'assert_append_only',
|
||||
'assert_tenant', 'tenant_scope', 'normalize_tenant',
|
||||
'PBL_E_DB', 'PBL_E_DB_UNAVAILABLE', 'PBL_E_APPEND_ONLY',
|
||||
]
|
||||
|
||||
@ -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-closed,22 工具 = 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 工具裁决:deny(fail-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。未登记的键直接抛 KeyError(fail-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. 构造helper:err() / fail()
|
||||
# ==========================================================================
|
||||
def err(code=None, message=None, detail=None, **extra):
|
||||
"""构造(不抛出)一个 PBLError 实例。
|
||||
|
||||
用法::
|
||||
|
||||
raise err(PBL_E_PARAM, 'name 不能为空', field='name')
|
||||
return err('PBL_E_NOT_FOUND', '蓝图不存在').to_dict()
|
||||
"""
|
||||
if isinstance(code, PBLError):
|
||||
return code
|
||||
return PBLError(code=code, message=message, detail=detail, **extra)
|
||||
|
||||
|
||||
def fail(code=None, message=None, detail=None, **extra):
|
||||
"""构造并**抛出** PBLError(fail-closed 语义的语法糖)。"""
|
||||
raise err(code, message, detail, **extra)
|
||||
|
||||
|
||||
def as_error(exc):
|
||||
"""把任意异常归一为 PBLError(非 PBLError 包装成 PBL_E_INTERNAL)。"""
|
||||
if isinstance(exc, PBLError):
|
||||
return exc
|
||||
return PBLError(code=PBL_E_INTERNAL, message=str(exc) or exc.__class__.__name__,
|
||||
detail={'origin': exc.__class__.__name__})
|
||||
|
||||
|
||||
def error_body(code=None, message=None, detail=None, **extra):
|
||||
"""返回统一错误响应体 dict(供 api 层直接 return)。"""
|
||||
return err(code, message, detail, **extra).to_dict()
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 5. 引用模块写保护(fail-closed 门禁)
|
||||
# ==========================================================================
|
||||
# 设计约定:rbac/world/scene/entity/scense/scense_runtime/script_engine 为引用模块,
|
||||
# 严格写保护;一切扩展走 pbl_domain_ext / pbl_scense_ext / pbl_runtime_ext /
|
||||
# pbl_kdb_ext 四个薄扩展模块(见 docs/01-design/architecture.md §写保护)。
|
||||
WRITE_PROTECTED_MODULES = frozenset({
|
||||
'rbac',
|
||||
'world',
|
||||
'scene',
|
||||
'entity',
|
||||
'scense',
|
||||
'scense_runtime',
|
||||
'script_engine',
|
||||
})
|
||||
|
||||
# 别名归一(历史代码里出现过带前缀/带连字符的写法)
|
||||
_MODULE_ALIAS = {
|
||||
'pbl_rbac': 'rbac',
|
||||
'world_snapshot': 'world',
|
||||
'world_sync': 'world',
|
||||
'scense_game': 'scense',
|
||||
'scense_demo': 'scense',
|
||||
'scense_drag': 'scense',
|
||||
'scense-runtime': 'scense_runtime',
|
||||
'script-engine': 'script_engine',
|
||||
}
|
||||
|
||||
WRITE_ACTIONS = frozenset({
|
||||
'create', 'insert', 'update', 'delete', 'upsert', 'write', 'save',
|
||||
'remove', 'drop', 'truncate', 'alter', 'patch', 'put', 'post',
|
||||
'c', 'u', 'd', 'i',
|
||||
})
|
||||
|
||||
READ_ACTIONS = frozenset({'read', 'get', 'list', 'query', 'select', 'search', 'r'})
|
||||
|
||||
|
||||
def normalize_module(module):
|
||||
if module is None:
|
||||
return ''
|
||||
text = str(module).strip().lower()
|
||||
if '/' in text:
|
||||
text = text.rstrip('/').split('/')[-1]
|
||||
if text.endswith('.py'):
|
||||
text = text[:-3]
|
||||
return _MODULE_ALIAS.get(text, text)
|
||||
|
||||
|
||||
def is_write_protected(module, action='write'):
|
||||
"""判断 (module, action) 是否命中写保护。未知 action 按写处理(fail-closed)。"""
|
||||
mod = normalize_module(module)
|
||||
if mod not in WRITE_PROTECTED_MODULES:
|
||||
return False
|
||||
act = str(action or 'write').strip().lower()
|
||||
if act in READ_ACTIONS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assert_not_write_protected(module, action='write', detail=None):
|
||||
"""写保护断言:命中则抛 WriteProtectedError(PBL_E_WRITE_PROTECTED / HTTP 403)。
|
||||
|
||||
所有 CRUD 工厂、审计写入、Agent 工具裁决在执行写操作前**必须**先过本门禁。
|
||||
"""
|
||||
if is_write_protected(module, action):
|
||||
raise WriteProtectedError(
|
||||
message='%s:模块 %s 的 %s 操作被写保护拒绝' % (
|
||||
DEFAULT_MESSAGES[PBL_E_WRITE_PROTECTED], normalize_module(module), action),
|
||||
detail=dict(detail or {}, module=normalize_module(module), action=str(action),
|
||||
protected=sorted(WRITE_PROTECTED_MODULES)),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def assert_append_only(op, table='pbl_audit_log'):
|
||||
"""审计表 append-only 断言:UPDATE/DELETE 一律拒绝(PBL_E_APPEND_ONLY / HTTP 405)。"""
|
||||
act = str(op or '').strip().lower()
|
||||
if act in ('update', 'delete', 'u', 'd', 'upsert', 'truncate', 'drop', 'alter'):
|
||||
raise AppendOnlyError(
|
||||
message='%s:table=%s op=%s' % (DEFAULT_MESSAGES[PBL_E_APPEND_ONLY], table, act),
|
||||
detail={'table': table, 'op': act},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
# 规范错误码常量
|
||||
'PBL_E_OK', 'PBL_E_PARAM', 'PBL_E_TENANT', 'PBL_E_TENANT_MISSING',
|
||||
'PBL_E_TENANT_INVALID', 'PBL_E_NOT_FOUND', 'PBL_E_CONFLICT', 'PBL_E_FORBIDDEN',
|
||||
'PBL_E_WRITE_PROTECTED', 'PBL_E_WRITE_LOCK', 'PBL_E_APPEND_ONLY', 'PBL_E_DB',
|
||||
'PBL_E_DB_UNAVAILABLE', 'PBL_E_VALIDATION', 'PBL_E_COMPILE', 'PBL_E_TOOL_DENIED',
|
||||
'PBL_E_NEED_INFO', 'PBL_E_INTERNAL',
|
||||
'ALL_CODES', 'LEGACY_CODE_MAP', 'CODE_TO_HTTP', 'DEFAULT_MESSAGES',
|
||||
'ErrorCode', 'PBL_ERR',
|
||||
'normalize_code', 'http_status_of', 'default_message',
|
||||
# 规范异常
|
||||
'PBLError', 'ParamInvalidError', 'TenantError', 'TenantMissingError',
|
||||
'TenantInvalidError', 'NotFoundError', 'ConflictError', 'ForbiddenError',
|
||||
'WriteProtectedError', 'WriteLockError', 'AppendOnlyError', 'DbError',
|
||||
'DbUnavailableError', 'ValidationError', 'CompileError', 'ToolDeniedError',
|
||||
'NeedInfoError',
|
||||
# 兼容别名
|
||||
'PblError', 'PblValidationError', 'PblNotFound', 'PblConflict', 'PblForbidden',
|
||||
'PblParamInvalid', 'PblTenantMissing', 'PblWriteProtected', 'PblAppendOnly',
|
||||
'PblDbUnavailable', 'COMPAT_ALIASES',
|
||||
# helper
|
||||
'err', 'fail', 'as_error', 'error_body',
|
||||
# 写保护
|
||||
'WRITE_PROTECTED_MODULES', 'WRITE_ACTIONS', 'READ_ACTIONS',
|
||||
'normalize_module', 'is_write_protected', 'assert_not_write_protected',
|
||||
'assert_append_only',
|
||||
]
|
||||
|
||||
@ -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('未探测到 ServerEnv(ahserver/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 —— 宿主 ServerEnv;None 时自动探测
|
||||
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',
|
||||
]
|
||||
|
||||
@ -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) 方言 mariadb,DDL 生成器不含禁用 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-closed),with_tenant 绑定上下文')
|
||||
def _check_assert_tenant():
|
||||
from pbl_common.context import (assert_tenant, with_tenant, get_tenant,
|
||||
reset_tenant, has_tenant, TenantMissingError)
|
||||
reset_tenant()
|
||||
assert has_tenant() is False
|
||||
try:
|
||||
assert_tenant()
|
||||
except TenantMissingError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('无上下文时 assert_tenant 未抛')
|
||||
with with_tenant('t_020') as ctx:
|
||||
assert ctx.tenant_id == 't_020'
|
||||
assert get_tenant() == 't_020'
|
||||
assert assert_tenant() == 't_020'
|
||||
assert has_tenant() is False, 'with 退出后上下文未复位'
|
||||
return True
|
||||
|
||||
|
||||
@check('context: check_tenant_column 校验 tenant_id 打头(data-model 硬约束)')
|
||||
def _check_tenant_column():
|
||||
from pbl_common.context import check_tenant_column
|
||||
from pbl_common.errors import PBLError
|
||||
ok, first = check_tenant_column('pbl_blueprint',
|
||||
[{'name': 'tenant_id'}, {'name': 'id'}])
|
||||
assert ok is True and first == 'tenant_id'
|
||||
ok2, first2 = check_tenant_column('bad_table', ['id', 'tenant_id'], required=False)
|
||||
assert ok2 is False and first2 == 'id'
|
||||
try:
|
||||
check_tenant_column('bad_table', ['id', 'tenant_id'])
|
||||
except PBLError as exc:
|
||||
assert exc.code == 'PBL_E_TENANT'
|
||||
else:
|
||||
raise AssertionError('未打头表未被拒绝')
|
||||
return True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 3. dbutil.py —— sqlor 主路径(QC #6)
|
||||
# ==========================================================================
|
||||
@check('dbutil: 主路径走 sqlor(sor.C/U/D/R/I/sqlExe),PyMySQL 仅显式回落')
|
||||
def _check_sqlor_primary():
|
||||
import re
|
||||
import pbl_common.dbutil as dbutil
|
||||
src = inspect.getsource(dbutil)
|
||||
# 必须真实出现 sqlor 调用(而非只在文档里声称)
|
||||
for token in ('sor.C(', 'sor.U(', 'sor.D(', 'sor.I(', 'sor.sqlExe('):
|
||||
assert token in src, 'dbutil 未调用 sqlor API: %s' % token
|
||||
assert 'sqlorContext' in src, 'dbutil 未经 DBPools().sqlorContext 取通道'
|
||||
# 回落必须显式开关,且不得静默
|
||||
assert 'PBL_DB_FALLBACK' in src, 'PyMySQL 回落缺少显式开关'
|
||||
assert 'fallback_enabled' in src
|
||||
assert '[FALLBACK]' in src, '回落路径缺少告警日志'
|
||||
# 禁编造 sqlor API
|
||||
for fake in ('sor.save(', 'sor.list(', 'sor.insert(', 'sor.query(', 'sor.one('):
|
||||
assert fake not in src, 'dbutil 编造了不存在的 sqlor API: %s' % fake
|
||||
return True
|
||||
|
||||
|
||||
@check('dbutil: 库名不硬编码,统一 get_module_dbname')
|
||||
def _check_no_hardcoded_dbname():
|
||||
import re
|
||||
import pbl_common.dbutil as dbutil
|
||||
src = inspect.getsource(dbutil)
|
||||
bad = re.findall(r"^\s*DBNAME\s*=\s*['\"]", src, re.M)
|
||||
assert not bad, 'dbutil 存在硬编码 DBNAME'
|
||||
assert 'get_module_dbname' in src
|
||||
assert dbutil.get_module_dbname('pbl_blueprint') != ''
|
||||
return True
|
||||
|
||||
|
||||
@check('dbutil: new_id / now_str 自包含实现(不 import pbl_blueprint.m1b)')
|
||||
def _check_id_time_helpers():
|
||||
import pbl_common.dbutil as dbutil
|
||||
src = inspect.getsource(dbutil)
|
||||
assert 'pbl_blueprint' not in src, 'dbutil 不得反向依赖 pbl_blueprint(循环依赖根因)'
|
||||
assert 'except ImportError' not in src, 'dbutil 不得用 except ImportError 静默吞错'
|
||||
ids = {dbutil.new_id() for _ in range(200)}
|
||||
assert len(ids) == 200, 'new_id 出现重复'
|
||||
assert all(len(i) <= 32 for i in ids), 'new_id 超过 VARCHAR(32)'
|
||||
pre = dbutil.new_id('bp')
|
||||
assert pre.startswith('bp_') and len(pre) <= 32
|
||||
stamp = dbutil.now_str()
|
||||
assert len(stamp) == 19 and stamp[4] == '-' and stamp[10] == ' ', stamp
|
||||
return True
|
||||
|
||||
|
||||
@check('dbutil: 破坏性 SQL 被拒绝、标识符白名单生效')
|
||||
def _check_sql_guard():
|
||||
import pbl_common.dbutil as dbutil
|
||||
from pbl_common.errors import ParamInvalidError
|
||||
for sql in ('DROP DATABASE pbls', 'truncate table pbl_audit_log'):
|
||||
try:
|
||||
dbutil._reject_forbidden_sql(sql)
|
||||
except ParamInvalidError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('破坏性 SQL 未被拒绝: %s' % sql)
|
||||
assert dbutil._reject_forbidden_sql('SELECT * FROM pbl_blueprint') is True
|
||||
assert dbutil.safe_ident('pbl_blueprint') == 'pbl_blueprint'
|
||||
try:
|
||||
dbutil.safe_ident('a; DROP TABLE x')
|
||||
except ParamInvalidError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('非法标识符未被拒绝')
|
||||
return True
|
||||
|
||||
|
||||
@check('dbutil: Db 五方法齐备且写路径过写保护门禁')
|
||||
def _check_db_facade():
|
||||
from pbl_common.dbutil import Db
|
||||
for name in ('create', 'read', 'update', 'delete', 'execute', 'insert', 'sqlExe'):
|
||||
assert callable(getattr(Db, name, None)), 'Db 缺少方法 %s' % name
|
||||
src = inspect.getsource(Db)
|
||||
assert '_guard' in src and 'assert_not_write_protected' in src
|
||||
assert 'assert_append_only' in src
|
||||
return True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. crud_factory.py —— C/U/D/R/I 与语义名双套(QC #4)
|
||||
# ==========================================================================
|
||||
@check('crud_factory: CrudBase 同时提供 C/U/D/R/I 与 create/read/update/delete/list/get')
|
||||
def _check_crud_methods():
|
||||
from pbl_common.crud_factory import CrudBase, tenant_crud, crud_factory
|
||||
required = ('C', 'U', 'D', 'R', 'I', 'create', 'read', 'update', 'delete',
|
||||
'list', 'get', 'upsert', 'exists', 'count', 'get_or_none')
|
||||
for name in required:
|
||||
fn = getattr(CrudBase, name, None)
|
||||
assert callable(fn), 'CrudBase 缺少方法 %s' % name
|
||||
assert inspect.iscoroutinefunction(fn), '%s 必须是 async' % name
|
||||
crud = tenant_crud('pbl_blueprint', module='pbl_blueprint')
|
||||
assert crud.table == 'pbl_blueprint' and crud.pk == 'id'
|
||||
assert tenant_crud('pbl_blueprint', module='pbl_blueprint') is crud, '工厂未缓存'
|
||||
assert crud_factory('pbl_evidence', module='pbl_evidence').table == 'pbl_evidence'
|
||||
return True
|
||||
|
||||
|
||||
@check('crud_factory: tenant_crud 为包内自包含实现(无反向 re-export / 无静默吞错)')
|
||||
def _check_crud_self_contained():
|
||||
import importlib
|
||||
cf = importlib.import_module('pbl_common.crud_factory')
|
||||
src = inspect.getsource(cf)
|
||||
assert 'pbl_blueprint' not in src, 'crud_factory 不得反向依赖 pbl_blueprint'
|
||||
assert 'except ImportError' not in src, 'crud_factory 不得静默吞 ImportError'
|
||||
assert 'def tenant_crud(' in src
|
||||
return True
|
||||
|
||||
|
||||
@check('crud_factory: 写路径先过写保护 + append-only(引用模块拒绝写)')
|
||||
def _check_crud_guards():
|
||||
import asyncio
|
||||
from pbl_common.crud_factory import CrudBase
|
||||
from pbl_common.errors import WriteProtectedError, AppendOnlyError
|
||||
protected = CrudBase(table='rbac_permission', module='rbac')
|
||||
for coro in (protected.C({'name': 'x'}, tenant_id='t_1'),
|
||||
protected.U({'name': 'y'}, where={'id': '1'}, tenant_id='t_1'),
|
||||
protected.D({'id': '1'}, tenant_id='t_1')):
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(coro)
|
||||
except WriteProtectedError as exc:
|
||||
assert exc.code == 'PBL_E_WRITE_PROTECTED'
|
||||
except RuntimeError:
|
||||
# 无事件循环时用新循环重试
|
||||
try:
|
||||
asyncio.new_event_loop().run_until_complete(coro)
|
||||
except WriteProtectedError as exc:
|
||||
assert exc.code == 'PBL_E_WRITE_PROTECTED'
|
||||
else:
|
||||
raise AssertionError('引用模块写操作未被拦截')
|
||||
else:
|
||||
raise AssertionError('引用模块写操作未被拦截')
|
||||
audit = CrudBase(table='pbl_audit_log', module='pbl_common')
|
||||
try:
|
||||
asyncio.new_event_loop().run_until_complete(
|
||||
audit.U({'detail': 'x'}, where={'id': '1'}, tenant_id='t_1'))
|
||||
except AppendOnlyError as exc:
|
||||
assert exc.code == 'PBL_E_APPEND_ONLY'
|
||||
else:
|
||||
raise AssertionError('审计表 UPDATE 未被 append-only 拦截')
|
||||
return True
|
||||
|
||||
|
||||
@check('crud_factory: delete 无 where 被拒(禁止全表删除)')
|
||||
def _check_crud_delete_guard():
|
||||
import asyncio
|
||||
from pbl_common.crud_factory import CrudBase
|
||||
from pbl_common.errors import ParamInvalidError
|
||||
crud = CrudBase(table='pbl_blueprint', module='pbl_blueprint')
|
||||
try:
|
||||
asyncio.new_event_loop().run_until_complete(crud.D(None, tenant_id='t_1'))
|
||||
except ParamInvalidError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('无 where 的 delete 未被拒绝')
|
||||
return True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 5. audit.py —— write_audit 契约(QC #4 / 人工介入 ②)
|
||||
# ==========================================================================
|
||||
@check('audit: 交付接口为 write_audit(append 为兼容别名),签名含 action/table/row_id/detail/ctx/db_conn')
|
||||
def _check_audit_signature():
|
||||
from pbl_common import audit
|
||||
assert callable(audit.write_audit), 'audit 缺少 write_audit'
|
||||
assert inspect.iscoroutinefunction(audit.write_audit)
|
||||
params = list(inspect.signature(audit.write_audit).parameters)
|
||||
for name in ('action', 'table', 'row_id', 'detail', 'ctx', 'db_conn'):
|
||||
assert name in params, 'write_audit 签名缺少 %s(与 kernel 历史实现不一致)' % name
|
||||
assert audit.append is audit.write_audit, 'append 兼容别名缺失'
|
||||
for name in ('write_audit_batch', 'audit_trail', 'flush_memory_audit',
|
||||
'build_audit_record', 'write_audit_sync'):
|
||||
assert callable(getattr(audit, name, None)), 'audit 缺少 %s' % name
|
||||
return True
|
||||
|
||||
|
||||
@check('audit: 自包含实现,不 import pbl_blueprint.m1b,无静默 except ImportError')
|
||||
def _check_audit_self_contained():
|
||||
import pbl_common.audit as audit
|
||||
src = inspect.getsource(audit)
|
||||
assert 'pbl_blueprint' not in src, 'audit 不得反向依赖 pbl_blueprint(循环依赖根因)'
|
||||
assert 'm1b' not in src, 'audit 不得引用 m1b 兼容层'
|
||||
assert 'except ImportError' not in src, 'audit 不得静默吞 ImportError'
|
||||
assert 'class write_audit' not in src, 'audit 不得把 write_audit 写成类(占位坏味)'
|
||||
return True
|
||||
|
||||
|
||||
@check('audit: build_audit_record tenant_id 打头且缺租户即抛')
|
||||
def _check_audit_record():
|
||||
from pbl_common.audit import build_audit_record
|
||||
from pbl_common.errors import ParamInvalidError
|
||||
rec = build_audit_record('create', table='pbl_blueprint', row_id='bp_1',
|
||||
detail={'name': 'x'}, tenant_id='t_030')
|
||||
assert list(rec.keys())[0] == 'tenant_id', '审计记录首键必须是 tenant_id'
|
||||
assert rec['tenant_id'] == 't_030'
|
||||
assert rec['action'] == 'create'
|
||||
assert rec['table_name'] == 'pbl_blueprint'
|
||||
assert len(rec['id']) <= 32
|
||||
assert build_audit_record('c', tenant_id='t_030')['action'] == 'create'
|
||||
assert build_audit_record('U', tenant_id='t_030')['action'] == 'update'
|
||||
assert build_audit_record('weird_action', tenant_id='t_030')['action'] == 'other'
|
||||
try:
|
||||
build_audit_record('create', table='pbl_blueprint')
|
||||
except ParamInvalidError as exc:
|
||||
assert exc.code == 'PBL_E_TENANT_MISSING'
|
||||
else:
|
||||
raise AssertionError('缺租户的审计写入未被拒绝')
|
||||
return True
|
||||
|
||||
|
||||
@check('audit: DB 不可用时进内存缓冲而非丢弃,flush 可补写')
|
||||
def _check_audit_buffer():
|
||||
import asyncio
|
||||
from pbl_common import audit
|
||||
audit.clear_memory_audit()
|
||||
|
||||
class _BrokenDb(object):
|
||||
async def create(self, *a, **kw):
|
||||
raise RuntimeError('db down')
|
||||
|
||||
async def read(self, *a, **kw):
|
||||
raise RuntimeError('db down')
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
res = loop.run_until_complete(
|
||||
audit.write_audit('create', table='pbl_blueprint', row_id='bp_2',
|
||||
tenant_id='t_031', db_conn=_BrokenDb()))
|
||||
assert res.get('buffered') is True, 'DB 失败未进缓冲'
|
||||
stats = audit.audit_stats()
|
||||
assert stats['buffered_now'] >= 1
|
||||
assert stats['failed'] >= 1
|
||||
flushed = loop.run_until_complete(audit.flush_memory_audit(db_conn=_BrokenDb()))
|
||||
assert flushed['remaining'] >= 1, 'flush 失败后未回灌缓冲(审计丢失)'
|
||||
audit.clear_memory_audit()
|
||||
loop.close()
|
||||
return True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 6. api.py —— 契约符号全集重导出(人工介入 ③(a))
|
||||
# ==========================================================================
|
||||
@check('api: 契约符号全集可见,verify_contract() 返回空缺失清单')
|
||||
def _check_api_contract():
|
||||
from pbl_common import api
|
||||
missing = api.verify_contract()
|
||||
assert not missing, 'api 契约符号缺失: %s' % ', '.join(missing)
|
||||
# 下游常用 import 路径逐个命中
|
||||
from pbl_common.api import (PBLError, PblError, err, fail, ErrorCode, CODE_TO_HTTP,
|
||||
TenantMissingError, NotFoundError, DbError,
|
||||
ParamInvalidError, assert_not_write_protected,
|
||||
WRITE_PROTECTED_MODULES, TenantContext, with_tenant,
|
||||
tenant_scope, assert_tenant, normalize_tenant,
|
||||
check_tenant_column, get_tenant, set_tenant,
|
||||
Db, get_db, get_module_dbname, new_id, now_str,
|
||||
sqlExe, CrudBase, tenant_crud, crud_factory,
|
||||
write_audit, write_audit_batch, audit_trail,
|
||||
flush_memory_audit, ok, fail_body, paged, api_guard)
|
||||
assert api.PBLError is PBLError and api.PblError is PblError
|
||||
return True
|
||||
|
||||
|
||||
@check('api: 响应体 helper 结构统一(ok/fail_body/paged/from_exception)')
|
||||
def _check_api_response():
|
||||
from pbl_common.api import ok, fail_body, paged, from_exception
|
||||
from pbl_common.errors import NotFoundError, PBLError
|
||||
body = ok({'id': 'x'})
|
||||
assert body['ok'] is True and body['code'] == 'PBL_E_OK' and body['http_status'] == 200
|
||||
bad = fail_body('PBL_E_NOT_FOUND', '蓝图不存在')
|
||||
assert bad['ok'] is False and bad['code'] == 'PBL_E_NOT_FOUND'
|
||||
assert bad['http_status'] == 404 and bad['data'] is None
|
||||
pg = paged([{'id': 1}, {'id': 2}], total=9, page=2)
|
||||
assert pg['data']['total'] == 9 and len(pg['data']['rows']) == 2
|
||||
exc_body = from_exception(NotFoundError('没有'))
|
||||
assert exc_body['code'] == 'PBL_E_NOT_FOUND'
|
||||
assert from_exception(ValueError('boom'))['code'] == 'PBL_E_INTERNAL'
|
||||
return True
|
||||
|
||||
|
||||
@check('api: 无循环依赖——errors 不 import 任何 pbl_common 内部模块')
|
||||
def _check_no_cycle():
|
||||
import pbl_common.errors as errors
|
||||
src = inspect.getsource(errors)
|
||||
for mod in ('pbl_common.context', 'pbl_common.dbutil', 'pbl_common.crud_factory',
|
||||
'pbl_common.audit', 'pbl_common.api', 'pbl_common.kernel'):
|
||||
assert mod not in src, 'errors.py 反向依赖 %s → 循环依赖' % mod
|
||||
return True
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 运行器
|
||||
# ==========================================================================
|
||||
def run_self_check(verbose=True):
|
||||
"""执行全部自检,返回 (passed, total, failures)。"""
|
||||
passed, failures = 0, []
|
||||
for name, fn in RESULTS:
|
||||
try:
|
||||
result = fn()
|
||||
if result is False:
|
||||
raise AssertionError('check returned False')
|
||||
passed += 1
|
||||
if verbose:
|
||||
print(' [PASS] %s' % name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures.append((name, '%s: %s' % (type(exc).__name__, exc),
|
||||
traceback.format_exc()))
|
||||
if verbose:
|
||||
print(' [FAIL] %s -> %s: %s' % (name, type(exc).__name__, exc))
|
||||
total = len(RESULTS)
|
||||
if verbose:
|
||||
status = 'PASS' if not failures else 'FAIL'
|
||||
print('SELF_CHECK pbl_common: %s %d/%d' % (status, passed, total))
|
||||
for name, msg, _tb in failures:
|
||||
print(' - %s: %s' % (name, msg))
|
||||
return passed, total, failures
|
||||
|
||||
|
||||
def main():
|
||||
passed, total, failures = run_self_check(verbose=True)
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
sys.exit(main())
|
||||
|
||||
|
||||
__all__ = ['RESULTS', 'check', 'run_self_check', 'main']
|
||||
|
||||
@ -1,139 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pbl_common.tenant —— 租户上下文(tenant_id 强制打头,缺失即 fail-closed)
|
||||
"""pbl_common.tenant —— 兼容 shim(QC 退回意见 #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。
|
||||
缺失即抛 PBLTenantError(fail-closed),绝不回落默认租户。
|
||||
"""
|
||||
tid = None
|
||||
if isinstance(params, dict):
|
||||
for k in ('tenant_id', 'tenantId', 'tenant'):
|
||||
if params.get(k) not in (None, ''):
|
||||
tid = params.get(k)
|
||||
break
|
||||
if tid in (None, ''):
|
||||
tid = getattr(_local, 'tenant_id', None)
|
||||
if tid in (None, ''):
|
||||
raise PBLTenantError()
|
||||
return TenantContext(
|
||||
tenant_id=tid,
|
||||
user_id=(params.get('user_id') if isinstance(params, dict) else 0) or getattr(_local, 'user_id', 0),
|
||||
role=(params.get('role') if isinstance(params, dict) else '') or getattr(_local, 'role', ''),
|
||||
session_id=(params.get('session_id') if isinstance(params, dict) else 0) or getattr(_local, 'session_id', 0),
|
||||
trace_id=(params.get('trace_id') if isinstance(params, dict) else '') or getattr(_local, 'trace_id', ''),
|
||||
)
|
||||
|
||||
|
||||
def set_current(tenant_id, user_id=0, role='', session_id=0, trace_id=''):
|
||||
"""线程级当前租户(供 dspy/api 入口统一设置)。"""
|
||||
ctx = TenantContext(tenant_id, user_id=user_id, role=role,
|
||||
session_id=session_id, trace_id=trace_id)
|
||||
_local.tenant_id = ctx.tenant_id
|
||||
_local.user_id = ctx.user_id
|
||||
_local.role = ctx.role
|
||||
_local.session_id = ctx.session_id
|
||||
_local.trace_id = ctx.trace_id
|
||||
_local.ctx = ctx
|
||||
return ctx
|
||||
|
||||
|
||||
def get_current(strict=True):
|
||||
"""取线程级当前租户上下文;strict=True 时缺失即抛。"""
|
||||
ctx = getattr(_local, 'ctx', None)
|
||||
if ctx is not None:
|
||||
return ctx
|
||||
if strict:
|
||||
raise PBLTenantError()
|
||||
return None
|
||||
|
||||
|
||||
def clear_current():
|
||||
for k in ('tenant_id', 'user_id', 'role', 'session_id', 'trace_id', 'ctx'):
|
||||
if hasattr(_local, k):
|
||||
delattr(_local, k)
|
||||
__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',
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user