308 lines
11 KiB
Python
308 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""pbl_common.api —— 公共内核对外契约面(重导出契约符号全集)。
|
||
|
||
【人工介入答复 ③(a)】下游 11+ 模块(pbl_validation / pbl_compiler / pbl_assessment /
|
||
pbl_blueprint / pbl_appcodes / pbl_agent_runtime / pbl_evidence ...)历史上从
|
||
``pbl_common.api`` 取符号。本版把**契约符号全集**在此重导出,任何一处 import 都能命中:
|
||
|
||
from pbl_common.api import (
|
||
PBLError, err, fail, ErrorCode, CODE_TO_HTTP,
|
||
TenantMissingError, NotFoundError, DbError, ParamInvalidError,
|
||
assert_not_write_protected, WRITE_PROTECTED_MODULES,
|
||
TenantContext, with_tenant, tenant_scope, assert_tenant, normalize_tenant,
|
||
check_tenant_column, get_tenant, set_tenant,
|
||
Db, get_db, get_module_dbname, new_id, now_str, sqlExe,
|
||
CrudBase, tenant_crud, crud_factory,
|
||
write_audit, write_audit_batch, audit_trail, flush_memory_audit,
|
||
ok, fail_body, paged, api_guard,
|
||
)
|
||
|
||
【QC 退回意见 #2 / #5】旧 api.py:39 从 errors 导入 ``ParamInvalidError`` /
|
||
``ErrorCode``,而半迁移后的 errors.py 无这些名字 → import 即崩。现在 errors.py 已补齐
|
||
(规范类 + 兼容别名 + ErrorCode 常量面),本文件 import 闭包闭合。
|
||
|
||
本文件**只做重导出 + 统一响应体 helper**,不含业务逻辑,因此不引入新的依赖边:
|
||
依赖方向恒为 api → {errors, context, dbutil, crud_factory, audit},无环。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import functools
|
||
import logging
|
||
|
||
# ---- 1. 错误码与异常(规范 + 兼容全集) ---------------------------------
|
||
from pbl_common.errors import (
|
||
ALL_CODES,
|
||
CODE_TO_HTTP,
|
||
DEFAULT_MESSAGES,
|
||
LEGACY_CODE_MAP,
|
||
PBL_ERR,
|
||
PBL_E_APPEND_ONLY,
|
||
PBL_E_COMPILE,
|
||
PBL_E_CONFLICT,
|
||
PBL_E_DB,
|
||
PBL_E_DB_UNAVAILABLE,
|
||
PBL_E_FORBIDDEN,
|
||
PBL_E_INTERNAL,
|
||
PBL_E_NEED_INFO,
|
||
PBL_E_NOT_FOUND,
|
||
PBL_E_OK,
|
||
PBL_E_PARAM,
|
||
PBL_E_TENANT,
|
||
PBL_E_TENANT_INVALID,
|
||
PBL_E_TENANT_MISSING,
|
||
PBL_E_TOOL_DENIED,
|
||
PBL_E_VALIDATION,
|
||
PBL_E_WRITE_LOCK,
|
||
PBL_E_WRITE_PROTECTED,
|
||
AppendOnlyError,
|
||
CompileError,
|
||
ConflictError,
|
||
DbError,
|
||
DbUnavailableError,
|
||
ErrorCode,
|
||
ForbiddenError,
|
||
NeedInfoError,
|
||
NotFoundError,
|
||
PBLError,
|
||
ParamInvalidError,
|
||
PblConflict,
|
||
PblError,
|
||
PblForbidden,
|
||
PblNotFound,
|
||
PblValidationError,
|
||
TenantError,
|
||
TenantInvalidError,
|
||
TenantMissingError,
|
||
ToolDeniedError,
|
||
ValidationError,
|
||
WriteLockError,
|
||
WriteProtectedError,
|
||
as_error,
|
||
default_message,
|
||
err,
|
||
error_body,
|
||
fail,
|
||
http_status_of,
|
||
is_write_protected,
|
||
normalize_code,
|
||
normalize_module,
|
||
)
|
||
from pbl_common.errors import (
|
||
READ_ACTIONS,
|
||
WRITE_ACTIONS,
|
||
WRITE_PROTECTED_MODULES,
|
||
assert_append_only,
|
||
assert_not_write_protected,
|
||
)
|
||
|
||
# ---- 2. 租户上下文 ------------------------------------------------------
|
||
from pbl_common.context import (
|
||
INVALID_TENANT_VALUES,
|
||
SYSTEM_TENANT_IDS,
|
||
TENANT_ID_PATTERN,
|
||
TenantContext,
|
||
assert_same_tenant,
|
||
assert_tenant,
|
||
check_tenant_column,
|
||
current_context,
|
||
current_tenant,
|
||
get_tenant,
|
||
has_tenant,
|
||
normalize_tenant,
|
||
reset_tenant,
|
||
run_in_tenant,
|
||
set_tenant,
|
||
tenant_scope,
|
||
tenant_scope_ctx,
|
||
with_tenant,
|
||
)
|
||
|
||
# ---- 3. DB 适配 ---------------------------------------------------------
|
||
from pbl_common.dbutil import (
|
||
Db,
|
||
cur_date_string,
|
||
fallback_enabled,
|
||
fetch_all,
|
||
fetch_one,
|
||
get_db,
|
||
get_module_dbname,
|
||
get_server_env,
|
||
insert_row,
|
||
new_id,
|
||
now_str,
|
||
quote_ident,
|
||
safe_ident,
|
||
sqlExe,
|
||
sqlor_available,
|
||
today_str,
|
||
update_row,
|
||
)
|
||
from pbl_common.dbutil import delete_row, query
|
||
|
||
# ---- 4. CRUD 工厂 -------------------------------------------------------
|
||
from pbl_common.crud_factory import (
|
||
AUDIT_TABLE_MARKERS,
|
||
CrudBase,
|
||
bulk_create,
|
||
bulk_delete,
|
||
clear_registry,
|
||
crud_factory,
|
||
is_audit_table,
|
||
make_crud,
|
||
registered_tables,
|
||
tenant_crud,
|
||
)
|
||
|
||
# ---- 5. 审计 ------------------------------------------------------------
|
||
from pbl_common.audit import (
|
||
AUDIT_ACTIONS,
|
||
AUDIT_TABLE,
|
||
audit_stats,
|
||
audit_trail,
|
||
build_audit_record,
|
||
flush_memory_audit,
|
||
forbid_audit_mutation,
|
||
write_audit,
|
||
write_audit_batch,
|
||
write_audit_sync,
|
||
)
|
||
|
||
log = logging.getLogger('pbl_common.api')
|
||
|
||
|
||
# ==========================================================================
|
||
# 统一响应体 helper
|
||
# ==========================================================================
|
||
def ok(data=None, message='ok', **extra):
|
||
"""成功响应体:{'ok': True, 'code': 'PBL_E_OK', 'data': ..., 'message': ...}。"""
|
||
body = {'ok': True, 'code': PBL_E_OK, 'legacy_code': LEGACY_CODE_MAP[PBL_E_OK],
|
||
'message': message, 'data': data, 'http_status': 200}
|
||
if extra:
|
||
body.update(extra)
|
||
return body
|
||
|
||
|
||
def fail_body(code=None, message=None, detail=None, **extra):
|
||
"""失败响应体(不抛异常,供 dspy 直接 return)。"""
|
||
body = err(code, message, detail, **extra).to_dict()
|
||
body['data'] = None
|
||
return body
|
||
|
||
|
||
def paged(rows, total=None, page=1, rows_per_page=20, **extra):
|
||
"""分页响应体。"""
|
||
body = ok({'total': len(rows) if total is None else total,
|
||
'rows': list(rows or []),
|
||
'page': int(page or 1),
|
||
'rows_per_page': int(rows_per_page or 20)},
|
||
message='ok')
|
||
if extra:
|
||
body.update(extra)
|
||
return body
|
||
|
||
|
||
def from_exception(exc):
|
||
"""异常 → 响应体(非 PBLError 归一为 PBL_E_INTERNAL)。"""
|
||
return as_error(exc).to_dict()
|
||
|
||
|
||
def api_guard(func=None, tenant_required=True, audit_action=None, audit_table=None):
|
||
"""API 层装饰器:租户门禁 + 写保护门禁 + 异常归一 + 可选审计。
|
||
|
||
被装饰函数抛出的 PBLError → 统一响应体(http_status 由错误码映射);
|
||
非 PBLError → PBL_E_INTERNAL(fail-closed,不泄露内部栈到前端,栈进日志)。
|
||
"""
|
||
def deco(fn):
|
||
@functools.wraps(fn)
|
||
async def wrapper(*args, **kwargs):
|
||
tenant_id = kwargs.get('tenant_id')
|
||
try:
|
||
if tenant_required:
|
||
kwargs['tenant_id'] = assert_tenant(tenant_id)
|
||
if audit_table:
|
||
assert_not_write_protected(audit_table, audit_action or 'write')
|
||
result = await fn(*args, **kwargs)
|
||
if isinstance(result, dict) and 'ok' in result:
|
||
return result
|
||
return ok(result)
|
||
except Exception as exc: # noqa: BLE001
|
||
error = as_error(exc)
|
||
if error.code == PBL_E_INTERNAL:
|
||
log.exception('api_guard 未预期异常 in %s', getattr(fn, '__name__', fn))
|
||
if audit_action and audit_table:
|
||
try:
|
||
await write_audit(audit_action, table=audit_table,
|
||
row_id=kwargs.get('id'),
|
||
detail={'error': error.code,
|
||
'message': error.message},
|
||
tenant_id=kwargs.get('tenant_id'),
|
||
result='error')
|
||
except Exception: # noqa: BLE001
|
||
log.warning('api_guard 失败审计写入未成功')
|
||
return error.to_dict()
|
||
return wrapper
|
||
if func is not None:
|
||
return deco(func)
|
||
return deco
|
||
|
||
|
||
# ==========================================================================
|
||
# 契约自检(import 期即验证符号面完整,失败直接抛 → 不允许半迁移状态被交付)
|
||
# ==========================================================================
|
||
CONTRACT_SYMBOLS = (
|
||
# errors
|
||
'PBLError', 'PblError', 'err', 'fail', 'as_error', 'error_body',
|
||
'ErrorCode', 'PBL_ERR', 'CODE_TO_HTTP', 'LEGACY_CODE_MAP', 'ALL_CODES',
|
||
'DbError', 'NotFoundError', 'ParamInvalidError', 'TenantMissingError',
|
||
'TenantInvalidError', 'ConflictError', 'ForbiddenError', 'WriteProtectedError',
|
||
'AppendOnlyError', 'ValidationError', 'CompileError', 'ToolDeniedError',
|
||
'NeedInfoError', 'PblValidationError', 'PblNotFound', 'PblConflict', 'PblForbidden',
|
||
'WRITE_PROTECTED_MODULES', 'assert_not_write_protected', 'assert_append_only',
|
||
'is_write_protected', 'normalize_code', 'http_status_of',
|
||
# context
|
||
'TenantContext', 'with_tenant', 'tenant_scope', 'assert_tenant',
|
||
'normalize_tenant', 'check_tenant_column', 'get_tenant', 'set_tenant',
|
||
'reset_tenant', 'current_context', 'assert_same_tenant',
|
||
# dbutil
|
||
'Db', 'get_db', 'get_module_dbname', 'get_server_env', 'new_id', 'now_str',
|
||
'sqlExe', 'query', 'fetch_one', 'fetch_all', 'insert_row', 'update_row',
|
||
'delete_row', 'safe_ident', 'sqlor_available', 'fallback_enabled',
|
||
# crud
|
||
'CrudBase', 'tenant_crud', 'crud_factory', 'make_crud', 'bulk_create',
|
||
'bulk_delete', 'is_audit_table',
|
||
# audit
|
||
'write_audit', 'write_audit_batch', 'audit_trail', 'flush_memory_audit',
|
||
'build_audit_record', 'write_audit_sync', 'audit_stats', 'AUDIT_TABLE',
|
||
# response helper
|
||
'ok', 'fail_body', 'paged', 'from_exception', 'api_guard',
|
||
)
|
||
|
||
|
||
def verify_contract():
|
||
"""校验契约符号全集在本模块命名空间内可见。返回缺失清单(空=通过)。"""
|
||
ns = globals()
|
||
return [name for name in CONTRACT_SYMBOLS if name not in ns or ns[name] is None]
|
||
|
||
|
||
_MISSING = verify_contract()
|
||
if _MISSING: # pragma: no cover - 门禁
|
||
raise ImportError('pbl_common.api 契约符号缺失(半迁移状态禁止交付):%s'
|
||
% ', '.join(_MISSING))
|
||
|
||
|
||
__all__ = list(CONTRACT_SYMBOLS) + [
|
||
'CONTRACT_SYMBOLS', 'verify_contract', 'LEGACY_CODE_MAP', 'DEFAULT_MESSAGES',
|
||
'SYSTEM_TENANT_IDS', 'TENANT_ID_PATTERN', 'INVALID_TENANT_VALUES',
|
||
'WRITE_ACTIONS', 'READ_ACTIONS', 'normalize_module', 'default_message',
|
||
'cur_date_string', 'today_str', 'quote_ident', 'registered_tables',
|
||
'clear_registry', 'AUDIT_ACTIONS', 'AUDIT_TABLE_MARKERS',
|
||
'forbid_audit_mutation', 'tenant_scope_ctx', 'run_in_tenant', 'has_tenant',
|
||
'current_tenant', 'PBL_E_OK', 'PBL_E_PARAM', 'PBL_E_TENANT',
|
||
'PBL_E_TENANT_MISSING', 'PBL_E_TENANT_INVALID', 'PBL_E_NOT_FOUND',
|
||
'PBL_E_CONFLICT', 'PBL_E_FORBIDDEN', 'PBL_E_WRITE_PROTECTED',
|
||
'PBL_E_WRITE_LOCK', 'PBL_E_APPEND_ONLY', 'PBL_E_DB', 'PBL_E_DB_UNAVAILABLE',
|
||
'PBL_E_VALIDATION', 'PBL_E_COMPILE', 'PBL_E_TOOL_DENIED', 'PBL_E_NEED_INFO',
|
||
'PBL_E_INTERNAL', 'TenantError', 'WriteLockError', 'DbUnavailableError',
|
||
]
|