feat: add tenant-scoped rule and permission management
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user