"""规则租户物化服务。""" from __future__ import annotations from typing import Any from sqlalchemy import text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession class RuleTenantMaterializer: """将平台模板规则物化为租户私有规则资产。""" _PLATFORM_TEMPLATE_TENANTS = {"PUBLIC", "PROVINCIAL"} def _template_source_order(self) -> list[str]: return ["PUBLIC", "PROVINCIAL"] def _filter_materializable_tenants(self, tenants: list[dict[str, Any]]) -> list[str]: result: list[str] = [] for tenant in tenants: tenant_code = str(tenant.get("tenant_code") or "").strip().upper() if not tenant_code or tenant_code in self._PLATFORM_TEMPLATE_TENANTS: continue if not bool(tenant.get("is_enabled", True)): continue result.append(tenant_code) return result def _legacy_region_for_tenant(self, tenant_code: str) -> str: return str(tenant_code or "").strip().upper() async def MaterializeAllEnabledTenants(self) -> dict[str, int]: async with GetAsyncSession() as session: await self._ensure_rule_tenant_schema(session) await self._promote_legacy_provincial_templates_to_public(session) tenants = await self._load_enabled_tenants(session) tenant_codes = self._filter_materializable_tenants(tenants) stats = await self._materialize_tenants(session, tenant_codes) await session.commit() return stats async def MaterializeTenant(self, TenantCode: str) -> dict[str, int]: tenant_code = str(TenantCode or "").strip().upper() if not tenant_code or tenant_code in self._PLATFORM_TEMPLATE_TENANTS: return {"tenants": 0, "rule_sets": 0, "versions": 0, "bindings": 0} async with GetAsyncSession() as session: await self._ensure_rule_tenant_schema(session) await self._promote_legacy_provincial_templates_to_public(session) stats = await self._materialize_tenants(session, [tenant_code]) await session.commit() return stats async def _ensure_rule_tenant_schema(self, session) -> None: required = { "leaudit_rule_sets": {"tenant_code", "scope_type", "source_rule_set_id", "tenant_name_snapshot"}, "leaudit_rule_versions": {"tenant_code_snapshot", "scope_type_snapshot", "source_version_id"}, "leaudit_rule_group_bindings": {"tenant_code", "scope_type", "tenant_name_snapshot"}, } rows = ( await session.execute( text( """ SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ANY(:table_names) """ ), {"table_names": list(required)}, ) ).mappings().all() existing: dict[str, set[str]] = {} for row in rows: existing.setdefault(str(row["table_name"]), set()).add(str(row["column_name"])) missing = [ f"{table}.{column}" for table, columns in required.items() for column in sorted(columns - existing.get(table, set())) ] if missing: raise RuntimeError("规则域租户字段未就绪: " + ", ".join(missing)) async def _load_enabled_tenants(self, session) -> list[dict[str, Any]]: rows = ( await session.execute( text( """ SELECT tenant_code, is_enabled FROM sys_tenants WHERE deleted_at IS NULL AND is_enabled = TRUE ORDER BY display_order ASC, id ASC """ ) ) ).mappings().all() return [dict(row) for row in rows] async def _promote_legacy_provincial_templates_to_public(self, session) -> None: """把历史 PROVINCIAL 模板归并成 PUBLIC 模板源。""" await session.execute( text( """ UPDATE leaudit_rule_sets SET tenant_code = 'PUBLIC', scope_type = 'PUBLIC', region = 'PUBLIC', updated_at = NOW() WHERE deleted_at IS NULL AND COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') = 'PROVINCIAL' AND NOT EXISTS ( SELECT 1 FROM leaudit_rule_sets existing WHERE existing.rule_type = leaudit_rule_sets.rule_type AND existing.tenant_code = 'PUBLIC' AND existing.deleted_at IS NULL ) """ ) ) await session.execute( text( """ UPDATE leaudit_rule_versions rv SET tenant_code_snapshot = 'PUBLIC', scope_type_snapshot = 'PUBLIC', updated_at = NOW() FROM leaudit_rule_sets rs WHERE rv.rule_set_id = rs.id AND rs.tenant_code = 'PUBLIC' AND (rv.tenant_code_snapshot IS NULL OR rv.tenant_code_snapshot = 'PROVINCIAL') """ ) ) await session.execute( text( """ UPDATE leaudit_rule_group_bindings rgb SET tenant_code = 'PUBLIC', scope_type = 'PUBLIC', updated_at = NOW() FROM leaudit_rule_sets rs WHERE rgb.rule_set_id = rs.id AND rs.tenant_code = 'PUBLIC' AND rgb.deleted_at IS NULL AND COALESCE(NULLIF(BTRIM(rgb.tenant_code), ''), 'PROVINCIAL') = 'PROVINCIAL' """ ) ) async def _materialize_tenants(self, session, tenant_codes: list[str]) -> dict[str, int]: stats = {"tenants": len(tenant_codes), "rule_sets": 0, "versions": 0, "bindings": 0} for tenant_code in tenant_codes: stats["rule_sets"] += await self._materialize_rule_sets(session, tenant_code) stats["versions"] += await self._materialize_versions(session, tenant_code) stats["bindings"] += await self._materialize_bindings(session, tenant_code) return stats async def _materialize_rule_sets(self, session, tenant_code: str) -> int: result = await session.execute( text( """ INSERT INTO leaudit_rule_sets ( rule_type, rule_name, domain_type, description, entry_module, current_version_id, status, is_builtin, owner_user_id, tenant_code, scope_type, source_rule_set_id, tenant_name_snapshot, created_at, updated_at, deleted_at, region ) SELECT src.rule_type, src.rule_name, src.domain_type, src.description, src.entry_module, NULL, 'draft', FALSE, src.owner_user_id, CAST(:tenant_code AS varchar), 'TENANT', src.id, t.tenant_name, NOW(), NOW(), NULL, CAST(:tenant_code AS varchar) FROM leaudit_rule_sets src JOIN sys_tenants t ON t.tenant_code = CAST(:tenant_code AS varchar) WHERE src.deleted_at IS NULL AND src.tenant_code = 'PUBLIC' AND NOT EXISTS ( SELECT 1 FROM leaudit_rule_sets existing WHERE existing.rule_type = src.rule_type AND existing.tenant_code = CAST(:tenant_code AS varchar) AND existing.deleted_at IS NULL ) """ ), {"tenant_code": tenant_code}, ) return int(result.rowcount or 0) async def _materialize_versions(self, session, tenant_code: str) -> int: result = await session.execute( text( """ INSERT INTO leaudit_rule_versions ( rule_set_id, version_no, version_seq, status, source_type, dsl_format, oss_url, file_sha256, file_size, local_cache_path, metadata_type_id, metadata_name, metadata_version, change_note, editor_user_id, publisher_user_id, published_at, created_at, updated_at, deleted_at, tenant_code_snapshot, scope_type_snapshot, source_version_id ) SELECT tenant_rs.id, src_v.version_no, src_v.version_seq, src_v.status, src_v.source_type, src_v.dsl_format, src_v.oss_url, src_v.file_sha256, src_v.file_size, src_v.local_cache_path, src_v.metadata_type_id, src_v.metadata_name, src_v.metadata_version, src_v.change_note, src_v.editor_user_id, src_v.publisher_user_id, src_v.published_at, NOW(), NOW(), NULL, CAST(:tenant_code AS varchar), 'TENANT', src_v.id FROM leaudit_rule_sets tenant_rs JOIN leaudit_rule_sets src_rs ON src_rs.id = tenant_rs.source_rule_set_id JOIN leaudit_rule_versions src_v ON src_v.rule_set_id = src_rs.id WHERE tenant_rs.deleted_at IS NULL AND tenant_rs.tenant_code = CAST(:tenant_code AS varchar) AND src_rs.tenant_code = 'PUBLIC' AND src_v.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM leaudit_rule_versions tenant_existing WHERE tenant_existing.rule_set_id = tenant_rs.id AND tenant_existing.deleted_at IS NULL ) AND NOT EXISTS ( SELECT 1 FROM leaudit_rule_versions existing WHERE existing.rule_set_id = tenant_rs.id AND existing.deleted_at IS NULL AND ( existing.source_version_id = src_v.id OR existing.version_no = src_v.version_no OR existing.version_seq = src_v.version_seq ) ) """ ), {"tenant_code": tenant_code}, ) await session.execute( text( """ UPDATE leaudit_rule_sets tenant_rs SET current_version_id = tenant_v.id, status = 'active', updated_at = NOW() FROM leaudit_rule_sets src_rs JOIN leaudit_rule_versions src_current ON src_current.id = src_rs.current_version_id JOIN leaudit_rule_versions tenant_v ON tenant_v.source_version_id = src_current.id WHERE tenant_rs.source_rule_set_id = src_rs.id AND tenant_rs.tenant_code = CAST(:tenant_code AS varchar) AND tenant_v.rule_set_id = tenant_rs.id AND tenant_rs.current_version_id IS NULL """ ), {"tenant_code": tenant_code}, ) return int(result.rowcount or 0) async def _materialize_bindings(self, session, tenant_code: str) -> int: result = await session.execute( text( """ INSERT INTO leaudit_rule_group_bindings ( group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, tenant_code, scope_type, tenant_name_snapshot, created_at, updated_at, deleted_at ) SELECT src_b.group_id, tenant_rs.id, src_b.rule_type_binding_id, src_b.priority, src_b.is_active, '由公共规则模板自动物化', CAST(:tenant_code AS varchar), 'TENANT', t.tenant_name, NOW(), NOW(), NULL FROM leaudit_rule_group_bindings src_b JOIN leaudit_rule_sets src_rs ON src_rs.id = src_b.rule_set_id JOIN leaudit_rule_sets tenant_rs ON tenant_rs.source_rule_set_id = src_rs.id AND tenant_rs.tenant_code = CAST(:tenant_code AS varchar) AND tenant_rs.deleted_at IS NULL JOIN sys_tenants t ON t.tenant_code = CAST(:tenant_code AS varchar) WHERE src_b.deleted_at IS NULL AND src_b.is_active = TRUE AND src_rs.tenant_code = 'PUBLIC' AND NOT EXISTS ( SELECT 1 FROM leaudit_rule_group_bindings existing WHERE existing.group_id = src_b.group_id AND existing.tenant_code = CAST(:tenant_code AS varchar) AND existing.rule_set_id = tenant_rs.id AND existing.deleted_at IS NULL ) """ ), {"tenant_code": tenant_code}, ) return int(result.rowcount or 0) _RULE_TENANT_MATERIALIZER_SINGLETON: RuleTenantMaterializer | None = None def GetRuleTenantMaterializerSingleton() -> RuleTenantMaterializer: global _RULE_TENANT_MATERIALIZER_SINGLETON if _RULE_TENANT_MATERIALIZER_SINGLETON is None: _RULE_TENANT_MATERIALIZER_SINGLETON = RuleTenantMaterializer() return _RULE_TENANT_MATERIALIZER_SINGLETON