From 82e5e1d9f2fe50375ba146897556100fda8600b8 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Thu, 17 Sep 2026 23:15:58 +0800 Subject: [PATCH] =?UTF-8?q?approve:=20[M1b]=20pbl=5Fblueprint=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF/=E5=AD=90=E5=AF=B9=E8=B1=A1=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E4=B8=8E=E5=85=B3=E8=81=94=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pbl_common/__init__.py | 439 ++++++++++++++++------- pbl_common/api.py | 540 +++++++++++++++------------- pbl_common/audit.py | 358 ++++++++++++++---- pbl_common/context.py | 476 ++++++++++++++++++------ pbl_common/crud.py | 202 +++-------- pbl_common/crud_factory.py | 518 ++++++++++++++++---------- pbl_common/dbutil.py | 718 ++++++++++++++++++++++++++----------- pbl_common/errors.py | 652 +++++++++++++++++++++++++-------- pbl_common/init.py | 343 +++++++++--------- pbl_common/self_check.py | 651 ++++++++++++++++++++++++++------- pbl_common/tenant.py | 202 ++++------- 11 files changed, 3395 insertions(+), 1704 deletions(-) diff --git a/pbl_common/__init__.py b/pbl_common/__init__.py index dc5a824..e5f249e 100644 --- a/pbl_common/__init__.py +++ b/pbl_common/__init__.py @@ -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', +] diff --git a/pbl_common/api.py b/pbl_common/api.py index 096f8d1..3f7c842 100644 --- a/pbl_common/api.py +++ b/pbl_common/api.py @@ -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', ] diff --git a/pbl_common/audit.py b/pbl_common/audit.py index d2a823a..cac9913 100644 --- a/pbl_common/audit.py +++ b/pbl_common/audit.py @@ -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', +] diff --git a/pbl_common/context.py b/pbl_common/context.py index 445503b..b7a33b5 100644 --- a/pbl_common/context.py +++ b/pbl_common/context.py @@ -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', +] diff --git a/pbl_common/crud.py b/pbl_common/crud.py index b25621c..881ce78 100644 --- a/pbl_common/crud.py +++ b/pbl_common/crud.py @@ -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', +] diff --git a/pbl_common/crud_factory.py b/pbl_common/crud_factory.py index 03a5a3b..fa5bdf0 100644 --- a/pbl_common/crud_factory.py +++ b/pbl_common/crud_factory.py @@ -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', +] diff --git a/pbl_common/dbutil.py b/pbl_common/dbutil.py index ccdfc07..5f792a4 100644 --- a/pbl_common/dbutil.py +++ b/pbl_common/dbutil.py @@ -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', +] diff --git a/pbl_common/errors.py b/pbl_common/errors.py index 5c5951a..683684c 100644 --- a/pbl_common/errors.py +++ b/pbl_common/errors.py @@ -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 '' % (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', +] diff --git a/pbl_common/init.py b/pbl_common/init.py index 7c4d55e..4e04372 100644 --- a/pbl_common/init.py +++ b/pbl_common/init.py @@ -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', +] diff --git a/pbl_common/self_check.py b/pbl_common/self_check.py index 65c17f0..2f102ee 100644 --- a/pbl_common/self_check.py +++ b/pbl_common/self_check.py @@ -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'] diff --git a/pbl_common/tenant.py b/pbl_common/tenant.py index 33c8810..c998029 100644 --- a/pbl_common/tenant.py +++ b/pbl_common/tenant.py @@ -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', +]