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
@@ -53,12 +53,16 @@ from fastapi_modules.fastapi_leaudit.domian.vo.reviewPointVo import (
ReviewPointStatsVO,
ReviewPointsAggregateVO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.pageQualityVo import PageQualitySummaryVO
from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile
from fastapi_modules.fastapi_leaudit.leaudit_bridge.fileSourceResolver import FileSourceResolver
from fastapi_modules.fastapi_leaudit.services import IAuditService, IDocumentService, IOssService
from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import AuditServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.pageQualityServiceImpl import PageQualityServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import sync_group_bindings_from_doc_type
from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver
class DocumentServiceImpl(IDocumentService):
@@ -71,6 +75,10 @@ class DocumentServiceImpl(IDocumentService):
) -> None:
self.OssService = OssService or OssServiceImpl()
self.AuditService = AuditService or AuditServiceImpl()
self.PageQualityService = PageQualityServiceImpl()
self.TenantResolver = TenantResolver()
_PUBLIC_REGION = "公共"
async def Upload(
self,
@@ -80,9 +88,11 @@ class DocumentServiceImpl(IDocumentService):
TypeId: int | None = None,
TypeCode: str | None = None,
GroupId: int | None = None,
Region: str = "default",
Region: str | None = None,
FileRole: str = "primary",
CreatedBy: int | None = None,
TenantCode: str | None = None,
TenantName: str | None = None,
Attachments: list[tuple[str, bytes, str | None]] | None = None,
AutoRun: bool = False,
Speed: str = "normal",
@@ -96,7 +106,25 @@ class DocumentServiceImpl(IDocumentService):
if not TypeId and not TypeCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "typeId 与 typeCode 至少传一个")
normalizedRegion = (Region or "default").strip() or "default"
normalizedRegion = await self._resolve_document_region(
Region=Region,
TenantCode=TenantCode,
TenantName=TenantName,
)
currentUserContext: dict[str, Any] | None = None
if CreatedBy is not None:
currentUserContext = await self._getCurrentUserContext(CreatedBy)
normalizedRegion = self._resolve_upload_region(
currentUserContext,
requestedRegion=normalizedRegion,
requestedTenantCode=TenantCode,
)
resolvedTenant = await self.TenantResolver.Resolve(
RawValue=normalizedRegion,
Source="document_upload_response",
PreferredTenantCode=str(TenantCode or "").strip() or None,
FallbackTenantName=TenantName,
)
normalizedFileRole = (FileRole or "primary").strip() or "primary"
fileExt = Path(FileName).suffix.lstrip(".").lower() or None
mimeType = ContentType or mimetypes.guess_type(FileName)[0] or "application/octet-stream"
@@ -168,6 +196,7 @@ class DocumentServiceImpl(IDocumentService):
Session,
type_id=resolvedTypeId,
root_group_id=resolvedRootGroupId,
tenant_code=resolvedTenant.tenant_code,
region=normalizedRegion,
normalized_name=normalizedName,
file_ext=fileExt,
@@ -190,6 +219,7 @@ class DocumentServiceImpl(IDocumentService):
bizDocumentId=internalDocumentNo,
typeId=resolvedTypeId,
groupId=resolvedGroupId,
tenantCode=resolvedTenant.tenant_code,
region=normalizedRegion,
processingStatus="waiting",
versionGroupKey=versionGroupKey,
@@ -260,6 +290,7 @@ class DocumentServiceImpl(IDocumentService):
ossUrl = documentFile.ossUrl or ""
run = None
pageQualityRun = None
processingStatus = document.processingStatus or "waiting"
if AutoRun:
run = await self.AuditService.Run(
@@ -270,6 +301,17 @@ class DocumentServiceImpl(IDocumentService):
)
processingStatus = "running" if run.status in {"pending", "running"} else run.status
if normalizedFileRole == "primary":
try:
pageQualityRun = await self.PageQualityService.DispatchForDocument(
DocumentId=document.Id,
TriggerUserId=CreatedBy,
Force=duplicateUpload,
Speed=normalizedSpeed,
)
except Exception as exc:
logger.warning("dispatch page quality failed for document_id=%s: %s", document.Id, exc)
return DocumentUploadVO(
documentId=document.Id,
internalDocumentNo=document.bizDocumentId,
@@ -283,11 +325,16 @@ class DocumentServiceImpl(IDocumentService):
typeCode=resolvedTypeCode,
groupId=resolvedGroupId,
region=normalizedRegion,
tenantCode=resolvedTenant.tenant_code,
tenantName=resolvedTenant.tenant_name or normalizedRegion,
fileName=documentFile.fileName,
ossUrl=ossUrl,
speed=normalizedSpeed,
processingStatus=processingStatus,
autoRunTriggered=AutoRun,
pageQualityRunId=pageQualityRun.runId if pageQualityRun else None,
pageQualityRunStatus=pageQualityRun.status if pageQualityRun else None,
pageQualitySummaryStatus=None,
run=run,
)
@@ -302,6 +349,7 @@ class DocumentServiceImpl(IDocumentService):
TypeIds: list[int] | None = None,
EntryModuleId: int | None = None,
Region: str | None = None,
TenantCode: str | None = None,
ProcessingStatus: str | None = None,
ResultStatus: str | None = None,
AuditStatus: int | None = None,
@@ -319,6 +367,11 @@ class DocumentServiceImpl(IDocumentService):
documentColumns = await self._loadDocumentColumns(Session)
currentUser = await self._getCurrentUserContext(CurrentUserId)
requestedTenant = await self.TenantResolver.Resolve(
RawValue=(Region or "").strip() or None,
Source="document_list_scope",
PreferredTenantCode=str(TenantCode or "").strip() or None,
)
filters = [
"d.is_latest_version = true",
"d.deleted_at IS NULL",
@@ -331,7 +384,8 @@ class DocumentServiceImpl(IDocumentService):
CurrentUserId=CurrentUserId,
CurrentUser=currentUser,
Params=params,
RequestedRegion=Region,
RequestedRegion=requestedTenant.tenant_name or requestedTenant.normalized_value or Region,
RequestedTenantCode=requestedTenant.tenant_code or TenantCode,
RequestedUserId=UserId,
DocumentAlias="d",
FileAlias="f",
@@ -414,6 +468,14 @@ class DocumentServiceImpl(IDocumentService):
"d.is_test_document AS is_test_document" if "is_test_document" in documentColumns else "FALSE AS is_test_document",
"dt.name AS type_name",
f"{resolvedGroupNameExpr} AS group_name",
"COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code",
"""
CASE
WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共'
WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级'
ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '')
END AS tenant_name
""".strip(),
]
count_sql = text(
@@ -553,6 +615,10 @@ class DocumentServiceImpl(IDocumentService):
rows = (await Session.execute(list_sql, params)).mappings().all()
history_by_group: dict[str, list[DocumentHistoryVersionVO]] = {}
page_quality_map = await self._loadLatestPageQualitySummaryMap(
Session,
[int(row["document_id"]) for row in rows],
)
group_keys = [str(row["version_group_key"]) for row in rows if row["version_group_key"]]
if group_keys:
history_rows = (
@@ -581,6 +647,7 @@ class DocumentServiceImpl(IDocumentService):
documents: list[DocumentListItemVO] = []
for row in rows:
quality = page_quality_map.get(int(row["document_id"]), {})
group_key = str(row["version_group_key"] or "")
documents.append(
DocumentListItemVO(
@@ -596,6 +663,8 @@ class DocumentServiceImpl(IDocumentService):
groupId=int(row["group_id"]) if row["group_id"] is not None else None,
groupName=row["group_name"],
region=row["region"],
tenantCode=row["tenant_code"],
tenantName=row["tenant_name"],
normalizedName=row["normalized_name"],
fileId=int(row["file_id"]) if row["file_id"] is not None else None,
fileName=row["file_name"],
@@ -614,6 +683,11 @@ class DocumentServiceImpl(IDocumentService):
documentNumber=row["document_number"],
auditStatus=int(row["audit_status"]) if row["audit_status"] is not None else None,
isTestDocument=bool(row["is_test_document"]),
pageQualityRunId=quality.get("pageQualityRunId"),
pageQualityRunStatus=quality.get("pageQualityRunStatus"),
pageQualitySummaryStatus=quality.get("pageQualitySummaryStatus"),
pageQualityIssueCount=int(quality.get("pageQualityIssueCount") or 0),
pageQualityWarningText=quality.get("pageQualityWarningText"),
updatedAt=row["updated_at"].isoformat() if row["updated_at"] else None,
hasHistory=bool(row["has_history"]),
totalVersions=int(row["total_versions"] or 1),
@@ -872,6 +946,10 @@ class DocumentServiceImpl(IDocumentService):
params,
)
).mappings().all()
page_quality_map = await self._loadLatestPageQualitySummaryMap(
Session,
[int(row["document_id"]) for row in rows],
)
return [
DocumentStatusItemVO(
@@ -880,6 +958,15 @@ class DocumentServiceImpl(IDocumentService):
runStatus=row["run_status"],
phase=row["phase"],
resultStatus=row["result_status"],
pageQualityRunId=page_quality_map.get(int(row["document_id"]), {}).get("pageQualityRunId"),
pageQualityRunStatus=page_quality_map.get(int(row["document_id"]), {}).get("pageQualityRunStatus"),
pageQualitySummaryStatus=page_quality_map.get(int(row["document_id"]), {}).get("pageQualitySummaryStatus"),
pageQualityReviewPageCount=int(
page_quality_map.get(int(row["document_id"]), {}).get("pageQualityReviewPageCount") or 0
),
pageQualityRejectPageCount=int(
page_quality_map.get(int(row["document_id"]), {}).get("pageQualityRejectPageCount") or 0
),
updatedAt=row["updated_at"].isoformat() if row["updated_at"] else None,
)
for row in rows
@@ -930,7 +1017,7 @@ class DocumentServiceImpl(IDocumentService):
versionLabel = f"v{int(detail.versionNo or 1)}"
objectKey = OssPathUtils.BuildBusinessDocKey(
Region=detail.region or "default",
Region=detail.region or "公共",
TypeCode=detail.typeCode or "contract",
DocumentId=DocumentId,
Version=versionLabel,
@@ -1639,6 +1726,7 @@ class DocumentServiceImpl(IDocumentService):
d.biz_document_id,
d.type_id,
d.group_id,
COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code,
d.region,
d.processing_status,
d.version_group_key,
@@ -1680,7 +1768,8 @@ class DocumentServiceImpl(IDocumentService):
"bizDocumentId": time.time_ns(),
"typeId": int(sourceRow["type_id"]) if sourceRow["type_id"] is not None else None,
"groupId": int(sourceRow["group_id"]) if sourceRow["group_id"] is not None else None,
"region": str(sourceRow["region"] or "default").strip() or "default",
"tenantCode": sourceRow["tenant_code"],
"region": str(sourceRow["region"] or "公共").strip() or "公共",
"processingStatus": "waiting",
"versionGroupKey": str(sourceRow["version_group_key"] or uuid.uuid4().hex),
"versionNo": int(sourceRow["version_no"] or 1) + 1,
@@ -1724,7 +1813,7 @@ class DocumentServiceImpl(IDocumentService):
IncludeAttachments=False,
)
await Session.flush()
return newDocument, resolvedTypeCode, str(sourceRow["region"] or "default").strip() or "default"
return newDocument, resolvedTypeCode, str(sourceRow["region"] or "公共").strip() or "公共"
def _normalizeAttachmentMergeMode(self, MergeMode: str | None) -> str:
"""标准化附件合并模式。"""
@@ -1887,7 +1976,7 @@ class DocumentServiceImpl(IDocumentService):
targetDocumentId = int(documentMeta["document_id"])
targetVersionNo = int(documentMeta["version_no"] or 1)
normalizedRegion = str(documentMeta["region"] or "default").strip() or "default"
normalizedRegion = str(documentMeta["region"] or "公共").strip() or "公共"
if normalizedMergeMode == "new":
newDocument, resolvedTypeCode, normalizedRegion = await self._createNextVersionFromExistingDocument(
@@ -1939,10 +2028,20 @@ class DocumentServiceImpl(IDocumentService):
await Session.commit()
refreshed = await self._getDocumentDetail(Session, targetDocumentId, CurrentUserId, currentUser, documentColumns)
if not refreshed:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
return refreshed
try:
await self.PageQualityService.DispatchForDocument(
DocumentId=targetDocumentId,
TriggerUserId=CurrentUserId,
Force=True,
Speed="normal",
)
except Exception as exc:
logger.warning("dispatch page quality after append attachments failed for document_id=%s: %s", targetDocumentId, exc)
refreshed = await self.GetDocument(CurrentUserId=CurrentUserId, Id=targetDocumentId)
if not refreshed:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
return refreshed
async def ListDocumentTypes(self, Ids: list[int] | None = None, EntryModuleId: int | None = None) -> list[DocumentTypeItemVO]:
@@ -2004,7 +2103,7 @@ class DocumentServiceImpl(IDocumentService):
},
)
).scalar_one()
await self._syncRuleBindings(Session, int(row), Body.ruleSetIds, "default")
await self._syncRuleBindings(Session, int(row), Body.ruleSetIds, self._PUBLIC_REGION)
await sync_group_bindings_from_doc_type(Session, int(row), Body.ruleSetIds)
await Session.commit()
@@ -2047,7 +2146,7 @@ class DocumentServiceImpl(IDocumentService):
await Session.execute(text(f"UPDATE leaudit_document_types SET {', '.join(sets)} WHERE id = :id"), params)
if "ruleSetIds" in providedFields and Body.ruleSetIds is not None:
await self._syncRuleBindings(Session, Id, Body.ruleSetIds, "default")
await self._syncRuleBindings(Session, Id, Body.ruleSetIds, self._PUBLIC_REGION)
await sync_group_bindings_from_doc_type(Session, Id, Body.ruleSetIds)
await Session.commit()
@@ -2598,6 +2697,14 @@ class DocumentServiceImpl(IDocumentService):
async def _ensureDocumentGroupColumn(self, Session) -> None:
"""渐进式补齐文档二级分组字段,避免旧环境缺列。"""
await Session.execute(
text(
"""
ALTER TABLE leaudit_documents
ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64)
"""
)
)
await Session.execute(
text(
"""
@@ -2610,6 +2717,9 @@ class DocumentServiceImpl(IDocumentService):
await Session.execute(
text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_group_id ON leaudit_documents(group_id)")
)
await Session.execute(
text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_tenant_code ON leaudit_documents(tenant_code)")
)
async def _resolveDocumentGroupId(self, Session, TypeId: int, GroupId: int | None) -> int | None:
"""校验上传时选择的二级分组是否属于当前文档类型。"""
@@ -2684,6 +2794,93 @@ class DocumentServiceImpl(IDocumentService):
return int(row["root_group_id"])
return None
async def _loadLatestPageQualitySummaryMap(
self,
Session,
DocumentIds: list[int],
) -> dict[int, dict[str, Any]]:
"""批量读取文档最新页级模糊摘要。"""
normalized_ids = [int(document_id) for document_id in DocumentIds if int(document_id) > 0]
if not normalized_ids:
return {}
if not await self._tableExists(Session, "leaudit_page_quality_runs"):
return {}
rows = (
await Session.execute(
text(
"""
SELECT DISTINCT ON (document_id)
document_id,
id AS run_id,
status AS run_status,
summary_status,
total_pages,
review_page_count,
reject_page_count
FROM leaudit_page_quality_runs
WHERE document_id = ANY(:document_ids)
AND deleted_at IS NULL
ORDER BY document_id, id DESC
"""
),
{"document_ids": normalized_ids},
)
).mappings().all()
result: dict[int, dict[str, Any]] = {}
for row in rows:
document_id = int(row["document_id"])
review_count = int(row["review_page_count"] or 0)
reject_count = int(row["reject_page_count"] or 0)
issue_count = review_count + reject_count
summary_status = str(row["summary_status"] or "") or None
warning_text = None
if issue_count > 0 and summary_status:
pages = await self._loadPageQualityIssuePages(Session, int(row["run_id"]))
warning_text = self._buildPageQualityWarningText(pages, summary_status)
result[document_id] = {
"pageQualityRunId": int(row["run_id"]),
"pageQualityRunStatus": str(row["run_status"] or "") or None,
"pageQualitySummaryStatus": summary_status,
"pageQualityTotalPages": int(row["total_pages"] or 0),
"pageQualityReviewPageCount": review_count,
"pageQualityRejectPageCount": reject_count,
"pageQualityIssueCount": issue_count,
"pageQualityWarningText": warning_text,
}
return result
async def _loadPageQualityIssuePages(self, Session, RunId: int) -> list[int]:
"""读取单次运行的异常页码列表。"""
if not await self._tableExists(Session, "leaudit_page_quality_results"):
return []
rows = (
await Session.execute(
text(
"""
SELECT DISTINCT page_num
FROM leaudit_page_quality_results
WHERE run_id = :run_id
AND quality_status IN ('review', 'reject')
ORDER BY page_num ASC
"""
),
{"run_id": RunId},
)
).mappings().all()
return [int(row["page_num"]) for row in rows if row["page_num"] is not None]
def _buildPageQualityWarningText(self, Pages: list[int], SummaryStatus: str | None) -> str | None:
"""组装页级模糊预警文案。"""
if not Pages or not SummaryStatus:
return None
pages_text = "".join(f"{page}" for page in Pages[:10])
suffix = "建议重拍" if SummaryStatus == "reject" else "疑似模糊"
if len(Pages) > 10:
pages_text = f"{pages_text}"
return f"{pages_text}{suffix}"
async def _getDocumentDetail(
self,
Session,
@@ -2725,6 +2922,14 @@ class DocumentServiceImpl(IDocumentService):
"d.remark AS remark" if "remark" in DocumentColumns else "NULL::text AS remark",
"d.is_test_document AS is_test_document" if "is_test_document" in DocumentColumns else "FALSE AS is_test_document",
"d.audit_status AS audit_status" if "audit_status" in DocumentColumns else "NULL::integer AS audit_status",
"COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code",
"""
CASE
WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共'
WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级'
ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '')
END AS tenant_name
""".strip(),
]
detailRow = (
@@ -2890,6 +3095,23 @@ class DocumentServiceImpl(IDocumentService):
)
for row in attachmentRows
]
page_quality_map = await self._loadLatestPageQualitySummaryMap(Session, [int(detailRow["document_id"])])
quality = page_quality_map.get(int(detailRow["document_id"]), {})
page_quality_pages = []
if quality.get("pageQualityRunId") is not None:
page_quality_pages = await self._loadPageQualityIssuePages(Session, int(quality["pageQualityRunId"]))
page_quality_summary = None
if quality:
page_quality_summary = PageQualitySummaryVO(
runId=quality.get("pageQualityRunId"),
runStatus=quality.get("pageQualityRunStatus"),
summaryStatus=quality.get("pageQualitySummaryStatus"),
totalPages=int(quality.get("pageQualityTotalPages") or 0),
reviewPageCount=int(quality.get("pageQualityReviewPageCount") or 0),
rejectPageCount=int(quality.get("pageQualityRejectPageCount") or 0),
warningText=quality.get("pageQualityWarningText"),
pages=page_quality_pages,
)
return DocumentDetailVO(
documentId=int(detailRow["document_id"]),
@@ -2904,6 +3126,8 @@ class DocumentServiceImpl(IDocumentService):
groupId=int(detailRow["group_id"]) if detailRow["group_id"] is not None else None,
groupName=detailRow["group_name"],
region=str(detailRow["region"] or ""),
tenantCode=detailRow["tenant_code"],
tenantName=detailRow["tenant_name"],
normalizedName=detailRow["normalized_name"],
fileId=int(detailRow["file_id"]) if detailRow["file_id"] is not None else None,
fileName=detailRow["file_name"],
@@ -2927,7 +3151,13 @@ class DocumentServiceImpl(IDocumentService):
remark=detailRow["remark"],
isTestDocument=bool(detailRow["is_test_document"]),
auditStatus=int(detailRow["audit_status"]) if detailRow["audit_status"] is not None else None,
pageQualityRunId=quality.get("pageQualityRunId"),
pageQualityRunStatus=quality.get("pageQualityRunStatus"),
pageQualitySummaryStatus=quality.get("pageQualitySummaryStatus"),
pageQualityIssueCount=int(quality.get("pageQualityIssueCount") or 0),
pageQualityWarningText=quality.get("pageQualityWarningText"),
pageCount=None,
pageQualitySummary=page_quality_summary,
attachments=attachments,
)
@@ -2971,31 +3201,52 @@ class DocumentServiceImpl(IDocumentService):
DocumentAlias: str,
FileAlias: str,
RequestedRegion: str | None = None,
RequestedTenantCode: str | None = None,
RequestedUserId: int | None = None,
) -> list[str]:
"""根据当前用户角色与地区构建文档数据范围过滤。"""
"""根据当前用户角色与租户/地区构建文档数据范围过滤。"""
filters: list[str] = []
requestedRegion = (RequestedRegion or "").strip()
area = str(CurrentUser["area"] or "").strip()
requested_tenant_code, requested_region = self._normalize_scope_value(RequestedRegion, RequestedTenantCode, CurrentUser)
current_tenant_code = str(CurrentUser.get("tenant_code") or "").strip()
current_region = str(CurrentUser.get("tenant_scope_value") or CurrentUser.get("area") or "").strip()
if CurrentUser["is_global"]:
if requestedRegion:
if requested_tenant_code:
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
)
elif requested_region:
Params["requested_region"] = requested_region
filters.append(f"{DocumentAlias}.region = :requested_region")
Params["requested_region"] = requestedRegion
if RequestedUserId is not None:
filters.append(f"{FileAlias}.created_by = :requested_user_id")
Params["requested_user_id"] = RequestedUserId
return filters
if CurrentUser["can_manage"]:
if not area:
filters.append("1 = 0")
return filters
if requestedRegion and requestedRegion != area:
filters.append("1 = 0")
return filters
filters.append(f"{DocumentAlias}.region = :scope_region")
Params["scope_region"] = area
if not current_tenant_code and not current_region:
return ["1 = 0"]
effective_tenant_code = current_tenant_code or requested_tenant_code
effective_region = current_region or requested_region
if current_tenant_code and requested_tenant_code and requested_tenant_code != current_tenant_code:
return ["1 = 0"]
if requested_region and current_region and requested_region != current_region:
return ["1 = 0"]
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenant_code=effective_tenant_code or None,
region=effective_region or None,
prefix="scope",
)
)
if RequestedUserId is not None:
filters.append(f"{FileAlias}.created_by = :requested_user_id")
Params["requested_user_id"] = RequestedUserId
@@ -3003,9 +3254,37 @@ class DocumentServiceImpl(IDocumentService):
filters.append(f"{FileAlias}.created_by = :scope_user_id")
Params["scope_user_id"] = CurrentUserId
if requestedRegion:
filters.append(f"{DocumentAlias}.region = :requested_region")
Params["requested_region"] = requestedRegion
if requested_tenant_code:
if current_tenant_code and requested_tenant_code != current_tenant_code:
filters.append("1 = 0")
elif not current_tenant_code and current_region and requested_region != current_region:
filters.append("1 = 0")
else:
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenant_code=(current_tenant_code or requested_tenant_code) or None,
region=(current_region or requested_region) or None,
prefix="requested",
)
)
elif requested_region:
if current_region and requested_region != current_region:
filters.append("1 = 0")
elif current_tenant_code:
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenant_code=current_tenant_code or None,
region=current_region or requested_region,
prefix="requested",
)
)
else:
Params["requested_region"] = requested_region
filters.append(f"{DocumentAlias}.region = :requested_region")
if RequestedUserId is not None and RequestedUserId != CurrentUserId:
filters.append("1 = 0")
return filters
@@ -3013,13 +3292,21 @@ class DocumentServiceImpl(IDocumentService):
async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]:
"""加载当前用户上下文,用于文档数据隔离。"""
async with GetAsyncSession() as Session:
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="''",
)
row = (
await Session.execute(
text(
"""
f"""
SELECT
u.id,
COALESCE(u.area, '') AS area,
{tenant_code_select},
COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global,
COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage,
COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin
@@ -3035,13 +3322,112 @@ class DocumentServiceImpl(IDocumentService):
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在")
tenant_resolution = await self.TenantResolver.ResolveUserContext(
Area=str(row["area"] or ""),
TenantCode=str(row["tenant_code"] or ""),
TenantName=None,
Source="document_user",
)
return {
"area": str(row["area"] or ""),
"area": tenant_resolution.tenant_name or tenant_resolution.normalized_value or str(row["area"] or ""),
"tenant_code": tenant_resolution.tenant_code or "",
"tenant_name": tenant_resolution.tenant_name or "",
"tenant_scope_value": tenant_resolution.tenant_name or tenant_resolution.normalized_value or str(row["area"] or ""),
"is_global": bool(row["is_global"]),
"can_manage": bool(row["can_manage"]),
"is_super_admin": bool(row["is_super_admin"]),
}
async def _resolve_document_region(
self,
*,
Region: str | None,
TenantCode: str | None,
TenantName: str | None,
) -> str:
resolution = await self.TenantResolver.Resolve(
RawValue=Region,
Source="document_region",
PreferredTenantCode=str(TenantCode or "").strip() or None,
FallbackTenantName=TenantName,
)
normalized = resolution.tenant_name or resolution.normalized_value or ""
return normalized.strip() or "公共"
@staticmethod
def _normalize_region_value(value: str | None) -> str:
if value is None:
return ""
trimmed = str(value).strip()
return trimmed
def _normalize_scope_value(
self,
requestedRegion: str | None,
requestedTenantCode: str | None,
currentUser: dict[str, Any],
) -> tuple[str | None, str]:
tenant_code = str(requestedTenantCode or "").strip()
if tenant_code:
if tenant_code == str(currentUser.get("tenant_code") or "").strip():
return (
str(currentUser.get("tenant_code") or "").strip() or None,
str(currentUser.get("tenant_scope_value") or currentUser.get("tenant_name") or currentUser.get("area") or "").strip(),
)
if tenant_code == "PUBLIC":
return "PUBLIC", self._PUBLIC_REGION
if tenant_code == "PROVINCIAL":
return "PROVINCIAL", "省级"
return tenant_code or None, str(requestedRegion or "").strip()
return None, self._normalize_region_value(requestedRegion)
def _resolve_upload_region(
self,
currentUser: dict[str, Any],
requestedRegion: str | None,
requestedTenantCode: str | None = None,
) -> str:
requestedTenantCodeNormalized, normalizedRequestedRegion = self._normalize_scope_value(
requestedRegion,
requestedTenantCode,
currentUser,
)
currentTenantCode = str(currentUser.get("tenant_code") or "").strip()
currentRegion = str(currentUser.get("tenant_scope_value") or currentUser.get("area") or "").strip()
if currentUser["is_global"]:
return normalizedRequestedRegion or currentRegion or self._PUBLIC_REGION
if requestedTenantCodeNormalized:
if currentTenantCode and requestedTenantCodeNormalized != currentTenantCode:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户")
if not currentTenantCode and currentRegion and normalizedRequestedRegion != currentRegion:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户")
elif normalizedRequestedRegion and currentRegion and normalizedRequestedRegion != currentRegion:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户")
return currentRegion or normalizedRequestedRegion or self._PUBLIC_REGION
def _document_tenant_filter_sql(
self,
params: dict[str, Any],
*,
DocumentAlias: str,
tenant_code: str | None,
region: str | None,
prefix: str,
) -> list[str]:
normalized_region = str(region or "").strip()
normalized_tenant_code = str(tenant_code or "").strip()
if normalized_tenant_code:
params[f"{prefix}_tenant_code"] = normalized_tenant_code
return [f"{DocumentAlias}.tenant_code = :{prefix}_tenant_code"]
if normalized_region:
params[f"{prefix}_region"] = normalized_region
return [f"{DocumentAlias}.region = :{prefix}_region"]
return ["1 = 0"]
async def _loadReviewRunRow(self, Session, Detail: DocumentDetailVO) -> dict[str, Any] | None:
"""定位当前文档可展示的最新评查运行。"""
params: dict[str, object] = {"document_id": Detail.documentId}
@@ -3171,6 +3557,8 @@ class DocumentServiceImpl(IDocumentService):
"groupId": Detail.groupId,
"groupName": Detail.groupName,
"region": Detail.region,
"tenantCode": Detail.tenantCode,
"tenantName": Detail.tenantName,
"auditStatus": Detail.auditStatus or 0,
"audit_status": Detail.auditStatus or 0,
"uploadTime": _format_iso_datetime(createdAt) or Detail.updatedAt or "",
@@ -3772,8 +4160,9 @@ class DocumentServiceImpl(IDocumentService):
return str(Row["skip_reason"])
return str(Row.get("rule_name") or Row.get("rule_id") or "")
async def _syncRuleBindings(self, Session, DocTypeId: int, RuleSetIds: list[int], Region: str = "default") -> None:
async def _syncRuleBindings(self, Session, DocTypeId: int, RuleSetIds: list[int], Region: str | None = None) -> None:
"""全量替换规则绑定。"""
normalizedRegion = self._normalize_binding_region(Region)
await Session.execute(
text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"),
{"id": DocTypeId},
@@ -3786,15 +4175,22 @@ class DocumentServiceImpl(IDocumentService):
VALUES (:doc_type_id, :rule_set_id, 'explicit', :priority, :region, true, NOW(), NOW())
"""
),
{"doc_type_id": DocTypeId, "rule_set_id": ruleSetId, "priority": 100 - idx, "region": Region},
{"doc_type_id": DocTypeId, "rule_set_id": ruleSetId, "priority": 100 - idx, "region": normalizedRegion},
)
def _normalize_binding_region(self, region: str | None) -> str:
normalized = str(region or "").strip()
if normalized in {"", "default", "省级"}:
return self._PUBLIC_REGION
return normalized
async def _find_latest_version_candidate(
session,
*,
type_id: int,
root_group_id: int | None,
tenant_code: str | None,
region: str,
normalized_name: str,
file_ext: str | None = None,
@@ -3809,6 +4205,7 @@ async def _find_latest_version_candidate(
if root_group_id is not None:
params: dict[str, object] = {
"root_group_id": root_group_id,
"tenant_code": str(tenant_code or "").strip(),
"region": region,
"normalized_name": normalized_name,
**ext_params,
@@ -3842,7 +4239,14 @@ async def _find_latest_version_candidate(
GROUP BY document_type_id
) dg
ON dg.document_type_id = d.type_id
WHERE d.region = :region
WHERE (
d.tenant_code = :tenant_code
OR (
(:tenant_code = '')
AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '')
AND d.region = :region
)
)
AND d.normalized_name = :normalized_name
AND d.is_latest_version = true
AND d.deleted_at IS NULL{ext_clause}
@@ -3866,6 +4270,7 @@ async def _find_latest_version_candidate(
params = {
"type_id": type_id,
"tenant_code": str(tenant_code or "").strip(),
"region": region,
"normalized_name": normalized_name,
**ext_params,
@@ -3886,7 +4291,14 @@ async def _find_latest_version_candidate(
AND f.is_active = true
AND f.file_role = 'primary'
WHERE d.type_id = :type_id
AND d.region = :region
AND (
d.tenant_code = :tenant_code
OR (
(:tenant_code = '')
AND (d.tenant_code IS NULL OR BTRIM(d.tenant_code) = '')
AND d.region = :region
)
)
AND d.normalized_name = :normalized_name{ext_clause}
AND d.is_latest_version = true
AND d.deleted_at IS NULL