develop(pbls): QC 退回整改——补齐 self_check/真实可编译代码/config processors/requirements 依赖
This commit is contained in:
parent
99f8b4d780
commit
d5638e470d
@ -1,2 +1 @@
|
|||||||
# pbl_common
|
契约说明(1,824B):1 表 + 导出清单 + C1 落地(_assert_tenant_first/insert 覆盖/update 剥离) + C2 落地(4 条 fail-closed) + tx 单事务用法示例(供 T13) + 挂载方式
|
||||||
|
|
||||||
@ -1,19 +1,93 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_common —— PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)
|
|
||||||
|
|
||||||
注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。
|
|
||||||
"""
|
"""
|
||||||
from pbl_common.init import load_pbl_common
|
pbl_common —— PBL 公共内核(租户上下文 / DB 适配 / 错误码 / 审计 / CRUD 工厂)
|
||||||
from pbl_common.api import (
|
|
||||||
pbl_common_ping,
|
|
||||||
pbl_common_audit,
|
|
||||||
|
|
||||||
|
所有 pbl_* 模块的依赖底座,必须在 app/pbls.py 的 init() 中最先 load。
|
||||||
|
铁律:
|
||||||
|
- 所有读写 tenant_id 强制打头,缺失即抛 PBL-TENANT-0001(fail-closed)
|
||||||
|
- 取库名一律 ServerEnv().get_module_dbname('模块名'),禁止硬编码 DBNAME
|
||||||
|
- DB 方言 mariadb(BIGINT 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__ = [
|
__version__ = '1.0.0'
|
||||||
'load_pbl_common',
|
|
||||||
'pbl_common_ping',
|
|
||||||
'pbl_common_audit',
|
|
||||||
|
|
||||||
|
__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__',
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,288 +1,265 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
pbl_common.api —— 公共内核对内契约接口(供其它 pbl_* 模块调用)
|
||||||
import time
|
|
||||||
|
|
||||||
from ahserver.serverenv import ServerEnv
|
只暴露稳定契约,不暴露内部实现。所有函数 tenant_id 打头。
|
||||||
|
"""
|
||||||
|
|
||||||
DEFAULT_MODULE = 'pbl'
|
from pbl_common.context import (
|
||||||
TENANT_FALLBACK = 'default'
|
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):
|
def health():
|
||||||
"""统一业务错误:dspy 侧返回 {ok:False, error_code, error_msg},不抛裸异常。"""
|
"""健康检查(不触库,供 /healthz 与部署冒烟)"""
|
||||||
|
return {
|
||||||
def __init__(self, code, msg):
|
'ok': True,
|
||||||
Exception.__init__(self, '%s: %s' % (code, msg))
|
'module': 'pbl_common',
|
||||||
self.code, self.msg = code, msg
|
'dialect': DIALECT,
|
||||||
|
'tables': sorted(TABLES.keys()),
|
||||||
|
'write_protected_modules': list(WRITE_PROTECTED_MODULES),
|
||||||
|
'audit_actions': len(AUDIT_ACTIONS),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def now_str():
|
def self_check():
|
||||||
return time.strftime('%Y-%m-%d %H:%M:%S')
|
"""
|
||||||
|
公共内核自检(不依赖 DB,纯逻辑断言)。
|
||||||
|
返回 {'ok': bool, 'passed': int, 'total': int, 'details': [...]}
|
||||||
|
末行由调用方打印:SELF_CHECK pbl_common: PASS n/n
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def _add(name, fn):
|
||||||
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 ''
|
|
||||||
try:
|
try:
|
||||||
size = max(1, min(int(kw.get('page_size') or page_size), 200))
|
ok, msg = fn()
|
||||||
page = max(1, int(kw.get('page') or 1))
|
except Exception as e: # noqa: BLE001
|
||||||
except (TypeError, ValueError):
|
ok, msg = False, '异常:%s' % e
|
||||||
size, page = page_size, 1
|
results.append((name, bool(ok), msg or ''))
|
||||||
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]}
|
|
||||||
|
|
||||||
async def upsert(key_cols, **kw):
|
# 1 租户缺失必须抛错(fail-closed)
|
||||||
tid = await _tid()
|
def t_tenant_missing():
|
||||||
match = {}
|
unbind_context(None)
|
||||||
for c in key_cols:
|
try:
|
||||||
if kw.get(c) in (None, ''):
|
require_tenant()
|
||||||
raise PblError('PBL_PARAM_MISSING', '缺少 %s' % c)
|
return False, '未绑定上下文却取到 tenant_id(fail-closed 失效)'
|
||||||
match[c] = kw[c]
|
except PblError as e:
|
||||||
if tid:
|
if e.code == ErrorCode.TENANT_MISSING:
|
||||||
match['tenant_id'] = tid
|
return True, 'code=%s' % e.code
|
||||||
where = ' AND '.join('`%s` = %s' % (c, _ph('w_' + c)) for c in match)
|
return False, '错误码不符:%s' % e.code
|
||||||
rows = await sql_rows('SELECT `id` FROM `%s` WHERE %s LIMIT 1' % (table, where),
|
_add('tenant_missing_fail_closed', t_tenant_missing)
|
||||||
{'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
|
|
||||||
|
|
||||||
return {'create': create, 'read': read, 'update': update, 'delete': delete,
|
# 2 上下文绑定/还原
|
||||||
'list': list_rows, 'upsert': upsert}
|
def t_bind_unbind():
|
||||||
|
ctx = build_context('t_demo', user_id='u1', role='teacher', trace_id='tr1')
|
||||||
|
old = bind_context(ctx)
|
||||||
|
ok1 = require_tenant() == 't_demo'
|
||||||
|
unbind_context(old)
|
||||||
|
ok2 = current_context() is None
|
||||||
|
return (ok1 and ok2), 'bind=%s unbind=%s' % (ok1, ok2)
|
||||||
|
_add('context_bind_unbind', t_bind_unbind)
|
||||||
|
|
||||||
|
# 3 非法 tenant_id 全拒
|
||||||
JSON_COLS_CACHE = {}
|
def t_tenant_invalid():
|
||||||
|
bad = [None, '', ' ', 123, 'x' * 65, "a'b", 'a;b', 'a--b']
|
||||||
|
for v in bad:
|
||||||
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 ('{', '['):
|
|
||||||
try:
|
try:
|
||||||
out[k] = json.loads(v)
|
build_context(v)
|
||||||
|
return False, '非法值未被拒绝:%r' % (v,)
|
||||||
|
except PblError:
|
||||||
continue
|
continue
|
||||||
except ValueError:
|
return True, '%d 个非法值全部拒绝' % len(bad)
|
||||||
pass
|
_add('tenant_invalid_rejected', t_tenant_invalid)
|
||||||
out[k] = v
|
|
||||||
return out
|
# 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):
|
__all__ = [
|
||||||
"""安全布尔:DB 可能返回 int/str/bytes,禁用 int() 直接抛。"""
|
'health', 'self_check',
|
||||||
if v is None:
|
'build_context', 'bind_context', 'unbind_context', 'current_context',
|
||||||
return False
|
'require_tenant', 'require_context', 'context_scope',
|
||||||
if isinstance(v, bool):
|
'normalize_tenant', 'assert_tenant', 'tenant_scope', 'with_tenant', 'check_tenant_column',
|
||||||
return v
|
'get_dbname', 'query', 'query_one', 'execute', 'insert', 'transaction', 'sqlExe', 'DIALECT',
|
||||||
if isinstance(v, (int, float)):
|
'make_crud', 'CrudBase',
|
||||||
return v != 0
|
'write_audit', 'query_audit', 'AUDIT_ACTIONS',
|
||||||
return str(v).strip().lower() in ('1', 'y', 'yes', 'true', 't', 'on')
|
'to_jsonable', 'dumps', 'loads', 'parse_json_column',
|
||||||
|
'PblError', 'ErrorCode', 'assert_not_write_protected', 'WRITE_PROTECTED_MODULES',
|
||||||
|
'ensure_tables', 'all_ddl', 'ddl_of', 'TABLES',
|
||||||
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]}
|
|
||||||
|
|||||||
106
pbl_common/audit.py
Normal file
106
pbl_common/audit.py
Normal 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
155
pbl_common/context.py
Normal 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():
|
||||||
|
"""取当前线程上下文,未绑定即抛 TenantMissingError(fail-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
240
pbl_common/crud.py
Normal 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
234
pbl_common/crud_factory.py
Normal 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 : 透传 CrudBase(pk/readonly/audit/json_columns/auto_timestamp)
|
||||||
|
"""
|
||||||
|
real_table = table_name or table
|
||||||
|
if real_table and not table:
|
||||||
|
real_table = table
|
||||||
|
return CrudBase(real_table, module=module, **kwargs)
|
||||||
303
pbl_common/db.py
Normal file
303
pbl_common/db.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""DB 适配层:统一走 ServerEnv().get_module_dbname('模块名') 取库名(禁止硬编码 DBNAME),
|
||||||
|
底层复用平台 sqlor(sor.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 句柄(平台标准 API:sor.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 —— 原生 SQL(DDL/批量/复杂联查)。调用方必须自行保证 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
236
pbl_common/dbutil.py
Normal 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
|
||||||
|
- 优先走平台 sqlor(sor.C/U/D/R/I/sqlExe);sqlor 不可用时回落 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
186
pbl_common/errors.py
Normal 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
|
||||||
@ -1,21 +1,113 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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 (
|
挂载职责(module-development-spec):
|
||||||
pbl_common_ping,
|
1. 幂等建公共表(pbl_audit_log / pbl_seed_record)
|
||||||
pbl_common_audit,
|
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():
|
def _register_contract(env):
|
||||||
env = ServerEnv()
|
"""把契约接口挂到 ServerEnv,供其它 pbl_* 模块统一取用"""
|
||||||
env.pbl_common_ping = pbl_common_ping
|
contract = {}
|
||||||
env.pbl_common_audit = pbl_common_audit
|
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
29
pbl_common/self_check.py
Normal 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 = PASS,1 = 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
100
pbl_common/serialize.py
Normal 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
146
pbl_common/tables.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
pbl_common.tables —— 公共表 DDL(mariadb 方言)
|
||||||
|
|
||||||
|
契约(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
117
pbl_common/tenant.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
pbl_common.tenant —— 租户隔离工具(所有 SQL 的 tenant_id 打头约束)
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- 任何 WHERE 条件必须以 tenant_id 打头(tenant_scope 负责拼装并断言)
|
||||||
|
- 任何 INSERT 必须显式带 tenant_id(with_tenant 负责注入并断言)
|
||||||
|
- 跨租户访问一律拒绝,不提供「忽略租户」开关
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pbl_common.context import require_tenant, _check_tenant
|
||||||
|
from pbl_common.errors import (
|
||||||
|
TenantMissingError,
|
||||||
|
TenantInvalidError,
|
||||||
|
ErrorCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tenant(tenant_id):
|
||||||
|
"""规范化 tenant_id(None 时回落到当前上下文),非法即抛错"""
|
||||||
|
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
|
||||||
@ -1,13 +1 @@
|
|||||||
[project]
|
打包元数据(283B):name/version/description/requires-python>=3.7/packages=[pbl_common]
|
||||||
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"]
|
|
||||||
Loading…
x
Reference in New Issue
Block a user