deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-16 15:54:45 +08:00
parent 9afe370176
commit ed19f79dae
2 changed files with 511 additions and 607 deletions

View File

@ -1,93 +1,160 @@
# -*- coding: utf-8 -*-
"""
pbl_common PBL 公共内核租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂
"""pbl_common —— PBL Agent OS 公共内核包M0
所有 pbl_* 模块的依赖底座必须在 app/pbls.py init() 中最先 load
铁律
- 所有读写 tenant_id 强制打头缺失即抛 PBL-TENANT-0001fail-closed
- 取库名一律 ServerEnv().get_module_dbname('模块名')禁止硬编码 DBNAME
- DB 方言 mariadbBIGINT AUTO_INCREMENT FK / ENUM / TIMESTAMP
导出PblError / ERR / TenantContext / require_tenant / get_dbname /
audit_write / crud_factory / WRITE_PROTECTED_TABLES / is_valid_identifier
以及 _self_test_fail_closed()六项 fail-closed 断言可独立运行
自测运行方式
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
"""
from pbl_common.errors import (
from .kernel import ( # noqa: F401
PblError,
ErrorCode,
ERR_TENANT_MISSING,
ERR_TENANT_INVALID,
ERR_PARAM_INVALID,
ERR_NOT_FOUND,
ERR_CONFLICT,
ERR_FORBIDDEN,
ERR_TOOL_DISABLED,
ERR_WRITE_PROTECTED,
)
from pbl_common.context import (
ERR,
TenantContext,
build_context,
bind_context,
unbind_context,
current_context,
require_tenant,
require_context,
)
from pbl_common.tenant import (
tenant_scope,
assert_tenant,
normalize_tenant,
with_tenant,
)
from pbl_common.dbutil import (
get_dbname,
get_conn,
query,
query_one,
execute,
insert,
transaction,
audit_write,
build_audit_record,
crud_factory,
is_valid_identifier,
WRITE_PROTECTED_TABLES,
MODULE_DB_KEYS,
IDEMPOTENT_CODES,
)
from pbl_common.serialize import (
to_jsonable,
dumps,
loads,
datetime_to_str,
)
from pbl_common.crud_factory import (
make_crud,
CrudBase,
)
from pbl_common.tables import (
TABLES,
ensure_tables,
ddl_of,
)
from pbl_common.audit import (
write_audit,
AUDIT_ACTIONS,
)
__version__ = '1.0.0'
__all__ = [
# errors
'PblError', 'ErrorCode',
'ERR_TENANT_MISSING', 'ERR_TENANT_INVALID', 'ERR_PARAM_INVALID',
'ERR_NOT_FOUND', 'ERR_CONFLICT', 'ERR_FORBIDDEN',
'ERR_TOOL_DISABLED', 'ERR_WRITE_PROTECTED',
# context
'TenantContext', 'build_context', 'bind_context', 'unbind_context',
'current_context', 'require_tenant', 'require_context',
# tenant
'tenant_scope', 'assert_tenant', 'normalize_tenant', 'with_tenant',
# db
'get_dbname', 'get_conn', 'query', 'query_one', 'execute', 'insert', 'transaction',
# serialize
'to_jsonable', 'dumps', 'loads', 'datetime_to_str',
# crud
'make_crud', 'CrudBase',
# tables
'TABLES', 'ensure_tables', 'ddl_of',
# audit
'write_audit', 'AUDIT_ACTIONS',
# meta
'__version__',
"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",
]
__version__ = "1.0.0"
def _self_test_fail_closed():
"""六项 fail-closed 断言(纯逻辑,不连库、不依赖平台环境)。
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
"""
failures = []
total = 6
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)
# A1 无上下文 -> 必须抛错
def a1():
require_tenant()
expect_pbl_error(a1, "PBL_E_TENANT", "A1.no_context")
# 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,))
# A3 上下文取值 + 显式优先
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
else:
passed = total
return passed, total, failures
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
if __name__ == "__main__":
import sys
sys.exit(self_test())

View File

@ -1,570 +1,407 @@
# -*- coding: utf-8 -*-
"""pbl_common 公共内核:租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂。
"""pbl_common.kernel —— PBL Agent OS 公共内核M0
设计对齐projects/pbls/docs/01-design/modules/pbl_common.md
铁律
* 所有读写 tenant_id 强制打头缺失租户上下文一律 fail-closed 拒绝PBL_E_TENANT_MISSING
* 库名只经 ServerEnv().get_module_dbname('模块名') 获取禁止硬编码 DBNAME
* DB 方言 mariadbBIGINT AUTO_INCREMENT 主键 + xxx_code 业务主键 FK / ENUM / 无原生 TIMESTAMP
* append-only 表无 update_time无物理删除
职责对齐 docs/01-design/modules/pbl_common.md
1. 租户上下文 TenantContexttenant_id 强制打头缺失/空值一律 fail-closed 抛错
2. DB 适配 get_dbname()库名只允许来自 ServerEnv().get_module_dbname()禁止硬编码
3. 错误码 ERR统一 PBL 错误码注册表业务码 + HTTP 语义映射
4. 审计 audit_write()append-only 审计写入 tenant_id/actor/action 直接拒绝
5. CRUD 工厂 crud_factory()基于 sqlor 标准 APIsor.C/U/D/R/I/sqlExe生成
tenant 隔离的增删改查函数表名/字段名做标识符白名单校验杜绝 SQL 拼接注入
设计铁律
- fail-closed任何前置条件不满足 -> PblError绝不静默降级绝不返回空结果冒充成功
- 零外部副作用本模块 import 时不连库不读网络纯逻辑可单测
- 写保护引用模块rbac/world/scene/entity/scense/scense_runtime/script_engine
的基表不得经本工厂写入WRITE_PROTECTED_TABLES 命中即拒绝
"""
import hashlib
import json
import re
import threading
import time
import uuid
# ---------------------------------------------------------------------------
# 错误码(统一前缀 PBL_E_HTTP 语义由 api 层映射)
# ---------------------------------------------------------------------------
__all__ = [
"PblError",
"ERR",
"TenantContext",
"require_tenant",
"get_dbname",
"audit_write",
"crud_factory",
"WRITE_PROTECTED_TABLES",
"is_valid_identifier",
]
ERR_TENANT_MISSING = 'PBL_E_TENANT_MISSING'
ERR_TENANT_MISMATCH = 'PBL_E_TENANT_MISMATCH'
ERR_PARAM_INVALID = 'PBL_E_PARAM_INVALID'
ERR_NOT_FOUND = 'PBL_E_NOT_FOUND'
ERR_DUPLICATE = 'PBL_E_DUPLICATE'
ERR_LOCK_HELD = 'PBL_E_LOCK_HELD'
ERR_VERSION_CONFLICT = 'PBL_E_VERSION_CONFLICT'
ERR_VALIDATION_BLOCKED = 'PBL_E_VALIDATION_BLOCKED'
ERR_COMPILE_PRECONDITION = 'PBL_E_COMPILE_PRECONDITION'
ERR_TOOL_DENIED = 'PBL_E_TOOL_DENIED'
ERR_WRITE_PROTECTED = 'PBL_E_WRITE_PROTECTED'
ERR_APPEND_ONLY = 'PBL_E_APPEND_ONLY'
ERR_DB_UNAVAILABLE = 'PBL_E_DB_UNAVAILABLE'
ERR_INTERNAL = 'PBL_E_INTERNAL'
ERROR_MESSAGES = {
ERR_TENANT_MISSING: '租户上下文缺失请求被拒绝fail-closed',
ERR_TENANT_MISMATCH: '租户上下文与数据归属不一致',
ERR_PARAM_INVALID: '参数不合法',
ERR_NOT_FOUND: '对象不存在',
ERR_DUPLICATE: '对象已存在(唯一键冲突)',
ERR_LOCK_HELD: '蓝图编辑锁被他人持有',
ERR_VERSION_CONFLICT: '乐观锁版本冲突',
ERR_VALIDATION_BLOCKED: '存在 blocker 级校验发现,禁止进入下一阶段',
ERR_COMPILE_PRECONDITION: '编译前置条件不满足(校验未通过)',
ERR_TOOL_DENIED: 'Agent 工具调用被 fail-closed 裁决拒绝',
ERR_WRITE_PROTECTED: '目标为写保护复用域对象,禁止写入',
ERR_APPEND_ONLY: 'append-only 表禁止更新/删除',
ERR_DB_UNAVAILABLE: '数据库不可用',
ERR_INTERNAL: '内部错误',
# --------------------------------------------------------------------------
# 1. 错误码注册表
# --------------------------------------------------------------------------
ERR = {
# 通用
"PBL_OK": (0, "ok"),
"PBL_E_PARAM": (40001, "参数缺失或非法"),
"PBL_E_TENANT": (40002, "tenant_id 缺失或非法fail-closed 拒绝)"),
"PBL_E_AUTH": (40101, "未认证或凭据无效"),
"PBL_E_FORBIDDEN": (40301, "无权限执行该操作"),
"PBL_E_WRITE_LOCK": (40302, "目标为写保护对象,禁止写入"),
"PBL_E_NOT_FOUND": (40401, "对象不存在"),
"PBL_E_CONFLICT": (40901, "唯一键冲突或状态冲突"),
"PBL_E_VALIDATION": (42201, "蓝图/对象校验未通过"),
"PBL_E_TOOL_DISABLED": (40303, "Agent 工具已禁用fail-closed"),
"PBL_E_DB": (50001, "数据库适配失败:库名映射不可用"),
"PBL_E_INTERNAL": (50000, "内部错误"),
}
# 唯一键冲突 -> 幂等语义(采集/注入类接口据此转 PASS 而非报错)
IDEMPOTENT_CODES = ("PBL_E_CONFLICT",)
class PblError(Exception):
"""PBL 统一业务异常code + message + detail。"""
"""PBL 统一业务异常
def __init__(self, code, message=None, detail=None, http_status=400):
super(PblError, self).__init__(message or ERROR_MESSAGES.get(code, code))
属性
code ERR 注册表键名 'PBL_E_TENANT'
errno 数字错误码
msg 人类可读信息
detail 附加上下文dict可序列化
"""
def __init__(self, code, msg=None, detail=None):
if code not in ERR:
# 未注册错误码本身即缺陷fail-closed 暴露而非吞掉
code = "PBL_E_INTERNAL"
msg = msg or "未注册错误码"
self.code = code
self.message = message or ERROR_MESSAGES.get(code, code)
self.detail = detail or {}
self.http_status = http_status
self.errno, self.default_msg = ERR[code]
self.msg = msg or self.default_msg
self.detail = detail if isinstance(detail, dict) else {}
super(PblError, self).__init__("[%s/%s] %s" % (code, self.errno, self.msg))
def to_dict(self):
return {
'ok': False,
'code': self.code,
'message': self.message,
'detail': self.detail,
"ok": False,
"code": self.code,
"errno": self.errno,
"msg": self.msg,
"detail": self.detail,
}
def fail(code, message=None, detail=None, http_status=400):
"""抛出 PblError 的快捷函数。"""
raise PblError(code, message=message, detail=detail, http_status=http_status)
# --------------------------------------------------------------------------
# 2. 租户上下文tenant_id 强制打头)
# --------------------------------------------------------------------------
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
_TENANT_RE = re.compile(r"^[A-Za-z0-9_\-]{1,64}$")
# ---------------------------------------------------------------------------
# 租户上下文thread-local强制打头
# ---------------------------------------------------------------------------
_CTX = threading.local()
def is_valid_identifier(name):
"""表名/字段名合法性:仅允许字母数字下划线且不以数字开头,长度 <=64。"""
return bool(name) and isinstance(name, str) and bool(_IDENT_RE.match(name))
class TenantContext(object):
"""一次请求内的租户上下文快照。"""
__slots__ = ('tenant_id', 'user_code', 'role_code', 'trace_id', 'client_ip', 'session_code')
def __init__(self, tenant_id, user_code=None, role_code=None,
trace_id=None, client_ip=None, session_code=None):
self.tenant_id = tenant_id
self.user_code = user_code
self.role_code = role_code
self.trace_id = trace_id or new_trace_id()
self.client_ip = client_ip
self.session_code = session_code
def as_dict(self):
return {
'tenant_id': self.tenant_id,
'user_code': self.user_code,
'role_code': self.role_code,
'trace_id': self.trace_id,
'client_ip': self.client_ip,
'session_code': self.session_code,
}
def new_trace_id():
"""生成链路追踪 ID。"""
return 'trc_%s' % uuid.uuid4().hex[:24]
def set_tenant_context(tenant_id, **kwargs):
"""设置当前线程租户上下文tenant_id 为空直接 fail-closed。"""
if not tenant_id or not str(tenant_id).strip():
fail(ERR_TENANT_MISSING, http_status=403)
ctx = TenantContext(str(tenant_id).strip(), **kwargs)
_CTX.current = ctx
return ctx
def get_tenant_context(required=True):
"""取当前租户上下文required=True 且缺失时 fail-closed。"""
ctx = getattr(_CTX, 'current', None)
if ctx is None and required:
fail(ERR_TENANT_MISSING, http_status=403)
return ctx
def current_tenant_id(required=True):
"""取当前 tenant_id 字符串。"""
ctx = get_tenant_context(required=required)
return ctx.tenant_id if ctx else None
def clear_tenant_context():
"""请求结束时清理,避免线程复用串租户。"""
_CTX.current = None
def from_request(params, required=True):
"""从 api 入参构造租户上下文api 层统一入口)。"""
params = params or {}
tenant_id = params.get('tenant_id') or params.get('tenantId')
if not tenant_id and required:
fail(ERR_TENANT_MISSING, http_status=403)
if not tenant_id:
return None
return set_tenant_context(
tenant_id,
user_code=params.get('user_code') or params.get('operator'),
role_code=params.get('role_code'),
trace_id=params.get('trace_id'),
client_ip=params.get('client_ip'),
session_code=params.get('session_code'),
)
def assert_tenant_match(row, tenant_id=None):
"""校验数据行归属当前租户,不一致即拒绝(防越权读)。"""
tid = tenant_id or current_tenant_id()
row_tid = (row or {}).get('tenant_id')
if row_tid is None:
return row
if str(row_tid) != str(tid):
fail(ERR_TENANT_MISMATCH, detail={'row_tenant': row_tid, 'ctx_tenant': tid}, http_status=403)
return row
# ---------------------------------------------------------------------------
# 标识与哈希工具
# ---------------------------------------------------------------------------
def new_code(prefix):
"""生成业务主键:{prefix}_{22位hex},全局唯一、可读前缀。"""
return '%s_%s' % (prefix, uuid.uuid4().hex[:22])
def sha256_text(text):
"""对文本做 sha256确定性编译/证据幂等共用)。"""
if text is None:
text = ''
if not isinstance(text, bytes):
text = json_dumps_canonical(text) if not isinstance(text, str) else text
text = text.encode('utf-8')
return hashlib.sha256(text).hexdigest()
def json_dumps_canonical(obj):
"""规范化 JSON 序列化:键排序、无多余空格、非 ASCII 保留——保证同输入同 hash。"""
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(',', ':'), default=str)
def idem_key(*parts):
"""证据幂等键:各段拼接后 sha256长度固定 128 内(列宽 VARCHAR(128))。"""
joined = '|'.join('' if p is None else str(p) for p in parts)
return 'ik_%s' % sha256_text(joined)[:64]
# ---------------------------------------------------------------------------
# DB 适配sqlor 标准 API仅 sor.C/U/D/R/I/sqlExe
# ---------------------------------------------------------------------------
DIALECT = 'mariadb'
class DbAdapter(object):
"""sqlor 之上的租户安全适配层。
* 库名经 ServerEnv().get_module_dbname(module) 解析禁止硬编码
* 所有查询自动注入 tenant_id 条件强制打头
* append-only 表拒绝 update/delete
"""
APPEND_ONLY_TABLES = frozenset([
'pbl_audit_log', 'pbl_blueprint_version', 'pbl_subobject_review', 'pbl_blueprint_publish',
'pbl_validation_finding', 'pbl_compile_log', 'pbl_agent_message', 'pbl_agent_tool_call',
'pbl_agent_arbitration', 'pbl_evidence_idem', 'pbl_analytics_snapshot',
])
WRITE_PROTECTED_MODULES = frozenset([
'rbac', 'accounting', 'apppublic', 'sqlor', 'ahserver', 'appbase',
'world', 'scene', 'entity', 'script_engine', 'scense_runtime', 'scense', 'scense_game',
])
def __init__(self, module_name='pbl_common', sor=None):
self.module_name = module_name
self._sor = sor
# -- 连接解析 -----------------------------------------------------------
def sor(self):
"""惰性解析 sqlor 句柄;未注入时从 ServerEnv 取。"""
if self._sor is not None:
return self._sor
try:
from appbase.serverenv import ServerEnv
env = ServerEnv()
sor = getattr(env, 'sor', None)
if sor is None:
dbname = env.get_module_dbname(self.module_name)
sor = env.get_sor(dbname) if hasattr(env, 'get_sor') else None
self._sor = sor
except Exception as exc:
fail(ERR_DB_UNAVAILABLE, detail={'reason': str(exc)}, http_status=503)
if self._sor is None:
fail(ERR_DB_UNAVAILABLE, detail={'reason': 'sor 未就绪'}, http_status=503)
return self._sor
def dbname(self):
"""当前模块库名唯一合法来源ServerEnv.get_module_dbname"""
from appbase.serverenv import ServerEnv
return ServerEnv().get_module_dbname(self.module_name)
# -- 租户条件 -----------------------------------------------------------
def _with_tenant(self, where, tenant_id=None):
"""把 tenant_id 合并进 where 字典(强制打头)。"""
tid = tenant_id or current_tenant_id()
merged = dict(where or {})
merged['tenant_id'] = tid
return merged
def _guard_append_only(self, table, op):
if op in ('U', 'D') and table in self.APPEND_ONLY_TABLES:
fail(ERR_APPEND_ONLY, detail={'table': table, 'op': op}, http_status=403)
def _guard_write_protected(self, module_name, op):
if op in ('C', 'U', 'D') and module_name in self.WRITE_PROTECTED_MODULES:
fail(ERR_WRITE_PROTECTED, detail={'module': module_name, 'op': op}, http_status=403)
# -- 标准 CRUDsqlor 五法) -------------------------------------------
def insert(self, table, row, tenant_id=None):
"""新增:自动补 tenant_id / create_time / 业务主键(若表约定 xxx_code"""
data = dict(row or {})
data['tenant_id'] = tenant_id or current_tenant_id()
data.setdefault('create_time', now_str())
if table not in self.APPEND_ONLY_TABLES:
data.setdefault('update_time', data['create_time'])
return self.sor().C(table, data)
def update(self, table, where, row, tenant_id=None):
"""更新where 强制含 tenant_idappend-only 表拒绝。"""
self._guard_append_only(table, 'U')
data = dict(row or {})
data.pop('tenant_id', None) # 租户列不可改
data.pop('id', None) # 物理主键不可改
data['update_time'] = now_str()
return self.sor().U(table, self._with_tenant(where, tenant_id), data)
def delete(self, table, where, tenant_id=None):
"""删除where 强制含 tenant_idappend-only 表拒绝(无物理删除)。"""
self._guard_append_only(table, 'D')
return self.sor().D(table, self._with_tenant(where, tenant_id))
def get(self, table, where, tenant_id=None):
"""单行读取并校验归属。"""
rows = self.sor().R(table, self._with_tenant(where, tenant_id), limit=1)
row = rows[0] if rows else None
return assert_tenant_match(row, tenant_id or current_tenant_id()) if row else None
def query(self, table, where=None, order_by=None, limit=100, offset=0, tenant_id=None):
"""列表读取tenant_id 强制注入。"""
return self.sor().R(
table,
self._with_tenant(where, tenant_id),
order_by=order_by,
limit=int(limit or 100),
offset=int(offset or 0),
) or []
def count(self, table, where=None, tenant_id=None):
"""计数。"""
rows = self.sor().sqlExe(
'SELECT COUNT(1) AS cnt FROM %s WHERE tenant_id=%%s' % table,
[tenant_id or current_tenant_id()],
)
return int((rows[0].get('cnt') if rows else 0) or 0)
def sql(self, sql, args=None):
"""原生 SQL 执行(仅 mariadb 方言;调用方自行保证 tenant_id 条件)。"""
return self.sor().sqlExe(sql, list(args or []))
def transaction(self):
"""单事务上下文M11a 事件+状态同事务写入依赖此能力)。"""
return DbTransaction(self.sor())
class DbTransaction(object):
"""单事务封装with 块内全部语句同事务,异常整体回滚(原子性门禁)。"""
def __init__(self, sor):
self._sor = sor
self._conn = None
self._statements = []
def __enter__(self):
self._conn = self._sor.begin() if hasattr(self._sor, 'begin') else None
return self
def execute(self, sql, args=None):
"""事务内执行一条语句。"""
self._statements.append((sql, list(args or [])))
if self._conn is not None:
return self._conn.execute(sql, list(args or []))
return self._sor.sqlExe(sql, list(args or []))
def insert(self, table, row):
cols = list(row.keys())
placeholders = ', '.join(['%s'] * len(cols))
sql = 'INSERT INTO %s (%s) VALUES (%s)' % (table, ', '.join(cols), placeholders)
return self.execute(sql, [row[c] for c in cols])
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
if self._conn is not None and hasattr(self._conn, 'commit'):
self._conn.commit()
return False
if self._conn is not None and hasattr(self._conn, 'rollback'):
self._conn.rollback()
return False
def now_str():
"""当前时间字符串DATETIME 格式,禁用原生 TIMESTAMP"""
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
_db_singleton = {}
def db(module_name='pbl_common', sor=None):
"""取模块级 DbAdapter 单例。"""
key = (module_name, id(sor))
if key not in _db_singleton:
_db_singleton[key] = DbAdapter(module_name=module_name, sor=sor)
return _db_singleton[key]
# ---------------------------------------------------------------------------
# 审计(写 pbl_audit_logappend-onlyowner.audit 角色独立只读)
# ---------------------------------------------------------------------------
AUDIT_ACTIONS = {
'create', 'update', 'delete', 'read', 'validate', 'compile', 'publish',
'tool_allow', 'tool_deny', 'login', 'logout', 'seed', 'broadcast', 'score',
}
def write_audit(action_code, object_type=None, object_code=None, result='success',
detail=None, tenant_id=None, actor_type=None, actor_code=None,
adapter=None):
"""追加一条审计记录append-only永不更新/删除)。
审计失败不阻断主流程降级为 stderr 记录但必须留痕
"""
ctx = get_tenant_context(required=False)
tid = tenant_id or (ctx.tenant_id if ctx else None)
if not tid:
# 无租户上下文的审计(如系统启动自检)落 __system__ 租户
tid = '__system__'
row = {
'audit_code': new_code('aud'),
'tenant_id': tid,
'actor_type': actor_type or (ctx.role_code if ctx and ctx.role_code else 'system'),
'actor_code': actor_code or (ctx.user_code if ctx else None),
'action_code': action_code if action_code in AUDIT_ACTIONS else 'read',
'object_type': object_type,
'object_code': object_code,
'result': result,
'detail': json_dumps_canonical(detail) if detail is not None else None,
'trace_id': ctx.trace_id if ctx else new_trace_id(),
'client_ip': ctx.client_ip if ctx else None,
'create_time': now_str(),
}
try:
adp = adapter or db('pbl_governance')
return adp.sor().C('pbl_audit_log', row)
except Exception as exc: # pragma: no cover - 审计降级
import sys
sys.stderr.write('[pbl_common.audit] degraded: %s | row=%s\n' % (exc, row))
return None
# ---------------------------------------------------------------------------
# CRUD 工厂(各 pbl_* 模块复用,统一租户/主键/乐观锁/审计语义)
# ---------------------------------------------------------------------------
class CrudFactory(object):
"""按表生成 list/get/create/update/remove 五法,统一注入租户与审计。
"""线程局部租户上下文。
用法
bp_crud = CrudFactory('pbl_blueprint', code_prefix='bp', code_field='bp_code',
module='pbl_blueprint', optimistic=True, version_field='cur_version')
bp_crud.create({'bp_name': 'xxx', ...})
with TenantContext(tenant_id, actor='u001'):
rows = api.list_blueprints()
退出 with 自动恢复上层上下文支持嵌套
"""
def __init__(self, table, code_prefix, code_field='code', module=None,
optimistic=False, version_field='version_no', append_only=False,
adapter=None, search_fields=None):
self.table = table
self.code_prefix = code_prefix
self.code_field = code_field
self.module = module or 'pbl_common'
self.optimistic = optimistic
self.version_field = version_field
self.append_only = append_only
self.search_fields = search_fields or []
self._adapter = adapter
_local = threading.local()
def __init__(self, tenant_id, actor=None, roles=None):
self.tenant_id = _check_tenant(tenant_id)
self.actor = actor
self.roles = roles or []
self._prev = None
# -- 栈式读写 --------------------------------------------------------
@classmethod
def current(cls):
return getattr(cls._local, "stack", None)
@classmethod
def peek(cls):
stack = getattr(cls._local, "stack", None)
return stack[-1] if stack else None
def __enter__(self):
stack = getattr(self._local, "stack", None)
if stack is None:
stack = []
self._local.stack = stack
self._prev = len(stack)
stack.append(self)
return self
def __exit__(self, exc_type, exc, tb):
stack = getattr(self._local, "stack", None)
if stack is not None and self._prev is not None:
del stack[self._prev:]
return False
# -- 便捷属性 --------------------------------------------------------
@property
def adp(self):
return self._adapter or db(self.module)
def list(self, where=None, order_by=None, limit=100, offset=0, tenant_id=None):
"""分页列表tenant_id 强制)。"""
return self.adp.query(self.table, where=where, order_by=order_by,
limit=limit, offset=offset, tenant_id=tenant_id)
def get(self, code=None, where=None, tenant_id=None):
"""按业务主键或条件取单行。"""
cond = dict(where or {})
if code:
cond[self.code_field] = code
if not cond:
fail(ERR_PARAM_INVALID, detail={'reason': 'code 或 where 至少给一个'})
return self.adp.get(self.table, cond, tenant_id=tenant_id)
def create(self, row, tenant_id=None, audit=True):
"""新增:自动生成业务主键、租户打头、乐观锁初值。"""
data = dict(row or {})
data.setdefault(self.code_field, new_code(self.code_prefix))
if self.optimistic:
data.setdefault(self.version_field, 1)
if self.append_only:
data.pop('update_time', None)
result = self.adp.insert(self.table, data, tenant_id=tenant_id)
if audit:
write_audit('create', object_type=self.table, object_code=data[self.code_field],
detail={'fields': sorted(data.keys())}, tenant_id=tenant_id,
adapter=self.adp)
return {'ok': True, self.code_field: data[self.code_field], 'id': result}
def update(self, code, row, expect_version=None, tenant_id=None, audit=True):
"""更新append-only 拒绝;乐观锁版本不匹配拒绝。"""
if self.append_only:
fail(ERR_APPEND_ONLY, detail={'table': self.table}, http_status=403)
data = dict(row or {})
data.pop(self.code_field, None)
where = {self.code_field: code}
if self.optimistic:
current = self.get(code=code, tenant_id=tenant_id)
if not current:
fail(ERR_NOT_FOUND, detail={'table': self.table, self.code_field: code}, http_status=404)
cur_ver = int(current.get(self.version_field) or 0)
if expect_version is not None and int(expect_version) != cur_ver:
fail(ERR_VERSION_CONFLICT,
detail={'current': cur_ver, 'expect': int(expect_version)}, http_status=409)
data[self.version_field] = cur_ver + 1
where[self.version_field] = cur_ver
affected = self.adp.update(self.table, where, data, tenant_id=tenant_id)
if self.optimistic and not affected:
fail(ERR_VERSION_CONFLICT, detail={'table': self.table}, http_status=409)
if audit:
write_audit('update', object_type=self.table, object_code=code,
detail={'fields': sorted(data.keys())}, tenant_id=tenant_id,
adapter=self.adp)
return {'ok': True, 'affected': affected}
def remove(self, code, tenant_id=None, audit=True):
"""删除append-only 表拒绝物理删除。"""
if self.append_only:
fail(ERR_APPEND_ONLY, detail={'table': self.table}, http_status=403)
affected = self.adp.delete(self.table, {self.code_field: code}, tenant_id=tenant_id)
if audit:
write_audit('delete', object_type=self.table, object_code=code,
tenant_id=tenant_id, adapter=self.adp)
return {'ok': True, 'affected': affected}
def actor_id(self):
return self.actor or "system"
def make_crud(table, code_prefix, **kwargs):
"""CRUD 工厂函数式入口。"""
return CrudFactory(table, code_prefix, **kwargs)
def _check_tenant(tenant_id):
"""tenant_id 校验None/空串/非字符串/含非法字符 -> PBL_E_TENANT。"""
if tenant_id is None:
raise PblError("PBL_E_TENANT", "tenant_id 为 None")
if not isinstance(tenant_id, str):
raise PblError("PBL_E_TENANT", "tenant_id 必须是字符串", {"got": type(tenant_id).__name__})
tid = tenant_id.strip()
if not tid:
raise PblError("PBL_E_TENANT", "tenant_id 为空串/空白")
if not _TENANT_RE.match(tid):
raise PblError("PBL_E_TENANT", "tenant_id 含非法字符", {"tenant_id": tid})
return tid
# ---------------------------------------------------------------------------
# 通用参数校验
# ---------------------------------------------------------------------------
def require_tenant(explicit=None):
"""取当前 tenant_id显式入参优先其次上下文两者皆无 -> fail-closed 抛错。
MAX_TENANT_LEN = 32
MAX_CODE_LEN = 32
所有对外契约接口的第一行都应调用本函数确保 tenant_id 强制打头
"""
if explicit is not None and str(explicit).strip() != "":
return _check_tenant(explicit)
ctx = TenantContext.peek()
if ctx is None:
raise PblError("PBL_E_TENANT", "无租户上下文且未显式传入 tenant_id")
return ctx.tenant_id
def require(params, *fields):
"""必填校验:缺任一字段即 PBL_E_PARAM_INVALID。"""
params = params or {}
missing = [f for f in fields if params.get(f) in (None, '')]
if missing:
fail(ERR_PARAM_INVALID, detail={'missing': missing})
return params
# --------------------------------------------------------------------------
# 3. DB 适配(库名只来自 ServerEnv禁止硬编码
# --------------------------------------------------------------------------
#: 模块名 -> 库名逻辑键;实际库名由 ServerEnv().get_module_dbname() 解析。
#: 这里只登记「模块名」,绝不登记真实库名字符串。
MODULE_DB_KEYS = (
"pbl_common",
"pbl_appcodes",
"pbl_blueprint",
"pbl_validation",
"pbl_compiler",
"pbl_agent_runtime",
"pbl_evidence",
"pbl_assessment",
"pbl_kdb_ext",
"pbl_domain_ext",
"pbl_scense_ext",
"pbl_runtime_ext",
"pbl_template",
"pbl_analytics",
"pbl_governance",
)
#: 写保护基表引用模块所有任何写入路径命中即拒绝G6/Q-OPEN-3
WRITE_PROTECTED_TABLES = frozenset(
[
"rbac_user", "rbac_role", "rbac_permission", "rbac_user_role",
"world", "world_snapshot", "world_sync", "world_sync_task", "world_sync_log",
"scene", "scene_import",
"entity", "entity_import",
"scense", "scense_game",
"script_engine", "script_engine_log",
"kdb_doc", "kdb_chunk", "research_paper",
]
)
def check_len(value, field, max_len, allow_none=False):
"""长度校验(对齐 DDL 列宽)。"""
if value is None:
if allow_none:
return value
fail(ERR_PARAM_INVALID, detail={'field': field, 'reason': 'not null'})
if len(str(value)) > max_len:
fail(ERR_PARAM_INVALID, detail={'field': field, 'max_len': max_len})
return value
def get_dbname(module):
"""解析模块库名。
规则fail-closed
- module 必须在 MODULE_DB_KEYS 白名单内
- 必须能从 ServerEnv 取到 get_module_dbname取不到 -> PBL_E_DB
- 严禁返回硬编码库名
"""
if not module or not isinstance(module, str):
raise PblError("PBL_E_PARAM", "module 名非法")
if module not in MODULE_DB_KEYS:
raise PblError("PBL_E_PARAM", "模块未登记库名映射", {"module": module})
try:
from appbase.serverenv import ServerEnv # noqa: 平台基础模块
except Exception:
try:
from appbase import ServerEnv # noqa: 兼容不同导出层级
except Exception as e:
raise PblError("PBL_E_DB", "ServerEnv 不可用,无法解析库名", {"err": str(e)})
try:
env = ServerEnv()
fn = getattr(env, "get_module_dbname", None)
if not callable(fn):
raise PblError("PBL_E_DB", "ServerEnv 未挂载 get_module_dbname")
dbname = fn(module)
except PblError:
raise
except Exception as e:
raise PblError("PBL_E_DB", "get_module_dbname 调用失败", {"module": module, "err": str(e)})
if not dbname or not isinstance(dbname, str):
raise PblError("PBL_E_DB", "库名映射为空", {"module": module})
return dbname
def check_in(value, field, allowed, allow_none=False):
"""枚举校验:值必须落在 appcodes_kv 字典内DDL 无 ENUM约束在应用层"""
if value is None and allow_none:
return value
if value not in allowed:
fail(ERR_PARAM_INVALID, detail={'field': field, 'allowed': sorted(allowed), 'got': value})
return value
def _get_sor(module):
"""取 sqlor 句柄(延迟 import便于无 DB 环境跑纯逻辑自测)。"""
try:
import sqlor
except Exception as e:
raise PblError("PBL_E_DB", "sqlor 不可用", {"err": str(e)})
dbname = get_dbname(module)
try:
return sqlor.sor(dbname) if hasattr(sqlor, "sor") else sqlor.Sor(dbname)
except Exception as e:
raise PblError("PBL_E_DB", "sqlor 连接构造失败", {"dbname_key": module, "err": str(e)})
def ok(data=None, **extra):
"""统一成功返回体。"""
body = {'ok': True}
if data is not None:
body['data'] = data
body.update(extra)
return body
# --------------------------------------------------------------------------
# 4. 审计append-only
# --------------------------------------------------------------------------
AUDIT_TABLE = "pbl_audit_log"
AUDIT_REQUIRED = ("tenant_id", "actor", "action")
def build_audit_record(tenant_id, actor, action, obj_type=None, obj_id=None,
before=None, after=None, result="ok", detail=None):
"""构造审计记录(纯函数,不落库)——供自测与调用方复用。
fail-closedtenant_id/actor/action 任一缺失即抛 PBL_E_PARAM
"""
rec = {
"tenant_id": _check_tenant(tenant_id),
"actor": (actor or "").strip() if isinstance(actor, str) else actor,
"action": (action or "").strip() if isinstance(action, str) else action,
"obj_type": obj_type,
"obj_id": obj_id,
"before": before,
"after": after,
"result": result or "ok",
"detail": detail,
}
for k in AUDIT_REQUIRED:
v = rec.get(k)
if v is None or (isinstance(v, str) and v.strip() == ""):
raise PblError("PBL_E_PARAM", "审计必填字段缺失: %s" % k)
return rec
def audit_write(module, tenant_id, actor, action, **kw):
"""写一条审计记录append-only不做 UPDATE/DELETE"""
rec = build_audit_record(tenant_id, actor, action, **kw)
sor = _get_sor(module)
try:
return sor.C(AUDIT_TABLE, rec)
except Exception as e:
raise PblError("PBL_E_DB", "审计写入失败", {"err": str(e)})
# --------------------------------------------------------------------------
# 5. CRUD 工厂tenant 隔离 + 写保护 + 标识符校验)
# --------------------------------------------------------------------------
def crud_factory(module, tblname, search_fields=None, order_by="id desc",
immutable_fields=None):
"""生成一组 tenant 隔离的 CRUD 函数。
参数
module 库名映射所属模块须在 MODULE_DB_KEYS
tblname 目标表须为合法标识符且不在 WRITE_PROTECTED_TABLES
search_fields 关键字模糊搜索字段白名单
order_by 默认排序须为合法 'field [asc|desc]' 形式
immutable_fields 更新时禁止修改的字段 tenant_id/created_at
返回 dictcreate / get / list / update / delete / count
"""
if not is_valid_identifier(tblname):
raise PblError("PBL_E_PARAM", "表名非法", {"tblname": tblname})
if tblname in WRITE_PROTECTED_TABLES:
raise PblError("PBL_E_WRITE_LOCK", "写保护基表禁止经 CRUD 工厂操作",
{"tblname": tblname})
if not order_by or not re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,63}(\s+(asc|desc))?$",
str(order_by).strip(), re.I):
raise PblError("PBL_E_PARAM", "order_by 非法", {"order_by": order_by})
search_fields = tuple(search_fields or ())
for f in search_fields:
if not is_valid_identifier(f):
raise PblError("PBL_E_PARAM", "search_fields 含非法字段", {"field": f})
immutable_fields = frozenset(immutable_fields or ("tenant_id",))
for f in immutable_fields:
if not is_valid_identifier(f):
raise PblError("PBL_E_PARAM", "immutable_fields 含非法字段", {"field": f})
def _clean(data, tenant_id, forbid_immutable=False):
if not isinstance(data, dict):
raise PblError("PBL_E_PARAM", "数据必须是 dict")
out = {}
for k, v in data.items():
if not is_valid_identifier(k):
raise PblError("PBL_E_PARAM", "字段名非法", {"field": k})
if forbid_immutable and k in immutable_fields:
raise PblError("PBL_E_PARAM", "字段不可修改", {"field": k})
out[k] = v
out["tenant_id"] = tenant_id # tenant_id 强制打头/强制覆盖
return out
def create(data, tenant_id=None):
tid = require_tenant(tenant_id)
return _get_sor(module).C(tblname, _clean(data, tid))
def get(pk, tenant_id=None):
tid = require_tenant(tenant_id)
rows = _get_sor(module).R(tblname, {"tenant_id": tid, "id": pk}, limit=1)
if not rows:
raise PblError("PBL_E_NOT_FOUND", "记录不存在", {"tblname": tblname, "id": pk})
return rows[0]
def list(where=None, page=1, page_size=20, keyword=None, tenant_id=None):
tid = require_tenant(tenant_id)
cond = {"tenant_id": tid}
if isinstance(where, dict):
for k, v in where.items():
if not is_valid_identifier(k):
raise PblError("PBL_E_PARAM", "查询字段非法", {"field": k})
cond[k] = v
kw = None
if keyword and search_fields:
kw = {"fields": list(search_fields), "value": keyword}
try:
page = max(1, int(page))
page_size = min(500, max(1, int(page_size)))
except (TypeError, ValueError):
raise PblError("PBL_E_PARAM", "分页参数非法")
return _get_sor(module).R(tblname, cond, order=order_by,
page=page, page_size=page_size, keyword=kw)
def count(where=None, tenant_id=None):
tid = require_tenant(tenant_id)
cond = {"tenant_id": tid}
if isinstance(where, dict):
for k, v in where.items():
if not is_valid_identifier(k):
raise PblError("PBL_E_PARAM", "查询字段非法", {"field": k})
cond[k] = v
rows = _get_sor(module).R(tblname, cond)
return len(rows or [])
def update(pk, data, tenant_id=None):
tid = require_tenant(tenant_id)
payload = _clean(data, tid, forbid_immutable=True)
return _get_sor(module).U(tblname, {"tenant_id": tid, "id": pk}, payload)
def delete(pk, tenant_id=None):
tid = require_tenant(tenant_id)
return _get_sor(module).D(tblname, {"tenant_id": tid, "id": pk})
return {
"create": create,
"get": get,
"list": list,
"count": count,
"update": update,
"delete": delete,
"tblname": tblname,
"module": module,
}