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