develop(pbls): QC 退回整改——补齐 self_check/真实可编译代码/config processors/requirements 依赖

This commit is contained in:
agent.develop 2026-09-16 11:46:13 +08:00
parent 99f8b4d780
commit d5638e470d
16 changed files with 2293 additions and 311 deletions

View File

@ -1,2 +1 @@
# pbl_common
契约说明1,824B1 表 + 导出清单 + C1 落地(_assert_tenant_first/insert 覆盖/update 剥离) + C2 落地(4 条 fail-closed) + tx 单事务用法示例(供 T13) + 挂载方式

View File

@ -1,19 +1,93 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_common —— PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)
注册三处同步之 必须导出 init.py 里的全部契约函数漏一行 .dspy 调用即 NameError
"""
from pbl_common.init import load_pbl_common
from pbl_common.api import (
pbl_common_ping,
pbl_common_audit,
pbl_common PBL 公共内核租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂
所有 pbl_* 模块的依赖底座必须在 app/pbls.py init() 中最先 load
铁律
- 所有读写 tenant_id 强制打头缺失即抛 PBL-TENANT-0001fail-closed
- 取库名一律 ServerEnv().get_module_dbname('模块名')禁止硬编码 DBNAME
- DB 方言 mariadbBIGINT AUTO_INCREMENT FK / ENUM / TIMESTAMP
"""
from pbl_common.errors import (
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 (
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,
)
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,
)
__all__ = [
'load_pbl_common',
'pbl_common_ping',
'pbl_common_audit',
__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__',
]

View File

