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
@@ -45,12 +45,15 @@ from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskItemVO,
CrossReviewTaskPageVO,
CrossReviewTaskProgressVO,
CrossReviewTaskTenantVO,
)
from fastapi_modules.fastapi_leaudit.services.crossReviewService import ICrossReviewService
from fastapi_modules.fastapi_leaudit.services.documentService import IDocumentService
from fastapi_modules.fastapi_leaudit.services.auditService import IAuditService
from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import AuditServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver
class CrossReviewServiceImpl(ICrossReviewService):
@@ -189,6 +192,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
def __init__(self):
self.DocumentService: IDocumentService = DocumentServiceImpl()
self.AuditService: IAuditService = AuditServiceImpl()
self.TenantResolver = TenantResolver()
async def CreateTask(self, CurrentUserId: int, Body: CrossReviewTaskCreateDTO) -> CrossReviewTaskCreateVO:
"""创建交叉评查任务。"""
@@ -198,6 +202,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
memberUserIds = self._unique_int_list(Body.memberUserIds + [CurrentUserId])
principalUserIds = self._unique_int_list(Body.principalUserIds)
documentIds = self._unique_int_list(Body.documentIds)
await self._assert_task_scope_inputs(session, CurrentUserId, memberUserIds, documentIds)
await self._reset_transaction_for_write(session)
async with session.begin():
@@ -332,6 +337,17 @@ class CrossReviewServiceImpl(ICrossReviewService):
or 0
)
sso_user_columns = await SsoUserCompat.get_columns(session)
tenant_code_expr = SsoUserCompat.raw_optional_column(
sso_user_columns,
alias="u",
column="tenant_code",
)
tenant_name_expr = SsoUserCompat.raw_optional_column(
sso_user_columns,
alias="u",
column="tenant_name",
)
rows = (
await session.execute(
text(
@@ -348,13 +364,34 @@ class CrossReviewServiceImpl(ICrossReviewService):
task_regions AS (
SELECT
tm.task_id,
ARRAY_AGG(DISTINCT u.area ORDER BY u.area) FILTER (
WHERE u.area IS NOT NULL AND u.area != ''
ARRAY_AGG(
DISTINCT COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, ''))
ORDER BY COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, ''))
) FILTER (
WHERE COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, '')) IS NOT NULL
) AS evaluation_regions
FROM leaudit_cross_review_task_members tm
JOIN sso_users u ON u.id = tm.user_id
WHERE tm.delete_time IS NULL
GROUP BY tm.task_id
),
task_tenants AS (
SELECT
tm.task_id,
jsonb_agg(
DISTINCT jsonb_build_object(
'tenantCode',
COALESCE(NULLIF({tenant_code_expr}, ''), ''),
'tenantName',
COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, ''), '')
)
) FILTER (
WHERE COALESCE(NULLIF({tenant_name_expr}, ''), NULLIF(u.area, '')) IS NOT NULL
) AS evaluation_tenants
FROM leaudit_cross_review_task_members tm
JOIN sso_users u ON u.id = tm.user_id
WHERE tm.delete_time IS NULL
GROUP BY tm.task_id
)
SELECT
t.id AS task_id,
@@ -366,18 +403,21 @@ class CrossReviewServiceImpl(ICrossReviewService):
t.create_time,
COALESCE(ds.total_documents, 0) AS total_documents,
COALESCE(ds.completed_documents, 0) AS completed_documents,
COALESCE(tt.evaluation_tenants, '[]'::jsonb) AS evaluation_tenants,
COALESCE(tr.evaluation_regions, ARRAY[]::varchar[]) AS evaluation_regions
FROM leaudit_cross_review_tasks t
JOIN leaudit_cross_review_task_members tm
ON tm.task_id = t.id
LEFT JOIN doc_stats ds
ON ds.task_id = t.id
LEFT JOIN task_tenants tt
ON tt.task_id = t.id
LEFT JOIN task_regions tr
ON tr.task_id = t.id
WHERE {whereSql}
GROUP BY
t.id, t.task_name, t.task_type, t.doc_type_id, t.doc_type_code,
t.status, t.create_time, ds.total_documents, ds.completed_documents,
t.status, t.create_time, ds.total_documents, ds.completed_documents, tt.evaluation_tenants,
tr.evaluation_regions
ORDER BY t.create_time DESC, t.id DESC
LIMIT :limit OFFSET :offset
@@ -392,6 +432,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
totalDocuments = int(row["total_documents"] or 0)
completedDocuments = int(row["completed_documents"] or 0)
progress = round((completedDocuments / totalDocuments * 100) if totalDocuments > 0 else 0, 2)
evaluationTenants = self._parse_task_tenants(row.get("evaluation_tenants"))
rawRegions = row.get("evaluation_regions")
if rawRegions is None:
evaluationRegion: list[str] = []
@@ -399,6 +440,8 @@ class CrossReviewServiceImpl(ICrossReviewService):
evaluationRegion = [str(r) for r in rawRegions]
else:
evaluationRegion = [str(rawRegions)]
if not evaluationRegion:
evaluationRegion = [tenant.tenantName for tenant in evaluationTenants if tenant.tenantName]
items.append(
CrossReviewTaskItemVO(
taskId=int(row["task_id"]),
@@ -411,6 +454,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
totalDocuments=totalDocuments,
completedDocuments=completedDocuments,
createdAt=row.get("create_time"),
evaluationTenants=evaluationTenants,
evaluationRegion=evaluationRegion,
)
)
@@ -1346,6 +1390,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
resolvedTypeId = int(TypeId) if TypeId is not None else self._to_int(taskMeta.get("doc_type_id"))
resolvedGroupId = int(GroupId) if GroupId is not None else None
taskScope = await self._load_task_scope_context(session, TaskId)
uploadResult = await self.DocumentService.Upload(
FileName=FileName,
@@ -1355,6 +1400,9 @@ class CrossReviewServiceImpl(ICrossReviewService):
TypeCode=None if resolvedTypeId is not None else taskMeta.get("doc_type_code"),
GroupId=resolvedGroupId,
CreatedBy=CurrentUserId,
Region=taskScope["tenant_scope_value"],
TenantCode=taskScope["tenant_code"],
TenantName=taskScope["tenant_name"],
AutoRun=True,
ReviewScope="cross_review",
)
@@ -1988,6 +2036,236 @@ class CrossReviewServiceImpl(ICrossReviewService):
if not exists:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户不是交叉评查任务成员")
async def _assert_task_scope_inputs(
self,
session,
current_user_id: int,
member_user_ids: list[int],
document_ids: list[int],
) -> None:
"""创建任务前校验成员和文档均在单一有效租户范围内。"""
current_context = await self.DocumentService._getCurrentUserContext(current_user_id)
current_tenant_code = str(current_context.get("tenant_code") or "").strip()
current_tenant_name = str(current_context.get("tenant_name") or current_context.get("tenant_scope_value") or current_context.get("area") or "").strip()
is_global = bool(current_context.get("is_global"))
if not is_global and not current_tenant_code and not current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户未绑定有效租户,不能创建交叉评查任务")
resolved_scopes: list[dict[str, str]] = []
if member_user_ids:
sso_user_columns = await SsoUserCompat.get_columns(session)
tenant_code_select = SsoUserCompat.optional_coalesce_as(
sso_user_columns,
alias="u",
column="tenant_code",
fallback_sql="''",
)
tenant_name_select = SsoUserCompat.optional_coalesce_as(
sso_user_columns,
alias="u",
column="tenant_name",
fallback_sql="''",
)
user_rows = (
await session.execute(
text(
f"""
SELECT u.id, COALESCE(u.area, '') AS area, {tenant_code_select}, {tenant_name_select}
FROM sso_users u
WHERE id IN :user_ids
AND deleted_at IS NULL
"""
).bindparams(bindparam("user_ids", expanding=True)),
{"user_ids": member_user_ids},
)
).mappings().all()
user_map = {int(row["id"]): row for row in user_rows}
missing_user_ids = [user_id for user_id in member_user_ids if user_id not in user_map]
if missing_user_ids:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"成员用户不存在: {missing_user_ids[0]}")
for user_id in member_user_ids:
row = user_map[user_id]
tenant = await self.TenantResolver.ResolveUserContext(
Area=str(row.get("area") or ""),
TenantCode=str(row.get("tenant_code") or "") or None,
TenantName=str(row.get("tenant_name") or "") or None,
Source="cross_review_member_scope",
)
member_tenant_code = str(tenant.tenant_code or row.get("tenant_code") or "").strip()
member_tenant_name = str(tenant.tenant_name or tenant.normalized_value or row.get("area") or "").strip()
if not member_tenant_code and not member_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"成员用户未绑定有效租户: {user_id}")
resolved_scopes.append({"tenant_code": member_tenant_code, "tenant_name": member_tenant_name})
if current_tenant_code:
if member_tenant_code:
if member_tenant_code != current_tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}")
elif member_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}")
elif not is_global and member_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}")
if document_ids:
document_columns = await self._load_table_columns(session, "leaudit_documents")
document_tenant_code_select = "COALESCE(tenant_code, '') AS tenant_code" if "tenant_code" in document_columns else "'' AS tenant_code"
document_rows = (
await session.execute(
text(
f"""
SELECT id, COALESCE(region, '') AS region, {document_tenant_code_select}
FROM leaudit_documents
WHERE id IN :document_ids
AND deleted_at IS NULL
"""
).bindparams(bindparam("document_ids", expanding=True)),
{"document_ids": document_ids},
)
).mappings().all()
document_map = {int(row["id"]): row for row in document_rows}
missing_document_ids = [document_id for document_id in document_ids if document_id not in document_map]
if missing_document_ids:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"任务文档不存在: {missing_document_ids[0]}")
for document_id in document_ids:
row = document_map[document_id]
tenant = await self.TenantResolver.Resolve(
RawValue=str(row.get("region") or ""),
Source="cross_review_document_scope",
PreferredTenantCode=str(row.get("tenant_code") or "") or None,
)
document_tenant_code = str(tenant.tenant_code or row.get("tenant_code") or "").strip()
document_tenant_name = str(tenant.tenant_name or tenant.normalized_value or row.get("region") or "").strip()
if not document_tenant_code and not document_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"任务文档未绑定有效租户: {document_id}")
resolved_scopes.append({"tenant_code": document_tenant_code, "tenant_name": document_tenant_name})
if current_tenant_code:
if document_tenant_code:
if document_tenant_code != current_tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}")
elif document_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}")
elif not is_global and document_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}")
self._assert_single_task_scope(resolved_scopes)
async def _load_task_scope_context(self, session, task_id: int) -> dict[str, str | None]:
"""读取任务所属租户上下文,补传文档时沿用任务真实边界。"""
document_columns = await self._load_table_columns(session, "leaudit_documents")
document_tenant_code_select = "COALESCE(d.tenant_code, '') AS tenant_code" if "tenant_code" in document_columns else "'' AS tenant_code"
document_rows = (
await session.execute(
text(
f"""
SELECT
td.document_id,
COALESCE(d.region, '') AS region,
{document_tenant_code_select}
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE td.task_id = :task_id
AND td.delete_time IS NULL
AND d.deleted_at IS NULL
ORDER BY td.id ASC
"""
),
{"task_id": task_id},
)
).mappings().all()
if document_rows:
document_scopes: list[dict[str, str]] = []
for row in document_rows:
tenant = await self.TenantResolver.Resolve(
RawValue=str(row.get("region") or ""),
Source="cross_review_task_document_scope",
PreferredTenantCode=str(row.get("tenant_code") or "") or None,
)
document_scopes.append(
{
"tenant_code": str(tenant.tenant_code or row.get("tenant_code") or "").strip(),
"tenant_name": str(tenant.tenant_name or tenant.normalized_value or row.get("region") or "").strip(),
}
)
return self._assert_single_task_scope(document_scopes, error_message="交叉评查任务存在多租户文档,不能继续补传文档")
sso_user_columns = await SsoUserCompat.get_columns(session)
tenant_code_select = SsoUserCompat.optional_coalesce_as(
sso_user_columns,
alias="u",
column="tenant_code",
fallback_sql="''",
)
tenant_name_select = SsoUserCompat.optional_coalesce_as(
sso_user_columns,
alias="u",
column="tenant_name",
fallback_sql="''",
)
row = (
await session.execute(
text(
f"""
SELECT
COALESCE(u.area, '') AS area,
{tenant_code_select},
{tenant_name_select}
FROM leaudit_cross_review_tasks t
JOIN sso_users u
ON u.id = t.assigner_id
WHERE t.id = :task_id
AND t.delete_time IS NULL
LIMIT 1
"""
),
{"task_id": task_id},
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "交叉评查任务不存在")
tenant = await self.TenantResolver.ResolveUserContext(
Area=str(row.get("area") or ""),
TenantCode=str(row.get("tenant_code") or "") or None,
TenantName=str(row.get("tenant_name") or "") or None,
Source="cross_review_task_scope",
)
return {
"tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None),
"tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or str(row.get("area") or "") or None),
"tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row.get("area") or ""),
}
def _assert_single_task_scope(
self,
scopes: list[dict[str, str]],
*,
error_message: str = "交叉评查任务不允许混挂多个租户的成员或文档",
) -> dict[str, str | None]:
normalized: list[dict[str, str]] = []
identities: set[str] = set()
for scope in scopes:
tenant_code = str(scope.get("tenant_code") or "").strip()
tenant_name = str(scope.get("tenant_name") or "").strip()
if not tenant_code and not tenant_name:
continue
identity = f"code:{tenant_code}" if tenant_code else f"name:{tenant_name}"
identities.add(identity)
normalized.append({"tenant_code": tenant_code, "tenant_name": tenant_name})
if len(identities) > 1:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, error_message)
if not normalized:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "未能识别交叉评查任务租户边界")
primary = normalized[0]
return {
"tenant_code": primary["tenant_code"] or None,
"tenant_name": primary["tenant_name"] or None,
"tenant_scope_value": primary["tenant_name"] or primary["tenant_code"] or "",
}
async def _reset_transaction_for_write(self, session) -> None:
"""显式写事务前清理查询阶段开启的隐式事务。"""
if session.in_transaction():
@@ -2019,6 +2297,28 @@ class CrossReviewServiceImpl(ICrossReviewService):
return [str(v) for v in value]
return [str(value)]
def _parse_task_tenants(self, value) -> list[CrossReviewTaskTenantVO]:
"""安全解析任务租户 JSON 聚合结果。"""
if value is None:
return []
if not isinstance(value, list):
return []
result: list[CrossReviewTaskTenantVO] = []
seen: set[tuple[str, str]] = set()
for item in value:
if not isinstance(item, dict):
continue
tenant_code = str(item.get("tenantCode") or item.get("tenant_code") or "").strip()
tenant_name = str(item.get("tenantName") or item.get("tenant_name") or "").strip()
if not tenant_code and not tenant_name:
continue
identity = (tenant_code, tenant_name)
if identity in seen:
continue
seen.add(identity)
result.append(CrossReviewTaskTenantVO(tenantCode=tenant_code, tenantName=tenant_name or tenant_code))
return result
def _build_score_summary(self, finalScore: float, fullScore: float) -> str:
if fullScore <= 0:
return "0/0"