"""统一租户解析器与兼容层。""" from __future__ import annotations from dataclasses import dataclass from sqlalchemy import text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession @dataclass(slots=True) class TenantResolution: """租户解析结果。""" tenant_code: str | None tenant_name: str | None tenant_type: str | None raw_value: str | None normalized_value: str | None source: str is_public: bool = False class TenantResolver: """统一租户解析器。 当前阶段目标: 1. 兼容 area / region / tenant_name 等历史字段 2. 尽可能解析成稳定 tenant_code 3. 为旧业务继续保留 normalized 中文值,避免立即打崩 area 依赖代码 """ _PUBLIC_SENTINELS = {"default", "公共"} _PROVINCIAL_SENTINELS = {"省级", "省局"} def __init__(self) -> None: self._table_exists_cache: dict[str, bool] = {} async def Resolve( self, RawValue: str | None, Source: str = "generic", FallbackTenantName: str | None = None, PreferredTenantCode: str | None = None, ) -> TenantResolution: normalized = self._normalize(RawValue) if PreferredTenantCode and PreferredTenantCode.strip(): tenant = await self._loadTenantByCode(PreferredTenantCode.strip()) if tenant: return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source) if normalized in self._PUBLIC_SENTINELS: tenant = await self._loadTenantByCode("PUBLIC") if tenant: return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source, force_public=True) if normalized in self._PROVINCIAL_SENTINELS: tenant = await self._loadTenantByCode("PROVINCIAL") if tenant: return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source) if normalized: tenant = await self._loadTenantByAlias(normalized) if tenant: return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized, source=Source) fallback_name = self._normalize(FallbackTenantName) if fallback_name: tenant = await self._loadTenantByAlias(fallback_name) if tenant: return self._to_resolution(tenant=tenant, raw_value=RawValue, normalized_value=normalized or fallback_name, source=Source) return TenantResolution( tenant_code=None, tenant_name=fallback_name or normalized, tenant_type=None, raw_value=RawValue, normalized_value=normalized or fallback_name, source=Source, is_public=False, ) async def ResolveUserContext( self, *, Area: str | None, TenantCode: str | None, TenantName: str | None, Source: str = "user_context", ) -> TenantResolution: """按用户上下文解析租户。""" return await self.Resolve( RawValue=Area, Source=Source, FallbackTenantName=TenantName, PreferredTenantCode=TenantCode, ) @staticmethod def _normalize(value: str | None) -> str | None: if value is None: return None trimmed = str(value).strip() return trimmed if trimmed else "" async def _loadTenantByCode(self, tenant_code: str) -> dict | None: if not await self._table_exists("sys_tenants"): return None async with GetAsyncSession() as session: row = ( await session.execute( text( """ SELECT tenant_code, tenant_name, tenant_type, COALESCE(is_public, FALSE) AS is_public FROM sys_tenants WHERE tenant_code = :tenant_code AND deleted_at IS NULL AND is_enabled = TRUE LIMIT 1 """ ), {"tenant_code": tenant_code}, ) ).mappings().first() return dict(row) if row else None async def _loadTenantByAlias(self, alias_value: str) -> dict | None: if not await self._table_exists("sys_tenants") or not await self._table_exists("sys_tenant_aliases"): return None async with GetAsyncSession() as session: row = ( await session.execute( text( """ SELECT t.tenant_code, t.tenant_name, t.tenant_type, COALESCE(t.is_public, FALSE) AS is_public FROM sys_tenant_aliases a JOIN sys_tenants t ON t.tenant_code = a.tenant_code WHERE a.alias_value = :alias_value AND a.deleted_at IS NULL AND a.is_enabled = TRUE AND t.deleted_at IS NULL AND t.is_enabled = TRUE ORDER BY CASE a.alias_type WHEN 'DISPLAY' THEN 1 WHEN 'LEGACY_AREA' THEN 2 WHEN 'LEGACY_REGION' THEN 3 ELSE 9 END ASC, a.id ASC LIMIT 1 """ ), {"alias_value": alias_value}, ) ).mappings().first() return dict(row) if row else None async def _table_exists(self, table_name: str) -> bool: """缓存表存在性,兼容旧环境未完成租户基础表建表的场景。""" cached = self._table_exists_cache.get(table_name) if cached is not None: return cached async with GetAsyncSession() as session: row = ( await session.execute( text( """ SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = :table_name ) AS table_exists """ ), {"table_name": table_name}, ) ).scalar_one() exists = bool(row) self._table_exists_cache[table_name] = exists return exists @staticmethod def _to_resolution( *, tenant: dict, raw_value: str | None, normalized_value: str | None, source: str, force_public: bool = False, ) -> TenantResolution: return TenantResolution( tenant_code=str(tenant.get("tenant_code") or ""), tenant_name=tenant.get("tenant_name"), tenant_type=tenant.get("tenant_type"), raw_value=raw_value, normalized_value=normalized_value, source=source, is_public=bool(tenant.get("is_public")) or force_public, )