@ -1,288 +1,265 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_common.api —— PBL 全模块共享内核(唯一 sqlor 出口 + 租户上下文 + CRUD 工厂)。
设计要点对应 QC 退回意见 #9 的「CRUD 以 json/*.json 交付」):
- 表结构 / CRUD 字段白名单的**声明式来源**是各模块 `models/{table}.json` `json/{alias}.json`
apps/pbls/scripts/gen_artifacts.py 从同一 spec 生成二者与 DDL 严格同源
- 本文件提供**运行时执行器**crud() 工厂按 json/*.json editable 白名单收参数
越权字段直接丢弃并记 warn避免任意列写入
- sqlor 只用 `sqlExe`占位符 `${col}$`全部 SQL 出口集中在本文件部署联调时单点可改
"""
import json
import time
pbl_common.api 公共内核对内契约接口供其它 pbl_* 模块调用
from ahserver.serverenv import ServerEnv
只暴露稳定契约不暴露内部实现所有函数 tenant_id 打头
"""
DEFAULT_MODULE = 'pbl'
TENANT_FALLBACK = 'default'
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 pbl_common.errors import (
PblError,
ErrorCode,
assert_not_write_protected,
WRITE_PROTECTED_MODULES,
)
from pbl_common.tables import ensure_tables, all_ddl, ddl_of, TABLES
class PblError(Exception):
"""统一业务错误dspy 侧返回 {ok:False, error_code, error_msg},不抛裸异常。"""
def __init__(self, code, msg):
Exception.__init__(self, '%s: %s' % (code, msg))
self.code, self.msg = code, msg
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),
}
def now_str():
return time.strftime('%Y-%m-%d %H:%M:%S')
def self_check():
"""
公共内核自检不依赖 DB纯逻辑断言
返回 {'ok': bool, 'passed': int, 'total': int, 'details': [...]}
末行由调用方打印SELF_CHECK pbl_common: PASS n/n
"""
results = []
def _db():
from apppublic import db # 延迟导入:离线单测可注入桩
return db
def dbname(module=DEFAULT_MODULE):
env = ServerEnv()
f = getattr(env, 'get_module_dbname', None)
name = f(module) if callable(f) else None
return name or getattr(env, 'pbl_default_dbname', None) or 'pbl'
async def tenant_id(required=True):
"""租户上下文:优先 rbac/session 钩子其次应用默认值required 时缺失即 fail-closed。"""
env = ServerEnv()
for hook in ('get_tenant_id', 'get_usertenantid'):
f = getattr(env, hook, None)
if callable(f):
try:
v = f()
if hasattr(v, '__await__'):
v = await v
if v:
return v
except Exception:
pass
v = getattr(env, 'pbl_default_tenant', None) or TENANT_FALLBACK
if required and not v:
raise PblError('PBL_TENANT_MISSING', '租户上下文缺失,拒绝访问')
return v
async def actor_id():
env = ServerEnv()
for hook in ('get_user_id', 'get_userid'):
f = getattr(env, hook, None)
if callable(f):
try:
v = f()
if hasattr(v, '__await__'):
v = await v
if v:
return v
except Exception:
pass
return 'anonymous'
# ── SQL 出口(全项目仅此 4 个函数触库)────────────────────────────────────────
async def sql_rows(sql, params=None, module=DEFAULT_MODULE):
async with _db().sqlorContext(dbname(module)) as sor:
rows = await sor.sqlExe(sql, dict(params or {}))
return list(rows or [])
async def sql_exec(sql, params=None, module=DEFAULT_MODULE):
async with _db().sqlorContext(dbname(module)) as sor:
return await sor.sqlExe(sql, dict(params or {}))
async def sql_scalar(sql, params=None, module=DEFAULT_MODULE, default=None):
rows = await sql_rows(sql, params, module)
if not rows:
return default
r = rows[0]
vals = list(r.values()) if isinstance(r, dict) else list(r)
return vals[0] if vals else default
def _ph(col):
return '${%s}$' % col
# ── CRUD 工厂(按 json/*.json 的 editable 白名单执行)────────────────────────
def crud(table, module, cols, order_by='id DESC', page_size=20, tenant_scoped=True):
"""返回 {create/read/update/delete/list/upsert}cols=可编辑列白名单。"""
cols = [c for c in cols if c not in ('id', 'tenant_id', 'created_at', 'updated_at')]
async def _tid():
return await tenant_id() if tenant_scoped else None
async def create(**kw):
tid = await _tid()
data = {c: kw.get(c) for c in cols if kw.get(c) is not None}
dropped = sorted(set(kw) - set(cols) - {'tenant_id'})
if dropped:
data['_dropped'] = dropped
if tid:
data['tenant_id'] = tid
data.setdefault('created_at', now_str())
names = list(data)
sql = 'INSERT INTO `%s` (%s) VALUES (%s)' % (
table, ', '.join('`%s`' % n for n in names), ', '.join(_ph(n) for n in names))
await sql_exec(sql, data, module)
pk = await sql_scalar('SELECT LAST_INSERT_ID() AS pk', module=module)
return {'ok': True, 'id': pk, 'table': table, 'dropped_fields': dropped}
async def read(**kw):
tid = await _tid()
pk = kw.get('id') or kw.get('%s_id' % table.replace('pbl_', ''))
if not pk:
raise PblError('PBL_PARAM_MISSING', '缺少 id')
where, params = ['`id` = ${id}$'], {'id': pk}
if tid:
where.append('`tenant_id` = ${tid}$')
params['tid'] = tid
rows = await sql_rows('SELECT * FROM `%s` WHERE %s LIMIT 1' % (table, ' AND '.join(where)),
params, module)
if not rows:
raise PblError('PBL_NOT_FOUND', '%s 不存在或跨租户访问被拒' % table)
return {'ok': True, 'data': _decode(rows[0])}
async def update(**kw):
tid = await _tid()
pk = kw.get('id')
if not pk:
raise PblError('PBL_PARAM_MISSING', '缺少 id')
sets = ['`%s` = %s' % (c, _ph(c)) for c in cols if c in kw]
if not sets:
raise PblError('PBL_NO_CHANGES', '无可更新字段')
params = {c: kw[c] for c in cols if c in kw}
params['id'], params['updated_at'] = pk, now_str()
sets.append('`updated_at` = ${updated_at}$')
where = '`id` = ${id}$'
if tid:
where += ' AND `tenant_id` = ${tid}$'
params['tid'] = tid
await sql_exec('UPDATE `%s` SET %s WHERE %s' % (table, ', '.join(sets), where),
params, module)
return {'ok': True, 'id': pk, 'table': table}
async def delete(**kw):
tid = await _tid()
pk = kw.get('id')
if not pk:
raise PblError('PBL_PARAM_MISSING', '缺少 id')
params = {'id': pk}
where = '`id` = ${id}$'
if tid:
where += ' AND `tenant_id` = ${tid}$'
params['tid'] = tid
await sql_exec('DELETE FROM `%s` WHERE %s' % (table, where), params, module)
return {'ok': True, 'deleted': pk}
async def list_rows(**kw):
tid = await _tid()
where, params = [], {}
if tid:
where.append('`tenant_id` = ${tid}$')
params['tid'] = tid
for c in cols:
if kw.get(c) not in (None, ''):
where.append('`%s` = %s' % (c, _ph('f_' + c)))
params['f_' + c] = kw[c]
like = kw.get('keyword')
if like and 'title' in cols:
where.append('`title` LIKE ${kw}$')
params['kw'] = '%%%s%%' % like
wsql = (' WHERE ' + ' AND '.join(where)) if where else ''
def _add(name, fn):
try:
size = max(1, min(int(kw.get('page_size') or page_size), 200))
page = max(1, int(kw.get('page') or 1))
except (TypeError, ValueError):
size, page = page_size, 1
total = await sql_scalar('SELECT COUNT(*) AS c FROM `%s`%s' % (table, wsql),
params, module, default=0)
rows = await sql_rows('SELECT * FROM `%s`%s ORDER BY `%s` LIMIT ${lim}$ OFFSET ${off}$'
% (table, wsql, 'id'),
dict(params, lim=size, off=(page - 1) * size), module)
return {'ok': True, 'total': int(total or 0), 'page': page, 'page_size': size,
'data': [_decode(r) for r in rows]}
ok, msg = fn()
except Exception as e: # noqa: BLE001
ok, msg = False, '异常:%s' % e
results.append((name, bool(ok), msg or ''))
async def upsert(key_cols, **kw):
tid = await _tid()
match = {}
for c in key_cols:
if kw.get(c) in (None, ''):
raise PblError('PBL_PARAM_MISSING', '缺少 %s' % c)
match[c] = kw[c]
if tid:
match['tenant_id'] = tid
where = ' AND '.join('`%s` = %s' % (c, _ph('w_' + c)) for c in match)
rows = await sql_rows('SELECT `id` FROM `%s` WHERE %s LIMIT 1' % (table, where),
{'w_' + k: v for k, v in match.items()}, module)
if rows:
pk = (rows[0].get('id') if isinstance(rows[0], dict) else rows[0][0])
r = await update(id=pk, **{k: v for k, v in kw.items() if k in cols})
r['created'] = False
return r
r = await create(**kw)
r['created'] = True
return r
# 1 租户缺失必须抛错fail-closed
def t_tenant_missing():
unbind_context(None)
try:
require_tenant()
return False, '未绑定上下文却取到 tenant_idfail-closed 失效)'
except PblError as e:
if e.code == ErrorCode.TENANT_MISSING:
return True, 'code=%s' % e.code
return False, '错误码不符:%s' % e.code
_add('tenant_missing_fail_closed', t_tenant_missing)
return {'create': create, 'read': read, 'update': update, 'delete': delete,
'list': list_rows, 'upsert': upsert}
# 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)
JSON_COLS_CACHE = {}
def _decode(row):
"""LONGTEXT 存 JSON 的列还原为对象,便于 bricks/前端直接消费。"""
out = {}
for k, v in (row.items() if isinstance(row, dict) else {}):
if isinstance(v, str) and (k.endswith('_json')) and v[:1] in ('{', '['):
# 3 非法 tenant_id 全拒
def t_tenant_invalid():
bad = [None, '', ' ', 123, 'x' * 65, "a'b", 'a;b', 'a--b']
for v in bad:
try:
out[k] = json.loads(v)
build_context(v)
return False, '非法值未被拒绝:%r' % (v,)
except PblError:
continue
except ValueError:
pass
out[k] = v
return out
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],
}
def flag(v):
"""安全布尔DB 可能返回 int/str/bytes禁用 int() 直接抛。"""
if v is None:
return False
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
return str(v).strip().lower() in ('1', 'y', 'yes', 'true', 't', 'on')
def num(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return float(default)
def json_dump(v):
if v is None or isinstance(v, str):
return v
return json.dumps(v, ensure_ascii=False, sort_keys=True)
# ── 本模块契约 ───────────────────────────────────────────────────────────────
async def pbl_common_ping(**kw):
return {'ok': True, 'module': 'pbl_common', 'time': now_str(),
'dbname': dbname(), 'tenant': await tenant_id(required=False)}
async def pbl_common_audit(**kw):
"""审计写入append-only走平台 audit_log无表时降级为日志留痕不阻断主流程"""
action = kw.get('action') or 'unknown'
detail = json_dump(kw.get('detail'))
tid, who = await tenant_id(required=False), await actor_id()
try:
await sql_exec('INSERT INTO `audit_log` (`tenant_id`,`action`,`owner_id`,`detail`,'
'`created_at`) VALUES (${t}$,${a}$,${o}$,${d}$,${c}$)',
{'t': tid, 'a': action, 'o': who, 'd': detail, 'c': now_str()}, 'pbl')
return {'ok': True, 'written': 'audit_log'}
except Exception as exc: # 审计失败不阻断业务,但必须显式回报
return {'ok': True, 'written': 'log_only', 'warning': str(exc)[:200]}
__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',
]

106
pbl_common/audit.py Normal file
View File

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""
pbl_common.audit 审计日志append-only独立于业务表
设计对齐 audit-logging 通用规范 + docs/01-design/data-model.md pbl_audit_log
- append-only只提供 write_audit / query_audit不提供 update/delete
- 审计独立性写审计失败不得影响业务主流程降级为 stderr 记录
- tenant_id 打头trace_id 贯穿一次 Agent 调用链
"""
import datetime
import sys
import traceback
from pbl_common.context import current_context
from pbl_common.serialize import dumps
AUDIT_TABLE = 'pbl_audit_log'
# 允许的审计动作(白名单,未登记动作记为 'other' 但不拒绝)
AUDIT_ACTIONS = (
'create', 'read', 'update', 'delete',
'validate', 'compile', 'fork', 'publish', 'archive',
'tool_invoke', 'tool_deny',
'evidence_collect', 'assess',
'login', 'logout', 'perm_deny',
'seed', 'migrate', 'other',
)
def _now():
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def write_audit(action, resource_type=None, resource_id=None,
tenant_id=None, user_id=None, result='success',
detail=None, trace_id=None, module='pbl_common',
conn=None):
"""
写一条审计记录append-only
返回 True/False内部异常一律吞掉并降级到 stderr
保证审计写失败不阻断业务同时不静默丢失stderr 留痕
"""
ctx = current_context()
row = {
'tenant_id': tenant_id or (ctx.tenant_id if ctx else None),
'action': action if action in AUDIT_ACTIONS else 'other',
'action_raw': action,
'resource_type': resource_type,
'resource_id': str(resource_id) if resource_id is not None else None,
'user_id': user_id or (ctx.user_id if ctx else None),
'role': (ctx.role if ctx else None),
'session_id': (ctx.session_id if ctx else None),
'trace_id': trace_id or (ctx.trace_id if ctx else None),
'result': result,
'detail': dumps(detail) if detail is not None else None,
'created_at': _now(),
}
if not row['tenant_id']:
# 审计也必须有租户;无租户时降级 stderr不抛错打断业务
sys.stderr.write('[pbl_audit] skip: missing tenant_id action=%s\n' % action)
return False
cols = [c for c in row.keys() if c != 'action_raw']
sql = 'INSERT INTO `%s` (%s) VALUES (%s)' % (
AUDIT_TABLE,
', '.join('`%s`' % c for c in cols),
', '.join(['%s'] * len(cols)),
)
params = [row[c] for c in cols]
try:
from pbl_common.dbutil import execute
execute(sql, params, module=module, conn=conn)
return True
except Exception: # noqa: BLE001
sys.stderr.write('[pbl_audit] write failed action=%s tenant=%s\n%s\n'
% (action, row['tenant_id'], traceback.format_exc()))
return False
def query_audit(tenant_id, action=None, resource_type=None, resource_id=None,
limit=100, offset=0, module='pbl_common'):
"""只读查询审计tenant_id 打头,强制分页上限 500"""
from pbl_common.dbutil import query
from pbl_common.tenant import normalize_tenant
tid = normalize_tenant(tenant_id)
where = ['tenant_id = %s']
args = [tid]
if action:
where.append('action = %s')
args.append(action)
if resource_type:
where.append('resource_type = %s')
args.append(resource_type)
if resource_id is not None:
where.append('resource_id = %s')
args.append(str(resource_id))
limit = max(1, min(int(limit or 100), 500))
offset = max(0, int(offset or 0))
sql = ('SELECT * FROM `%s` WHERE %s ORDER BY id DESC LIMIT %%s OFFSET %%s'
% (AUDIT_TABLE, ' AND '.join(where)))
args.extend([limit, offset])
return query(sql, args, module=module, tenant_id=tid)

155
pbl_common/context.py Normal file
View File

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""
pbl_common.context 租户上下文threading.local 绑定fail-closed
设计要点
- TenantContext __slots__ 固定 6 字段防止误挂属性
- threading.local 绑定天然线程隔离异步/多线程下不串租户
- require_tenant() 是所有读写的第一道闸拿不到 tenant_id 直接抛错
绝不回落到默认租户全租户查询
"""
import threading
from pbl_common.errors import (
TenantMissingError,
TenantInvalidError,
ErrorCode,
)
_LOCAL = threading.local()
# tenant_id 合法长度上限varchar(64),与 data-model.md 对齐)
TENANT_ID_MAX_LEN = 64
class TenantContext(object):
"""租户上下文6 字段__slots__ 锁定)"""
__slots__ = ('tenant_id', 'user_id', 'role', 'session_id', 'trace_id', 'app_name')
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
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,
'app_name': self.app_name,
}
def copy(self, **overrides):
data = self.to_dict()
data.update(overrides)
return TenantContext(**data)
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))
def _check_tenant(tenant_id):
"""tenant_id 合法性校验None / 非 str / 空串 / 超长 / 含危险字符 一律拒绝"""
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:
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):
raise TenantInvalidError(
message='bind_context 需要 TenantContext 实例,实际 %s' % type(ctx).__name__,
)
old = getattr(_LOCAL, 'ctx', None)
_LOCAL.ctx = ctx
return old
def unbind_context(old=None):
"""解绑上下文;传入 bind_context 的返回值可还原上一层"""
if old is None:
_LOCAL.ctx = None
else:
_LOCAL.ctx = old
return True
def current_context():
"""取当前线程上下文,未绑定返回 None不抛错供探测用"""
return getattr(_LOCAL, 'ctx', None)
def require_context():
"""取当前线程上下文,未绑定即抛 TenantMissingErrorfail-closed"""
ctx = current_context()
if ctx is None:
raise TenantMissingError(
message='未绑定租户上下文,请先 bind_context(build_context(tenant_id=...))',
detail={'code': ErrorCode.TENANT_MISSING},
)
return ctx
def require_tenant():
"""取当前 tenant_id 字符串,未绑定即抛错(所有读写第一道闸)"""
return require_context().tenant_id
class context_scope(object):
"""with 语法糖:进入绑定、退出还原(异常也还原)"""
def __init__(self, ctx):
self.ctx = ctx
self._old = None
def __enter__(self):
self._old = bind_context(self.ctx)
return self.ctx
def __exit__(self, exc_type, exc_val, exc_tb):
unbind_context(self._old)
return False

240
pbl_common/crud.py Normal file
View File

@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
"""CRUD 工厂按「表定义四段式summary/fields/indexes/codes」生成标准增删改查。
生成物契约tblname / params
create(params) -> {'id': int}
read(id) -> dict | None
update(id, params) -> int(affected)
soft_delete(id) -> int(affected) # is_deleted=1业务表默认软删
delete(id) -> int(affected) # 物理删除,仅治理白名单表允许
list(where, page, size, order_by) -> {'total': int, 'rows': [...]}
get_by_code(code) -> dict | None
所有方法首参/内部一律强制 tenant_id 打头缺失即抛 PblError(E_TENANT_MISSING)
"""
import datetime
from . import db as _db
from .errors import PblError, ErrCode
from .tenant import require_tenant
from .serialize import normalize_code
# 审计/通用列(写入时自动补齐)
_AUTO_COLS = ('create_time', 'update_time', 'create_user', 'update_user', 'is_deleted')
class CrudFactory(object):
"""按表定义生成 CRUD 方法集。
:param table: 表名
:param fields: 字段名列表不含 id用于写入白名单过滤防止越权写系统列
:param codes: {字段: 允许取值集合}对应四段式 codes 替代 ENUM
:param pk: 主键列名默认 id
:param soft_delete:是否启用逻辑删除表含 is_deleted 列时为 True
:param dbname: 库名缺省走 get_module_dbname
:param module: 模块名用于取库名
"""
def __init__(self, table, fields, codes=None, pk='id', soft_delete=True,
dbname=None, module='pbl_common', unique_keys=None):
self.table = table
self.fields = tuple(fields or ())
self.codes = codes or {}
self.pk = pk
self.soft_delete = bool(soft_delete)
self.module = module
self.unique_keys = unique_keys or []
self._dbname = dbname
# ---- 内部工具 ----
@property
def dbname(self):
return self._dbname or _db.get_dbname(self.module)
def _tid(self, tenant_id, params=None):
if tenant_id is None and params:
tenant_id = params.get('tenant_id')
return require_tenant(tenant_id)
def _filter(self, params):
"""写入白名单过滤 + codes 取值校验(替代 ENUM 约束)。"""
out = {}
for k, v in (params or {}).items():
if k in ('id', self.pk):
continue
if self.fields and k not in self.fields and k not in _AUTO_COLS and k != 'tenant_id':
raise PblError(ErrCode.E_PARAM_INVALID,
'字段不在表定义白名单内:%s.%s' % (self.table, k))
if k in self.codes:
allowed = self.codes[k]
if v is not None and normalize_code(v) not in allowed:
raise PblError(ErrCode.E_PARAM_INVALID,
'字段 %s.%s 取值非法:%r(允许:%s'
% (self.table, k, v, sorted(allowed)))
v = normalize_code(v)
if isinstance(v, bool):
v = 1 if v else 0
out[k] = v
return out
def _now(self):
return datetime.datetime.now().replace(microsecond=0)
# ---- C ----
def create(self, params, tenant_id=None, user_id=None):
tid = self._tid(tenant_id, params)
data = self._filter(params)
data['tenant_id'] = tid
now = self._now()
if 'is_deleted' in self.fields or self.soft_delete:
data.setdefault('is_deleted', 0)
if 'create_time' in self.fields:
data.setdefault('create_time', now)
if 'update_time' in self.fields:
data.setdefault('update_time', now)
if user_id and 'create_user' in self.fields:
data.setdefault('create_user', str(user_id)[:32])
if user_id and 'update_user' in self.fields:
data.setdefault('update_user', str(user_id)[:32])
# 唯一键预检(无 FK/无 UNIQUE 兜底时给出可读错误)
for uk in self.unique_keys:
cond = {c: data.get(c) for c in uk if c in data}
if len(cond) == len(uk):
cond['tenant_id'] = tid
if self.soft_delete:
cond['is_deleted'] = 0
if _db.query_one(self.table, cond, tenant_id=tid, dbname=self.dbname):
raise PblError(ErrCode.E_DUPLICATE,
'唯一键冲突:%s %s' % (self.table, list(uk)), {'key': cond})
return _db.insert(self.table, data, tenant_id=tid, dbname=self.dbname)
# ---- R ----
def read(self, pk_value, tenant_id=None, include_deleted=False):
tid = require_tenant(tenant_id)
where = {self.pk: pk_value}
if self.soft_delete and not include_deleted:
where['is_deleted'] = 0
row = _db.query_one(self.table, where, tenant_id=tid, dbname=self.dbname)
if row is None:
raise PblError(ErrCode.E_NOT_FOUND,
'%s[%s=%s] 不存在tenant=%s' % (self.table, self.pk, pk_value, tid))
return row
def get_by_code(self, code, tenant_id=None):
tid = require_tenant(tenant_id)
where = {'code': normalize_code(code)}
if self.soft_delete:
where['is_deleted'] = 0
return _db.query_one(self.table, where, tenant_id=tid, dbname=self.dbname)
def list(self, where=None, page=1, size=20, order_by=None, tenant_id=None,
include_deleted=False, fields=None):
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = {}
for k, v in (where or {}).items():
if k == 'tenant_id' or v is None:
continue
if k in self.codes:
v = normalize_code(v)
cond[k] = v
if self.soft_delete and not include_deleted:
cond.setdefault('is_deleted', 0)
page = max(1, int(page or 1))
size = min(500, max(1, int(size or 20)))
rows = _db.query(self.table, cond, fields=fields,
order_by=order_by or ('%s DESC' % self.pk),
limit=size, offset=(page - 1) * size,
tenant_id=tid, dbname=self.dbname)
total = len(_db.query(self.table, cond, fields=[self.pk],
limit=100000, tenant_id=tid, dbname=self.dbname))
return {'total': total, 'page': page, 'size': size, 'rows': rows or []}
# ---- U ----
def update(self, pk_value, params, tenant_id=None, user_id=None, expect_version=None):
tid = require_tenant(tenant_id if tenant_id is not None else (params or {}).get('tenant_id'))
data = self._filter(params)
data.pop('tenant_id', None)
if not data:
raise PblError(ErrCode.E_PARAM_MISSING, '无可更新字段')
if 'update_time' in self.fields:
data['update_time'] = self._now()
if user_id and 'update_user' in self.fields:
data['update_user'] = str(user_id)[:32]
where = {self.pk: pk_value, 'tenant_id': tid}
if self.soft_delete:
where['is_deleted'] = 0
if expect_version is not None:
# 乐观锁版本不符即拒绝E_VERSION_STALE防止并发覆盖
cur = _db.query_one(self.table, where, tenant_id=tid, dbname=self.dbname)
if cur is None:
raise PblError(ErrCode.E_NOT_FOUND, '%s[%s] 不存在' % (self.table, pk_value))
if int(cur.get('version', 0)) != int(expect_version):
raise PblError(ErrCode.E_VERSION_STALE,
'版本已过期:期望 %s,实际 %s' % (expect_version, cur.get('version')),
{'current': cur.get('version')})
if 'version' in self.fields:
data['version'] = int(cur.get('version', 0)) + 1
return _db.update(self.table, data, where, tenant_id=tid, dbname=self.dbname)
# ---- D ----
def soft_delete(self, pk_value, tenant_id=None, user_id=None):
tid = require_tenant(tenant_id)
if not self.soft_delete:
raise PblError(ErrCode.E_PARAM_INVALID, '%s 无 is_deleted 列,不支持软删' % self.table)
data = {'is_deleted': 1}
if 'update_time' in self.fields:
data['update_time'] = self._now()
if user_id and 'update_user' in self.fields:
data['update_user'] = str(user_id)[:32]
return _db.update(self.table, data, {self.pk: pk_value}, tenant_id=tid, dbname=self.dbname)
def delete(self, pk_value, tenant_id=None):
"""物理删除:默认走软删;确需物理删除请显式 hard=True 且仅限治理白名单表。"""
tid = require_tenant(tenant_id)
if self.soft_delete:
return self.soft_delete(pk_value, tenant_id=tid)
return _db.delete(self.table, {self.pk: pk_value}, tenant_id=tid, dbname=self.dbname)
def exists(self, where, tenant_id=None):
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = {k: v for k, v in (where or {}).items() if k != 'tenant_id' and v is not None}
return _db.query_one(self.table, cond, tenant_id=tid, dbname=self.dbname) is not None
def make_crud(table_def, module='pbl_common', dbname=None):
"""由「四段式表定义」dict 生成 CrudFactory。
table_def 形如
{'summary': '蓝图聚合根',
'fields': [{'name': 'tenant_id', 'type': 'varchar(32)'}, ...],
'indexes': [{'name': 'ix_x', 'cols': ['tenant_id', 'status']}],
'codes': {'status': ['draft', 'published']}}
"""
fields = []
for f in (table_def.get('fields') or []):
name = f.get('name') if isinstance(f, dict) else f
if name:
fields.append(name)
codes = {}
for k, v in (table_def.get('codes') or {}).items():
codes[k] = set(v)
unique_keys = []
for ix in (table_def.get('indexes') or []):
if ix.get('unique'):
cols = ix.get('cols') or []
cols = [c for c in cols if c != 'tenant_id']
if cols:
unique_keys.append(tuple(cols))
return CrudFactory(
table=table_def.get('table') or table_def.get('name'),
fields=fields,
codes=codes,
pk=table_def.get('pk', 'id'),
soft_delete='is_deleted' in fields,
dbname=dbname,
module=module,
unique_keys=unique_keys,
)

234
pbl_common/crud_factory.py Normal file
View File

@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
"""
pbl_common.crud_factory CRUD 工厂统一租户打头 + 写保护 + 审计
pbl_* 模块的表 CRUD 一律由本工厂生成避免每个模块各写一套
tenant_id / 漏审计 / 误写引用基表的风险
用法
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)
"""
import datetime
from pbl_common.errors import (
NotFoundError,
ParamInvalidError,
ErrorCode,
assert_not_write_protected,
)
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
def _now():
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
class CrudBase(object):
"""单表 CRUD 基类tenant_id 强制打头fail-closed"""
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)
# ---------- 内部工具 ----------
def _guard_write(self):
if self.readonly:
raise ParamInvalidError(
code=ErrorCode.WRITE_PROTECTED,
message='%s 为只读(引用模块基表),禁止写入' % self.table,
detail={'table': self.table},
http_status=403,
)
def _encode_json_columns(self, 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])
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,
)
# ---------- 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
# ---------- 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)
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},
)
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
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))
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))
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}
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))
def exists(self, tenant_id, where=None, params=None):
return self.count(tenant_id, where, params) > 0
# ---------- 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)
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)
# ---------- 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 in_transaction(self):
"""返回 transaction 上下文管理器多表原子写pbl_runtime_ext 依赖)"""
return transaction(module=self.module)
def make_crud(table, table_name=None, module='pbl_common', **kwargs):
"""
CRUD 工厂入口
table : 表名第一参数兼容 make_crud('pbl_blueprint') 单参写法
table_name : 可选 table 同义兼容旧签名 make_crud(module, table)
module : 模块名用于 get_module_dbname 与写保护判定
kwargs : 透传 CrudBasepk/readonly/audit/json_columns/auto_timestamp
"""
real_table = table_name or table
if real_table and not table:
real_table = table
return CrudBase(real_table, module=module, **kwargs)

303
pbl_common/db.py Normal file
View File

@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
"""DB 适配层:统一走 ServerEnv().get_module_dbname('模块名') 取库名(禁止硬编码 DBNAME
底层复用平台 sqlorsor.C/U/D/R/I/sqlExe并补充单事务多语句能力供 pbl_runtime_ext 使用
设计要点
* get_dbname(module) 唯一库名入口ServerEnv 未注入时回落环境变量 PBLS_DBNAME再回落 'pbls'
* tx() 上下文管理器保证事件 + 状态 + 广播登记在同一事务内提交/回滚
* 所有查询自动补 tenant_id 条件tenant_first杜绝跨租户读写
"""
import os
import threading
from .errors import PblError, ErrCode
from .tenant import require_tenant
_ENV = None # ServerEnv 实例(由 init.load_pbl_common(env) 注入)
_ENV_LOCK = threading.Lock()
DEFAULT_DBNAME = 'pbls'
def set_env(env):
"""由 pbl_common/init.py 在应用挂载时调用,注入 ServerEnv。"""
global _ENV
with _ENV_LOCK:
_ENV = env
return env
def get_env():
return _ENV
def get_dbname(module_name='pbl_common'):
"""模块 → 库名。优先 ServerEnv.get_module_dbname其次环境变量最后默认库。"""
env = _ENV
if env is not None:
fn = getattr(env, 'get_module_dbname', None)
if callable(fn):
try:
name = fn(module_name)
if name:
return name
except Exception: # noqa: BLE001 —— 映射异常不阻断,回落
pass
return os.environ.get('PBLS_DBNAME') or DEFAULT_DBNAME
def _sor(dbname=None):
"""取 sqlor 句柄(平台标准 APIsor.C/U/D/R/I/sqlExe"""
try:
import sqlor
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'sqlor 不可用:%s' % e)
db = dbname or get_dbname()
factory = getattr(sqlor, 'Sqlor', None) or getattr(sqlor, 'sqlor', None)
if factory is None:
# 部分版本直接暴露模块级函数
return sqlor
try:
return factory(db)
except TypeError:
return factory(dbname=db)
def _sqlor_kwargs(params):
"""sqlor 参数规范化None 值剔除bool → int。"""
out = {}
for k, v in (params or {}).items():
if v is None:
continue
if isinstance(v, bool):
v = 1 if v else 0
out[k] = v
return out
# ---------------------------------------------------------------------------
# 基础读写tenant 强制打头)
# ---------------------------------------------------------------------------
def insert(table, params, tenant_id=None, dbname=None):
"""C —— 新增。params 必须含 tenant_id或显式传入否则拒绝。"""
tid = require_tenant(tenant_id if tenant_id is not None else (params or {}).get('tenant_id'))
data = dict(params or {})
data['tenant_id'] = tid
try:
return _sor(dbname).C(table, _sqlor_kwargs(data))
except PblError:
raise
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'insert %s 失败:%s' % (table, e))
def update(table, params, where, tenant_id=None, dbname=None):
"""U —— 更新。where 自动注入 tenant_id禁止跨租户更新。"""
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = dict(where or {})
cond['tenant_id'] = tid
try:
return _sor(dbname).U(table, _sqlor_kwargs(params), _sqlor_kwargs(cond))
except PblError:
raise
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'update %s 失败:%s' % (table, e))
def delete(table, where, tenant_id=None, dbname=None):
"""D —— 删除(物理删除仅限治理白名单表;业务表请用逻辑删除 is_deleted=1"""
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = dict(where or {})
cond['tenant_id'] = tid
try:
return _sor(dbname).D(table, _sqlor_kwargs(cond))
except PblError:
raise
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'delete %s 失败:%s' % (table, e))
def get(table, where, tenant_id=None, dbname=None):
"""R —— 单条查询。"""
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = dict(where or {})
cond['tenant_id'] = tid
try:
return _sor(dbname).R(table, _sqlor_kwargs(cond))
except PblError:
raise
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'get %s 失败:%s' % (table, e))
def query(table, where=None, fields=None, order_by=None, limit=None, offset=None,
tenant_id=None, dbname=None):
"""I —— 列表查询tenant_id 强制作为首个条件)。"""
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = {'tenant_id': tid}
for k, v in (where or {}).items():
if k == 'tenant_id':
continue
cond[k] = v
kw = _sqlor_kwargs(cond)
if fields:
kw['_fields'] = fields if isinstance(fields, str) else ','.join(fields)
if order_by:
kw['_orderby'] = order_by
if limit is not None:
kw['_limit'] = int(limit)
if offset:
kw['_offset'] = int(offset)
try:
rows = _sor(dbname).I(table, kw)
except PblError:
raise
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'query %s 失败:%s' % (table, e))
return rows or []
def query_one(table, where=None, tenant_id=None, dbname=None):
rows = query(table, where=where, limit=1, tenant_id=tenant_id, dbname=dbname)
return rows[0] if rows else None
def execute(sql, args=None, dbname=None):
"""sqlExe —— 原生 SQLDDL/批量/复杂联查)。调用方必须自行保证 SQL 内含 tenant_id 条件。"""
if 'tenant_id' not in (sql or ''):
raise PblError(ErrCode.E_TENANT_MISSING,
'execute() 的 SQL 必须显式包含 tenant_id 条件(租户强制打头)')
try:
return _sor(dbname).sqlExe(sql, args or ())
except PblError:
raise
except Exception as e: # noqa: BLE001
raise PblError(ErrCode.E_DB_ERROR, 'execute 失败:%s' % e)
# ---------------------------------------------------------------------------
# 单事务pbl_runtime_ext事件 + 状态 + 广播登记 必须同事务)
# ---------------------------------------------------------------------------
class _Tx(object):
"""事务句柄内部缓存语句commit 时按序执行,任一失败整体回滚。"""
def __init__(self, dbname=None):
self.dbname = dbname or get_dbname()
self._ops = [] # [(kind, table, a, b)]
self._conn = None
self.committed = False
self.rolled_back = False
# -- 事务内操作登记(延迟执行,保证原子性由 DB 事务兜底)--
def insert(self, table, params, tenant_id=None):
tid = require_tenant(tenant_id if tenant_id is not None else (params or {}).get('tenant_id'))
data = dict(params or {})
data['tenant_id'] = tid
self._ops.append(('C', table, _sqlor_kwargs(data), None))
return len(self._ops)
def update(self, table, params, where, tenant_id=None):
tid = require_tenant(tenant_id if tenant_id is not None else (where or {}).get('tenant_id'))
cond = dict(where or {})
cond['tenant_id'] = tid
self._ops.append(('U', table, _sqlor_kwargs(params), _sqlor_kwargs(cond)))
return len(self._ops)
def execute(self, sql, args=None):
if 'tenant_id' not in (sql or ''):
raise PblError(ErrCode.E_TENANT_MISSING, '事务内 SQL 必须含 tenant_id 条件')
self._ops.append(('X', sql, args or (), None))
return len(self._ops)
@property
def op_count(self):
return len(self._ops)
# -- 提交 / 回滚 --
def commit(self):
if self.committed or self.rolled_back:
raise PblError(ErrCode.E_TX_FAILED, '事务已结束,不可重复提交')
sor = _sor(self.dbname)
conn = self._acquire_conn(sor)
try:
if conn is not None:
conn.begin()
for kind, a, b, c in self._ops:
if kind == 'C':
sor.C(a, b)
elif kind == 'U':
sor.U(a, b, c)
elif kind == 'X':
sor.sqlExe(a, b)
else:
raise PblError(ErrCode.E_TX_FAILED, '未知事务操作:%s' % kind)
if conn is not None:
conn.commit()
self.committed = True
return len(self._ops)
except PblError:
self._safe_rollback(conn)
raise
except Exception as e: # noqa: BLE001
self._safe_rollback(conn)
raise PblError(ErrCode.E_TX_FAILED, '事务提交失败已回滚:%s' % e)
def rollback(self):
if self.committed:
return False
self._safe_rollback(self._conn)
self.rolled_back = True
self._ops = []
return True
# -- 内部 --
def _acquire_conn(self, sor):
"""尽力取底层连接以启用真实 DB 事务;取不到则退化为「全成功才落库」的延迟执行模型。"""
for attr in ('conn', 'connection', '_conn', '_connection', 'db'):
c = getattr(sor, attr, None)
if c is not None and hasattr(c, 'commit'):
self._conn = c
return c
return None
def _safe_rollback(self, conn):
self.rolled_back = True
self._ops = []
if conn is not None:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
class tx(object):
"""事务上下文管理器。
用法
with tx() as t:
t.insert('pbl_runtime_event', {...})
t.update('pbl_runtime_state', {...}, {...})
# 退出无异常 → 自动 commit抛异常 → 自动 rollback
"""
def __init__(self, dbname=None):
self._t = _Tx(dbname)
def __enter__(self):
return self._t
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self._t.commit()
else:
self._t.rollback()
return False
# 兼容别名(部分模块按 query/execute 直接调用)
R = get
I = query
C = insert
U = update
D = delete

236
pbl_common/dbutil.py Normal file
View File

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""
pbl_common.dbutil DB 适配层mariadb 方言 / sqlor 优先 / 连接池
约定
- 库名一律 ServerEnv().get_module_dbname(module)禁止硬编码 DBNAME
- 方言 mariadb占位符 %s主键 BIGINT AUTO_INCREMENT
- 优先走平台 sqlorsor.C/U/D/R/I/sqlExesqlor 不可用时回落 PyMySQL
- 所有写操作必须显式事务transaction 上下文管理器
"""
import threading
from pbl_common.errors import DbError, ErrorCode
from pbl_common.tenant import normalize_tenant
# 方言常量(与 projects/pbls/env/test.json ddl.dialect 同值)
DIALECT = 'mariadb'
PLACEHOLDER = '%s'
_POOL_LOCK = threading.RLock()
_POOLS = {} # dbname -> pool
_ENV_CACHE = {} # 缓存 ServerEnv 取到的库名
def _server_env():
"""取平台 ServerEnv 单例(不可用时返回 None走回落分支"""
try:
from ahserver.serverenv import ServerEnv
return ServerEnv()
except Exception: # noqa: BLE001
return None
def get_dbname(module='pbl_common'):
"""
取模块对应库名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
def _db_conf(dbname):
"""从 ServerEnv / conf 读取连接参数"""
env = _server_env()
conf = {}
if env is not None:
getter = getattr(env, 'get_database', None) or getattr(env, 'database', 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
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:
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):
try:
if exc_type is None:
self.conn.commit()
else:
self.conn.rollback()
finally:
try:
self.conn.close()
except Exception: # noqa: BLE001
pass
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)

186
pbl_common/errors.py Normal file
View File

@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""
pbl_common.errors 统一错误码与异常类型fail-closed
错误码格式PBL-{}-{4位序号}
所有对外接口只抛 PblError 子类禁止裸 Exception 穿透到 dspy
"""
class ErrorCode(object):
"""错误码常量表(与 docs/01-design/agent-tool-contract.md 对齐)"""
# 租户域
TENANT_MISSING = 'PBL-TENANT-0001'
TENANT_INVALID = 'PBL-TENANT-0002'
TENANT_MISMATCH = 'PBL-TENANT-0003'
# 参数域
PARAM_MISSING = 'PBL-PARAM-0001'
PARAM_INVALID = 'PBL-PARAM-0002'
PARAM_CODE_UNREGISTERED = 'PBL-PARAM-0003'
# 数据域
NOT_FOUND = 'PBL-DATA-0001'
CONFLICT = 'PBL-DATA-0002'
DUPLICATE = 'PBL-DATA-0003'
# 权限域
FORBIDDEN = 'PBL-AUTH-0001'
WRITE_PROTECTED = 'PBL-AUTH-0002'
TOOL_DISABLED = 'PBL-AUTH-0003'
# 校验域
VALIDATION_FAILED = 'PBL-VALID-0001'
QUALITY_INSUFFICIENT = 'PBL-VALID-0002'
# 编译域
COMPILE_FAILED = 'PBL-COMP-0001'
COMPILE_INCOMPLETE = 'PBL-COMP-0002'
# 运行时域
RUNTIME_EVENT_FAILED = 'PBL-RUN-0001'
RUNTIME_STATE_ILLEGAL = 'PBL-RUN-0002'
# 系统域
DB_ERROR = 'PBL-SYS-0001'
INTERNAL = 'PBL-SYS-0009'
ERR_TENANT_MISSING = ErrorCode.TENANT_MISSING
ERR_TENANT_INVALID = ErrorCode.TENANT_INVALID
ERR_PARAM_INVALID = ErrorCode.PARAM_INVALID
ERR_NOT_FOUND = ErrorCode.NOT_FOUND
ERR_CONFLICT = ErrorCode.CONFLICT
ERR_FORBIDDEN = ErrorCode.FORBIDDEN
ERR_TOOL_DISABLED = ErrorCode.TOOL_DISABLED
ERR_WRITE_PROTECTED = ErrorCode.WRITE_PROTECTED
# HTTP 状态映射dspy 层据此返回)
CODE_TO_HTTP = {
ErrorCode.TENANT_MISSING: 400,
ErrorCode.TENANT_INVALID: 400,
ErrorCode.TENANT_MISMATCH: 403,
ErrorCode.PARAM_MISSING: 400,
ErrorCode.PARAM_INVALID: 400,
ErrorCode.PARAM_CODE_UNREGISTERED: 400,
ErrorCode.NOT_FOUND: 404,
ErrorCode.CONFLICT: 409,
ErrorCode.DUPLICATE: 409,
ErrorCode.FORBIDDEN: 403,
ErrorCode.WRITE_PROTECTED: 403,
ErrorCode.TOOL_DISABLED: 403,
ErrorCode.VALIDATION_FAILED: 422,
ErrorCode.QUALITY_INSUFFICIENT: 422,
ErrorCode.COMPILE_FAILED: 422,
ErrorCode.COMPILE_INCOMPLETE: 422,
ErrorCode.RUNTIME_EVENT_FAILED: 500,
ErrorCode.RUNTIME_STATE_ILLEGAL: 409,
ErrorCode.DB_ERROR: 500,
ErrorCode.INTERNAL: 500,
}
class PblError(Exception):
"""PBL 统一业务异常基类"""
default_code = ErrorCode.INTERNAL
default_message = '内部错误'
def __init__(self, message=None, code=None, detail=None, http_status=None):
self.code = code or self.default_code
self.message = message or self.default_message
self.detail = detail or {}
self.http_status = http_status or CODE_TO_HTTP.get(self.code, 500)
super(PblError, self).__init__('%s: %s' % (self.code, self.message))
def to_dict(self):
"""序列化为对外 JSON 结构dspy 层直接返回)"""
out = {
'ok': False,
'error': {
'code': self.code,
'message': self.message,
'http_status': self.http_status,
},
}
if self.detail:
out['error']['detail'] = self.detail
return out
def __repr__(self):
return 'PblError(code=%r, message=%r)' % (self.code, self.message)
class TenantMissingError(PblError):
default_code = ErrorCode.TENANT_MISSING
default_message = '缺少 tenant_id所有 PBL 读写必须 tenant_id 打头)'
class TenantInvalidError(PblError):
default_code = ErrorCode.TENANT_INVALID
default_message = 'tenant_id 非法'
class ParamInvalidError(PblError):
default_code = ErrorCode.PARAM_INVALID
default_message = '参数非法'
class NotFoundError(PblError):
default_code = ErrorCode.NOT_FOUND
default_message = '记录不存在'
class ConflictError(PblError):
default_code = ErrorCode.CONFLICT
default_message = '数据冲突'
class ForbiddenError(PblError):
default_code = ErrorCode.FORBIDDEN
default_message = '无权限'
class WriteProtectedError(PblError):
"""引用模块写保护违规rbac/world/scene/entity/scense/scense_runtime/script_engine"""
default_code = ErrorCode.WRITE_PROTECTED
default_message = '目标为写保护引用模块基表,禁止写入(扩展请走 pbl_*_ext'
class ToolDisabledError(PblError):
"""Agent 工具 fail-closed未启用工具一律拒绝"""
default_code = ErrorCode.TOOL_DISABLED
default_message = 'Agent 工具未启用fail-closed 拒绝执行'
class ValidationFailedError(PblError):
default_code = ErrorCode.VALIDATION_FAILED
default_message = '蓝图校验未通过'
class CompileFailedError(PblError):
default_code = ErrorCode.COMPILE_FAILED
default_message = '蓝图编译失败'
class DbError(PblError):
default_code = ErrorCode.DB_ERROR
default_message = '数据库操作失败'
# 写保护模块清单(与 projects/pbls/env/test.json write_protected_modules 同值)
WRITE_PROTECTED_MODULES = (
'rbac', 'world', 'scene', 'entity',
'scense', 'scense_runtime', 'script_engine',
)
def assert_not_write_protected(module_name, table_name=None):
"""写保护断言:命中引用模块即抛 WriteProtectedError"""
if module_name in WRITE_PROTECTED_MODULES:
raise WriteProtectedError(
message='模块 %s 为写保护引用模块,禁止写入' % module_name,
detail={'module': module_name, 'table': table_name},
)
return True

View File

@ -1,21 +1,113 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`load_pbl_common()` —— pbl_common 模块唯一挂载入口。
注册三处同步之 env.<契约名> = <契约名> 定义在 api.py 导出在 __init__.py
"""
from ahserver.serverenv import ServerEnv
pbl_common.init 模块挂载入口load_pbl_common
from pbl_common.api import (
pbl_common_ping,
pbl_common_audit,
挂载职责module-development-spec
1. 幂等建公共表pbl_audit_log / pbl_seed_record
2. 注册 API 契约到 ServerEnv供其它 pbl_* 模块取用
3. 注册 RBAC 权限路径 rbac 已挂载
4. 建表失败不阻断启动降级 stderr保证应用可起界面可访问
"""
import sys
from pbl_common import api as pbl_common_api
from pbl_common.tables import ensure_tables, TABLES
MODULE_NAME = 'pbl_common'
# 对外暴露的契约接口名(其它模块通过 ServerEnv().pbl_common.xxx 调用)
CONTRACT_FUNCS = (
'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',
'get_dbname', 'query', 'query_one', 'execute', 'insert', 'transaction',
'make_crud', 'write_audit', 'query_audit',
'dumps', 'loads', 'to_jsonable', 'parse_json_column',
'PblError', 'ErrorCode', 'assert_not_write_protected',
)
# RBAC 权限路径(公共内核只读接口)
RBAC_PATHS = (
'/api/pbl_common_health.dspy',
'/api/pbl_common_self_check.dspy',
)
def load_pbl_common():
env = ServerEnv()
env.pbl_common_ping = pbl_common_ping
env.pbl_common_audit = pbl_common_audit
def _register_contract(env):
"""把契约接口挂到 ServerEnv供其它 pbl_* 模块统一取用"""
contract = {}
for name in CONTRACT_FUNCS:
fn = getattr(pbl_common_api, name, None)
if fn is not None:
contract[name] = fn
try:
env.pbl_common = contract
except Exception: # noqa: BLE001
# ServerEnv 可能是 __slots__ 对象,退化为字典挂载
try:
setattr(env, 'modules', getattr(env, 'modules', {}))
env.modules[MODULE_NAME] = contract
except Exception: # noqa: BLE001
pass
return contract
return 'pbl_common'
def _register_rbac(env):
"""注册 RBAC 权限rbac 未挂载时静默跳过)"""
registered = []
try:
perm = getattr(env, 'register_perm', None) or getattr(env, 'add_perm', None)
if callable(perm):
for path in RBAC_PATHS:
try:
perm('logined', path)
registered.append(path)
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001
pass
return registered
def load_pbl_common(env=None):
"""
挂载 pbl_common应用 init() 中第一个业务模块
env None 时自取 ServerEnv()兼容单模块测试
返回 {'module','tables','contract','rbac','ok'}
"""
if env is None:
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
except Exception: # noqa: BLE001
env = None
result = {
'module': MODULE_NAME,
'tables': [],
'contract': [],
'rbac': [],
'ok': True,
}
# 1) 幂等建表(失败降级,不阻断启动)
try:
result['tables'] = ensure_tables(module=MODULE_NAME)
except Exception as e: # noqa: BLE001
result['ok'] = False
result['error'] = 'ensure_tables 失败:%s' % e
sys.stderr.write('[pbl_common] ensure_tables failed: %s\n' % e)
# 2) 注册契约
if env is not None:
contract = _register_contract(env)
result['contract'] = sorted(contract.keys())
# 3) 注册 RBAC
result['rbac'] = _register_rbac(env)
sys.stdout.write('[pbl_common] loaded tables=%s contract=%d\n'
% (result['tables'] or sorted(TABLES.keys()), len(result['contract'])))
return result

29
pbl_common/self_check.py Normal file
View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""
pbl_common.self_check 公共内核自检入口可独立运行
运行方式
python -m pbl_common.self_check
python modules/pbl_common/pbl_common/self_check.py
输出末行 QC / CI grep
SELF_CHECK pbl_common: PASS 14/14
退出码0 = PASS1 = FAIL
"""
import sys
from pbl_common.api import self_check as _self_check
def main(argv=None):
res = _self_check()
for d in res['details']:
print('[%s] %-28s %s' % ('PASS' if d['ok'] else 'FAIL', d['name'], d['msg']))
print('SELF_CHECK pbl_common: %s %d/%d'
% ('PASS' if res['ok'] else 'FAIL', res['passed'], res['total']))
return 0 if res['ok'] else 1
if __name__ == '__main__':
sys.exit(main())

100
pbl_common/serialize.py Normal file
View File

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""
pbl_common.serialize JSON 序列化datetime/Decimal/bytes/set 安全转换
dspy 层返回 JSON 前统一过 to_jsonable避免 datetime 直接 json.dumps 抛错
"""
import datetime
import decimal
import json
DATETIME_FMT = '%Y-%m-%d %H:%M:%S'
DATE_FMT = '%Y-%m-%d'
TIME_FMT = '%H:%M:%S'
def datetime_to_str(v):
"""datetime/date/time → 字符串mariadb DATETIME 无时区,按本地格式输出)"""
if isinstance(v, datetime.datetime):
return v.strftime(DATETIME_FMT)
if isinstance(v, datetime.date):
return v.strftime(DATE_FMT)
if isinstance(v, datetime.time):
return v.strftime(TIME_FMT)
return v
def to_jsonable(obj):
"""递归转换为可 JSON 序列化结构"""
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
return datetime_to_str(obj)
if isinstance(obj, decimal.Decimal):
# 整数型 Decimal 转 int避免前端拿到 "3.00" 字符串
if obj == obj.to_integral_value():
return int(obj)
return float(obj)
if isinstance(obj, (bytes, bytearray)):
try:
return obj.decode('utf-8')
except UnicodeDecodeError:
import base64
return base64.b64encode(bytes(obj)).decode('ascii')
if isinstance(obj, dict):
return {str(k): to_jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [to_jsonable(v) for v in obj]
if isinstance(obj, (set, frozenset)):
return [to_jsonable(v) for v in sorted(obj, key=lambda x: str(x))]
# 兜底:有 to_dict / __dict__ 的对象
if hasattr(obj, 'to_dict'):
try:
return to_jsonable(obj.to_dict())
except Exception: # noqa: BLE001
pass
if hasattr(obj, '__slots__'):
return {s: to_jsonable(getattr(obj, s, None)) for s in obj.__slots__}
if hasattr(obj, '__dict__'):
return {k: to_jsonable(v) for k, v in vars(obj).items() if not k.startswith('_')}
return str(obj)
def dumps(obj, ensure_ascii=False, indent=None, sort_keys=False):
"""安全 json.dumps先 to_jsonable"""
return json.dumps(
to_jsonable(obj),
ensure_ascii=ensure_ascii,
indent=indent,
sort_keys=sort_keys,
default=str,
)
def loads(text, default=None):
"""安全 json.loads空/非法返回 default不抛错供容错读取配置"""
if text is None:
return default
if isinstance(text, (dict, list)):
return text
if isinstance(text, (bytes, bytearray)):
try:
text = text.decode('utf-8')
except UnicodeDecodeError:
return default
text = str(text).strip()
if not text:
return default
try:
return json.loads(text)
except (ValueError, TypeError):
return default
def parse_json_column(value, default=None):
"""
解析 DB 里的 JSON 文本列data-model.md payload/change_delta
longtext JSON空值/非法值返回 default绝不抛错中断主流程
"""
return loads(value, default if default is not None else {})

146
pbl_common/tables.py Normal file
View File

@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""
pbl_common.tables 公共表 DDLmariadb 方言
契约docs/01-design/data-model.md + projects/pbls/env/test.json ddl
- 方言 mariadb主键 `id BIGINT NOT NULL AUTO_INCREMENT`
- 禁止 FOREIGN KEY / REFERENCES / ENUM / TIMESTAMP
- 时间列一律 DATETIME
- 每表首列必须 tenant_id varchar(64) NOT NULL且索引以 tenant_id 打头
- 编码列 varchar(32)
- ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
本模块只声明 pbl_common 自有表审计 + 幂等种子记录
业务表 DDL 在各模块 tables.py apps/pbls/scripts/ddl/pbls_tables.sql
"""
from pbl_common.errors import DbError
DIALECT = 'mariadb'
# 禁用元素ensure_tables 前自检,命中即抛错,防方言漂移)
FORBIDDEN_TOKENS = ('FOREIGN KEY', 'REFERENCES ', 'ENUM(', 'TIMESTAMP', 'BIGSERIAL', 'SERIAL', 'nextval')
TABLES = {
'pbl_audit_log': {
'comment': 'PBL 审计日志append-only审计独立性',
'columns': [
('tenant_id', "varchar(64) NOT NULL COMMENT '租户ID强制打头'"),
('action', "varchar(32) NOT NULL COMMENT '动作(白名单 AUDIT_ACTIONS'"),
('action_raw', "varchar(64) DEFAULT NULL COMMENT '原始动作名(未登记动作留痕)'"),
('resource_type', "varchar(64) DEFAULT NULL COMMENT '资源类型(表名)'"),
('resource_id', "varchar(64) DEFAULT NULL COMMENT '资源主键'"),
('user_id', "varchar(64) DEFAULT NULL COMMENT '操作人'"),
('role', "varchar(64) DEFAULT NULL COMMENT '操作人角色'"),
('session_id', "varchar(128) DEFAULT NULL COMMENT '会话ID'"),
('trace_id', "varchar(64) DEFAULT NULL COMMENT '调用链追踪ID'"),
('result', "varchar(16) NOT NULL DEFAULT 'success' COMMENT '结果 success/fail/deny'"),
('detail', "longtext DEFAULT NULL COMMENT '明细 JSON'"),
('created_at', "datetime NOT NULL COMMENT '创建时间'"),
],
'indexes': [
('PRIMARY KEY', '(`id`)'),
('KEY `idx_audit_tenant_time`', '(`tenant_id`,`created_at`)'),
('KEY `idx_audit_tenant_res`', '(`tenant_id`,`resource_type`,`resource_id`)'),
('KEY `idx_audit_trace`', '(`tenant_id`,`trace_id`)'),
],
},
'pbl_seed_record': {
'comment': 'PBL 幂等种子注入记录appcodes/模板/治理种子防重)',
'columns': [
('tenant_id', "varchar(64) NOT NULL COMMENT '租户ID* 表示全局种子)'"),
('seed_key', "varchar(128) NOT NULL COMMENT '种子键(模块:组:项)'"),
('seed_group', "varchar(64) DEFAULT NULL COMMENT '种子分组'"),
('module', "varchar(64) NOT NULL COMMENT '所属模块'"),
('payload', "longtext DEFAULT NULL COMMENT '种子内容 JSON'"),
('checksum', "varchar(64) DEFAULT NULL COMMENT '内容校验和(变更检测)'"),
('version', "int NOT NULL DEFAULT 1 COMMENT '注入版本'"),
('status', "varchar(16) NOT NULL DEFAULT 'applied' COMMENT 'applied/skipped/failed'"),
('created_at', "datetime NOT NULL COMMENT '创建时间'"),
('updated_at', "datetime NOT NULL COMMENT '更新时间'"),
],
'indexes': [
('PRIMARY KEY', '(`id`)'),
('UNIQUE KEY `uk_seed_tenant_key`', '(`tenant_id`,`seed_key`)'),
('KEY `idx_seed_module`', '(`tenant_id`,`module`)'),
],
},
}
def _assert_dialect(ddl_text, table=None):
"""方言自检:命中禁用 token 即抛错mariadb 契约)"""
up = ddl_text.upper()
for token in FORBIDDEN_TOKENS:
if token.upper() in up:
raise DbError(
message='DDL 含禁用元素 %r(方言必须为 %s,表=%s' % (token, DIALECT, table),
detail={'token': token, 'table': table, 'dialect': DIALECT},
)
return True
def ddl_of(table_name):
"""生成单表 CREATE TABLE 语句mariadb 方言IF NOT EXISTS 幂等)"""
spec = TABLES.get(table_name)
if not spec:
raise DbError(message='未登记的表:%s' % table_name, detail={'table': table_name})
cols = spec['columns']
if not cols or cols[0][0] != 'tenant_id':
raise DbError(
message='%s 首列必须为 tenant_id实际 %s' % (table_name, cols[0][0] if cols else None),
detail={'table': table_name},
)
lines = ["CREATE TABLE IF NOT EXISTS `%s` (" % table_name]
lines.append(" `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',")
for name, decl in cols:
lines.append(" `%s` %s," % (name, decl))
for idx_name, idx_decl in spec['indexes']:
lines.append(" %s %s," % (idx_name, idx_decl))
# 去掉最后一行逗号
lines[-1] = lines[-1].rstrip(',')
lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s';"
% spec.get('comment', table_name))
ddl = '\n'.join(lines)
_assert_dialect(ddl, table_name)
return ddl
def all_ddl():
"""生成全部公共表 DDL拼接文本供 build.sh / apply_ddl.sh 使用)"""
return '\n\n'.join(ddl_of(t) for t in sorted(TABLES.keys()))
def ensure_tables(conn=None, module='pbl_common'):
"""
幂等建表CREATE TABLE IF NOT EXISTS
返回已确保的表名列表conn None 时自建连接
"""
from pbl_common.dbutil import execute, get_conn
own = conn is None
conn = conn or get_conn(module=module)
created = []
try:
for table in sorted(TABLES.keys()):
execute(ddl_of(table), conn=conn)
created.append(table)
if own:
try:
conn.commit()
except Exception: # noqa: BLE001
pass
finally:
if own:
try:
conn.close()
except Exception: # noqa: BLE001
pass
return created
def table_names():
"""公共表名清单"""
return sorted(TABLES.keys())

117
pbl_common/tenant.py Normal file
View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""
pbl_common.tenant 租户隔离工具所有 SQL tenant_id 打头约束
铁律
- 任何 WHERE 条件必须以 tenant_id 打头tenant_scope 负责拼装并断言
- 任何 INSERT 必须显式带 tenant_idwith_tenant 负责注入并断言
- 跨租户访问一律拒绝不提供忽略租户开关
"""
from pbl_common.context import require_tenant, _check_tenant
from pbl_common.errors import (
TenantMissingError,
TenantInvalidError,
ErrorCode,
)
def normalize_tenant(tenant_id):
"""规范化 tenant_idNone 时回落到当前上下文),非法即抛错"""
if tenant_id is None:
return require_tenant()
return _check_tenant(tenant_id)
def assert_tenant(tenant_id, expected=None):
"""
断言 tenant_id 合法若给出 expected 则必须相等防跨租户越权
返回规范化后的 tenant_id
"""
tid = normalize_tenant(tenant_id)
if expected is not None:
exp = _check_tenant(expected)
if tid != exp:
raise TenantInvalidError(
code=ErrorCode.TENANT_MISMATCH,
message='租户不匹配:请求 %s,上下文 %s' % (tid, exp),
detail={'request_tenant': tid, 'context_tenant': exp},
http_status=403,
)
return tid
def tenant_scope(tenant_id=None, extra_where=None, params=None):
"""
生成tenant_id 打头 WHERE 片段与参数列表
返回 (where_sql, param_list)
where_sql 形如 "tenant_id = %s AND status = %s"
param_list 形如 ['t001', 'draft']
extra_where 必须是已带占位符的字符串params 为对应参数序列
tenant_id 永远排在第一位契约tenant_id 强制打头
"""
tid = normalize_tenant(tenant_id)
where = 'tenant_id = %s'
args = [tid]
if extra_where:
ew = str(extra_where).strip()
if ew:
# 禁止调用方在 extra_where 里再塞 tenant_id避免重复/绕过)
low = ew.lower()
if low.startswith('tenant_id'):
raise TenantInvalidError(
message='extra_where 不得以 tenant_id 打头(由 tenant_scope 统一注入)',
detail={'extra_where': ew},
)
where = '%s AND (%s)' % (where, ew)
if params:
if isinstance(params, (list, tuple)):
args.extend(list(params))
else:
args.append(params)
return where, args
def with_tenant(row, tenant_id=None):
"""
为待插入的 dict 注入 tenant_id已存在则校验一致返回新 dict
用于所有 INSERT确保 tenant_id 不缺失不被调用方伪造成其它租户
"""
if not isinstance(row, dict):
raise TenantInvalidError(
message='with_tenant 需要 dict实际 %s' % type(row).__name__,
)
tid = normalize_tenant(tenant_id)
out = dict(row)
existing = out.get('tenant_id')
if existing is not None:
if _check_tenant(existing) != tid:
raise TenantInvalidError(
code=ErrorCode.TENANT_MISMATCH,
message='写入 tenant_id 与上下文不一致',
detail={'row_tenant': existing, 'context_tenant': tid},
http_status=403,
)
out['tenant_id'] = tid
return out
def check_tenant_column(table_name, columns):
"""
建表期校验表必须含 tenant_id 列且为首列data-model.md 契约
columns 为列名有序序列返回 True 或抛 TenantMissingError
"""
cols = list(columns or [])
if 'tenant_id' not in cols:
raise TenantMissingError(
message='%s 缺少 tenant_id 列(所有 PBL 表必须 tenant_id 打头)' % table_name,
detail={'table': table_name, 'columns': cols},
)
if cols and cols[0] != 'tenant_id':
raise TenantMissingError(
message='%s 的 tenant_id 未置于首列(实际首列 %s' % (table_name, cols[0]),
detail={'table': table_name, 'first_column': cols[0]},
)
return True

View File

@ -1,13 +1 @@
[project]
name = "pbl_common"
version = "0.1.0"
description = "PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)"
requires-python = ">=3.9"
dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["pbl_common"]
283Bname/version/description/requires-python>=3.7/packages=[pbl_common]