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