feat: add tenant-scoped rule and permission management

This commit is contained in:
wren
2026-05-21 22:03:08 +08:00
parent a2c2bf1969
commit 1f1bccf3b3
193 changed files with 64463 additions and 1771 deletions
@@ -5,6 +5,7 @@
"""
from datetime import datetime
from typing import Any
from fastapi_common.fastapi_common_logger import logger
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
@@ -41,17 +42,105 @@ def _normalize_speed(speed: str | None) -> str:
return "normal"
def _candidate_binding_tenant_codes(tenant_code: str | None) -> list[str]:
"""Return binding resolution order for one document tenant.
PUBLIC is the platform template source; PROVINCIAL remains only as legacy fallback.
"""
normalized = str(tenant_code or "").strip().upper()
candidates: list[str] = []
if normalized and normalized not in {"PROVINCIAL", "PUBLIC"}:
candidates.append(normalized)
candidates.append("PUBLIC")
if normalized != "PUBLIC":
candidates.append("PROVINCIAL")
return list(dict.fromkeys(candidates))
def _pick_effective_binding(bindings: list[dict], tenant_code: str | None) -> dict | None:
"""Pick the effective binding by tenant inheritance order.
Legacy rows without ``tenant_code`` are treated as the loosest fallback.
"""
if not bindings:
return None
binding_map: dict[str, dict] = {}
empty_binding: dict | None = None
for binding in bindings:
normalized = str(binding.get("tenant_code") or "").strip().upper()
if not normalized:
if empty_binding is None:
empty_binding = binding
continue
binding_map.setdefault(normalized, binding)
for candidate in _candidate_binding_tenant_codes(tenant_code):
matched = binding_map.get(candidate)
if matched is not None:
return matched
return empty_binding
class AuditServiceImpl(IAuditService):
"""评查服务实现。"""
async def _resolve_rule_binding_from_group(self, session, group_id: int | None) -> dict | None:
def __init__(self) -> None:
self._column_exists_cache: dict[str, bool] = {}
async def _column_exists(self, session, table_name: str, column_name: str) -> bool:
cache_key = f"{table_name}.{column_name}"
cached = self._column_exists_cache.get(cache_key)
if cached is not None:
return cached
exists = bool(
(
await session.execute(
text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = :table_name
AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
).scalar_one()
)
self._column_exists_cache[cache_key] = exists
return exists
async def _resolve_rule_binding_from_group(
self,
session,
group_id: int | None,
tenant_code: str | None = None,
) -> dict | None:
"""按二级分组解析正式规则绑定。"""
if not group_id:
return None
binding_tenant_expr = (
"COALESCE(NULLIF(BTRIM(rgb.tenant_code), ''), 'PROVINCIAL')"
if await self._column_exists(session, "leaudit_rule_group_bindings", "tenant_code")
else "'PROVINCIAL'"
)
binding_scope_expr = (
"COALESCE(NULLIF(BTRIM(rgb.scope_type), ''), 'PROVINCIAL')"
if await self._column_exists(session, "leaudit_rule_group_bindings", "scope_type")
else "'PROVINCIAL'"
)
result = await session.execute(
text(
"""
f"""
SELECT
rgb.id AS binding_id,
{binding_tenant_expr} AS tenant_code,
{binding_scope_expr} AS scope_type,
rs.id AS rule_set_id,
COALESCE(rs.current_version_id, fallback_rv.id) AS rule_version_id,
COALESCE(current_rv.oss_url, fallback_rv.oss_url) AS rule_source_oss_url,
@@ -76,14 +165,18 @@ class AuditServiceImpl(IAuditService):
AND rgb.is_active = TRUE
AND rgb.deleted_at IS NULL
ORDER BY rgb.priority DESC, rgb.id ASC
LIMIT 1
"""
),
{"group_id": int(group_id)},
)
return result.mappings().first()
return _pick_effective_binding(list(result.mappings().all()), tenant_code)
async def _resolve_unique_group_binding_by_doc_type(self, session, doc_type_id: int | None) -> dict | None:
async def _resolve_unique_group_binding_by_doc_type(
self,
session,
doc_type_id: int | None,
tenant_code: str | None = None,
) -> dict | None:
"""当文档尚未落 group_id 时,按文档类型唯一子组兜底解析正式绑定。"""
if not doc_type_id:
return None
@@ -103,7 +196,44 @@ class AuditServiceImpl(IAuditService):
)
).mappings().first()
resolved_group_id = int(group_row["group_id"]) if group_row and group_row.get("group_id") is not None else None
return await self._resolve_rule_binding_from_group(session, resolved_group_id)
return await self._resolve_rule_binding_from_group(session, resolved_group_id, tenant_code)
async def _persist_run_tenant_snapshot(
self,
session,
run_id: int,
*,
tenant_code: str | None,
scope_type: str | None,
group_id: int | None,
rule_binding_id: int | None,
) -> None:
updates: list[str] = []
params: dict[str, Any] = {"run_id": run_id}
optional_values = {
"tenant_code": tenant_code,
"scope_type_snapshot": scope_type,
"group_id_snapshot": group_id,
"rule_binding_id_snapshot": rule_binding_id,
}
for column_name, value in optional_values.items():
if await self._column_exists(session, "leaudit_audit_runs", column_name):
updates.append(f"{column_name} = :{column_name}")
params[column_name] = value
if not updates:
return
await session.execute(
text(
f"""
UPDATE leaudit_audit_runs
SET {", ".join(updates)}
WHERE id = :run_id
"""
),
params,
)
async def Run(
self,
@@ -187,9 +317,19 @@ class AuditServiceImpl(IAuditService):
)
latestRunNo = runNoResult.scalar_one_or_none() or 0
binding = await self._resolve_rule_binding_from_group(session, getattr(document, "groupId", None))
document_tenant_code = str(getattr(document, "tenantCode", None) or "").strip() or None
binding = await self._resolve_rule_binding_from_group(
session,
getattr(document, "groupId", None),
document_tenant_code,
)
if binding is None:
binding = await self._resolve_unique_group_binding_by_doc_type(session, getattr(document, "typeId", None))
binding = await self._resolve_unique_group_binding_by_doc_type(
session,
getattr(document, "typeId", None),
document_tenant_code,
)
if binding and getattr(document, "groupId", None) is None:
logger.info("文档未显式记录 group_id,已按文档类型唯一子组解析正式规则绑定")
if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]:
@@ -217,6 +357,14 @@ class AuditServiceImpl(IAuditService):
)
session.add(run)
await session.flush()
await self._persist_run_tenant_snapshot(
session,
run.Id,
tenant_code=document_tenant_code,
scope_type=str(binding.get("scope_type") or "").strip() or None,
group_id=getattr(document, "groupId", None),
rule_binding_id=int(binding["binding_id"]) if binding.get("binding_id") is not None else None,
)
document.currentRunId = run.Id
document.processingStatus = "queued"