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
@@ -31,12 +31,16 @@ from fastapi_modules.fastapi_leaudit.govdoc_engine.reporter.html_renderer import
from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile
from fastapi_modules.fastapi_leaudit.services import IGovdocService, IOssService
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver
@dataclass(frozen=True)
class _GovdocDocumentRow:
documentId: int
region: str
tenantCode: str | None
tenantName: str | None
processingStatus: str
currentRunId: int | None
versionGroupKey: str | None
@@ -72,9 +76,10 @@ class _GovdocDocumentRow:
class GovdocServiceImpl(IGovdocService):
"""公文处理与格式审查服务实现。"""
def __init__(self, OssService: IOssService | None = None) -> None:
def __init__(self, OssService: IOssService | None = None, TenantResolverService: TenantResolver | None = None) -> None:
self.OssService = OssService or OssServiceImpl()
self.Storage = StorageAdapter()
self.TenantResolver = TenantResolverService or TenantResolver()
def _parse_date_filter(self, value: str | None, field_name: str) -> date | None:
if value is None:
@@ -96,7 +101,8 @@ class GovdocServiceImpl(IGovdocService):
self,
file: UploadFile,
typeId: int | None = None,
region: str = "default",
region: str | None = None,
tenantCode: str | None = None,
autoRun: bool = True,
speed: str = "normal",
ruleVersionId: int | None = None,
@@ -111,7 +117,12 @@ class GovdocServiceImpl(IGovdocService):
if not content:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上传文件内容不能为空")
normalizedRegion = (region or "default").strip() or "default"
requested_tenant = await self.TenantResolver.Resolve(
RawValue=(region or "").strip() or None,
Source="govdoc_upload_request",
PreferredTenantCode=str(tenantCode or "").strip() or None,
)
normalizedRegion = str(requested_tenant.tenant_name or requested_tenant.normalized_value or region or "").strip()
fileName = file.filename
fileExt = Path(fileName).suffix.lstrip(".").lower() or None
if fileExt != "docx":
@@ -128,10 +139,11 @@ class GovdocServiceImpl(IGovdocService):
await self._ensureGovdocSchema(session)
await self._backfill_missing_version_groups(session)
currentUser = await self._getCurrentUserContext(createdBy)
resolvedRegion = self._resolve_upload_region(currentUser, normalizedRegion)
resolvedRegion = self._resolve_upload_region(currentUser, normalizedRegion, tenantCode)
latestCandidate = await self._find_latest_version_candidate(
session,
region=resolvedRegion,
tenantCode=requested_tenant.tenant_code or currentUser.get("tenant_code"),
normalizedName=normalizedName,
fileExt=fileExt,
)
@@ -139,6 +151,7 @@ class GovdocServiceImpl(IGovdocService):
latestCandidate = await self._backfill_legacy_version_chain(
session,
region=resolvedRegion,
tenantCode=requested_tenant.tenant_code or currentUser.get("tenant_code"),
normalizedName=normalizedName,
fileExt=fileExt,
)
@@ -163,6 +176,7 @@ class GovdocServiceImpl(IGovdocService):
bizDocumentId=time.time_ns(),
typeId=typeId,
groupId=None,
tenantCode=requested_tenant.tenant_code or currentUser.get("tenant_code"),
region=resolvedRegion,
processingStatus="waiting",
currentRunId=None,
@@ -242,6 +256,8 @@ class GovdocServiceImpl(IGovdocService):
"fileId": documentFile.Id,
"fileName": documentFile.fileName,
"region": resolvedRegion,
"tenantCode": requested_tenant.tenant_code or currentUser.get("tenant_code"),
"tenantName": requested_tenant.tenant_name or resolvedRegion,
"engineType": "govdoc",
"processingStatus": "processing" if runPayload else (document.processingStatus or "waiting"),
"autoRunTriggered": shouldAutoRun,
@@ -256,6 +272,7 @@ class GovdocServiceImpl(IGovdocService):
keyword: str | None = None,
fileExt: str | None = None,
region: str | None = None,
tenantCode: str | None = None,
status: str | None = None,
resultStatus: str | None = None,
createdBy: int | None = None,
@@ -267,6 +284,11 @@ class GovdocServiceImpl(IGovdocService):
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录")
currentUser = await self._getCurrentUserContext(userId)
requested_tenant = await self.TenantResolver.Resolve(
RawValue=(region or "").strip() or None,
Source="govdoc_list_scope",
PreferredTenantCode=str(tenantCode or "").strip() or None,
)
page = max(1, int(page))
pageSize = max(1, min(int(pageSize), 100))
offset = (page - 1) * pageSize
@@ -292,7 +314,8 @@ class GovdocServiceImpl(IGovdocService):
Params=params,
DocumentAlias="d",
FileAlias="f",
RequestedRegion=region,
RequestedRegion=requested_tenant.tenant_name or requested_tenant.normalized_value or region,
RequestedTenantCode=requested_tenant.tenant_code or tenantCode,
RequestedUserId=createdBy,
)
)
@@ -327,7 +350,9 @@ class GovdocServiceImpl(IGovdocService):
WITH effective_docs AS (
SELECT
d.id AS document_id,
COALESCE(d.region, 'default') AS region,
COALESCE(NULLIF(d.region, ''), '公共') AS region,
COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code,
{self._tenant_name_sql('d')} AS tenant_name,
COALESCE(d.processing_status, 'waiting') AS processing_status,
d.current_run_id,
COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key,
@@ -419,28 +444,28 @@ class GovdocServiceImpl(IGovdocService):
SELECT
d2.id AS document_id,
COUNT(*) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
) AS total_versions,
ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_version_no,
LAG(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_previous_version_id,
FIRST_VALUE(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_root_version_id,
CASE
WHEN ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at DESC, d2.id DESC
) = 1 THEN true
ELSE false
END AS derived_is_latest_version,
md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key
md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key
FROM leaudit_documents d2
JOIN leaudit_document_files f2
ON f2.document_id = d2.id
@@ -565,7 +590,9 @@ class GovdocServiceImpl(IGovdocService):
f"""
SELECT
d.id AS document_id,
COALESCE(d.region, 'default') AS region,
COALESCE(NULLIF(d.region, ''), '公共') AS region,
COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code,
{self._tenant_name_sql('d')} AS tenant_name,
COALESCE(d.processing_status, 'waiting') AS processing_status,
d.current_run_id,
COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key,
@@ -657,28 +684,28 @@ class GovdocServiceImpl(IGovdocService):
SELECT
d2.id AS document_id,
COUNT(*) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
) AS total_versions,
ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_version_no,
LAG(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_previous_version_id,
FIRST_VALUE(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_root_version_id,
CASE
WHEN ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at DESC, d2.id DESC
) = 1 THEN true
ELSE false
END AS derived_is_latest_version,
md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key
md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key
FROM leaudit_documents d2
JOIN leaudit_document_files f2
ON f2.document_id = d2.id
@@ -767,6 +794,8 @@ class GovdocServiceImpl(IGovdocService):
"mimeType": mapped.mimeType,
"fileSize": mapped.fileSize,
"region": mapped.region,
"tenantCode": mapped.tenantCode,
"tenantName": mapped.tenantName or mapped.region,
"processingStatus": mapped.processingStatus,
"versionGroupKey": mapped.versionGroupKey,
"versionNo": mapped.versionNo,
@@ -853,7 +882,7 @@ class GovdocServiceImpl(IGovdocService):
currentUser = await self._getCurrentUserContext(triggerUserId)
documentMeta = await self._get_document_for_run(documentId, triggerUserId, currentUser)
if documentMeta.currentRunId and not force:
currentRun = await self.GetRunStatus(documentMeta.currentRunId)
currentRun = await self.GetRunStatus(documentMeta.currentRunId, userId=triggerUserId)
if currentRun["status"] in {"pending", "processing"}:
return {
"runId": documentMeta.currentRunId,
@@ -897,77 +926,17 @@ class GovdocServiceImpl(IGovdocService):
"taskId": str(getattr(task, "id", "") or ""),
}
async def GetRunStatus(self, runId: int) -> dict[str, Any]:
async with GetAsyncSession() as session:
await self._ensureGovdocSchema(session)
row = (
await session.execute(
text(
"""
SELECT
id,
document_id,
status,
phase,
result_status,
total_score,
passed_count,
failed_count,
skipped_count,
error_message,
task_id,
created_at,
updated_at,
started_at,
finished_at
FROM govdoc_runs
WHERE id = :run_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"run_id": runId},
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在")
async def GetRunStatus(self, runId: int, userId: int | None = None) -> dict[str, Any]:
row = await self._get_scoped_run_row(runId, userId=userId)
return self._build_run_summary(row)
# ── 结果与报告 ────────────────────────────────────────
async def GetRunResult(self, runId: int) -> dict[str, Any]:
async def GetRunResult(self, runId: int, userId: int | None = None) -> dict[str, Any]:
"""从 govdoc_runs + govdoc_rule_results 读取审查结果,含 structure/outline。"""
runRow = await self._get_scoped_run_row(runId, userId=userId)
async with GetAsyncSession() as session:
await self._ensureGovdocSchema(session)
runRow = (
await session.execute(
text(
"""
SELECT
id,
document_id,
status,
phase,
total_score,
passed_count,
failed_count,
skipped_count,
result_status,
result_summary_json,
started_at,
finished_at
FROM govdoc_runs
WHERE id = :run_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"run_id": runId},
)
).mappings().first()
if not runRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在")
documentRow = (
await session.execute(
text(
@@ -1096,16 +1065,16 @@ class GovdocServiceImpl(IGovdocService):
"entities": aux.get("entities", {}),
}
async def GetRunFindings(self, runId: int) -> dict[str, Any]:
result = await self.GetRunResult(runId)
async def GetRunFindings(self, runId: int, userId: int | None = None) -> dict[str, Any]:
result = await self.GetRunResult(runId, userId=userId)
return {"runId": runId, "findings": result["findings"]}
async def GetRunEntities(self, runId: int) -> dict[str, Any]:
result = await self.GetRunResult(runId)
async def GetRunEntities(self, runId: int, userId: int | None = None) -> dict[str, Any]:
result = await self.GetRunResult(runId, userId=userId)
return {"runId": runId, "entities": result.get("entities", {})}
async def GetRunParagraphs(self, runId: int) -> str:
runStatus = await self.GetRunStatus(runId)
async def GetRunParagraphs(self, runId: int, userId: int | None = None) -> str:
runStatus = await self.GetRunStatus(runId, userId=userId)
if runStatus["status"] != "completed":
raise LeauditException(
StatusCodeEnum.HTTP_409_CONFLICT,
@@ -1117,7 +1086,10 @@ class GovdocServiceImpl(IGovdocService):
content = await self.OssService.DownloadBytes(str(paragraphArtifact["oss_url"]))
return content.decode("utf-8")
documentMeta = await self._get_document_for_read(int(runStatus["documentId"]))
if userId is None:
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录")
currentUser = await self._getCurrentUserContext(userId)
documentMeta = await self._get_document_for_run(int(runStatus["documentId"]), userId, currentUser)
fileRow = await self._get_active_original_file(documentMeta.documentId)
ossUrl = getattr(fileRow, "ossUrl", None) or fileRow.get("oss_url")
@@ -1138,7 +1110,7 @@ class GovdocServiceImpl(IGovdocService):
)
try:
doc = parse_docx(tempPath)
findingsResult = await self.GetRunFindings(runId)
findingsResult = await self.GetRunFindings(runId, userId=userId)
findingMap: dict[int, list[str]] = {}
for finding in findingsResult["findings"]:
pi = int(finding.get("location", {}).get("paragraph_index") or 0)
@@ -1150,20 +1122,21 @@ class GovdocServiceImpl(IGovdocService):
except Exception:
pass
async def GetRunStructure(self, runId: int) -> dict[str, Any]:
result = await self.GetRunResult(runId)
async def GetRunStructure(self, runId: int, userId: int | None = None) -> dict[str, Any]:
result = await self.GetRunResult(runId, userId=userId)
return {"runId": runId, "structure": result.get("structure", [])}
async def GetRunOutline(self, runId: int) -> dict[str, Any]:
result = await self.GetRunResult(runId)
async def GetRunOutline(self, runId: int, userId: int | None = None) -> dict[str, Any]:
result = await self.GetRunResult(runId, userId=userId)
return {"runId": runId, "outline": result.get("outline", [])}
async def GetReportHtml(self, runId: int) -> dict[str, Any]:
result = await self.GetRunResult(runId)
async def GetReportHtml(self, runId: int, userId: int | None = None) -> dict[str, Any]:
result = await self.GetRunResult(runId, userId=userId)
html = render_html(self._build_audit_result_from_run_result(result))
return {"runId": runId, "html": html}
async def GetReportDocx(self, runId: int) -> dict[str, Any]:
async def GetReportDocx(self, runId: int, userId: int | None = None) -> dict[str, Any]:
await self._get_scoped_run_row(runId, userId=userId)
artifact = await self._get_report_artifact(runId, "annotated_docx")
if not artifact:
return {"runId": runId, "docxUrl": ""}
@@ -1172,7 +1145,11 @@ class GovdocServiceImpl(IGovdocService):
"docxUrl": await self.OssService.PresignGetUrl(str(artifact["oss_url"])),
}
async def DownloadOriginal(self, documentId: int) -> dict[str, Any]:
async def DownloadOriginal(self, documentId: int, userId: int | None = None) -> dict[str, Any]:
if userId is None:
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录")
currentUser = await self._getCurrentUserContext(userId)
await self._get_document_for_run(documentId, userId, currentUser)
fileRow = await self._get_active_original_file(documentId)
ossUrl = getattr(fileRow, "ossUrl", None) or fileRow.get("oss_url")
if not ossUrl:
@@ -1253,6 +1230,10 @@ class GovdocServiceImpl(IGovdocService):
async def _ensureGovdocSchema(self, session) -> None:
statements = [
"""
ALTER TABLE leaudit_documents
ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64)
""",
"""
ALTER TABLE leaudit_documents
ADD COLUMN IF NOT EXISTS engine_type VARCHAR(32) NOT NULL DEFAULT 'leaudit'
@@ -1329,6 +1310,10 @@ class GovdocServiceImpl(IGovdocService):
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_leaudit_documents_tenant_code
ON public.leaudit_documents(tenant_code) WHERE deleted_at IS NULL
""",
"""
CREATE INDEX IF NOT EXISTS idx_leaudit_documents_engine_type
ON public.leaudit_documents(engine_type) WHERE deleted_at IS NULL
""",
@@ -1376,13 +1361,28 @@ class GovdocServiceImpl(IGovdocService):
async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]:
async with GetAsyncSession() as session:
await self._backfill_missing_version_groups(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="''",
)
tenant_name_select = SsoUserCompat.optional_coalesce_as(
sso_user_columns,
alias="u",
column="tenant_name",
fallback_sql="''",
)
row = (
await session.execute(
text(
"""
f"""
SELECT
u.id,
COALESCE(u.area, '') AS area,
{tenant_code_select},
{tenant_name_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
@@ -1398,8 +1398,17 @@ class GovdocServiceImpl(IGovdocService):
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在")
tenant = await self.TenantResolver.ResolveUserContext(
Area=str(row["area"] or ""),
TenantCode=str(row.get("tenant_code") or "") or None,
TenantName=str(row.get("tenant_name") or "") or None,
Source="govdoc_user_context",
)
return {
"area": str(row["area"] or ""),
"area": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""),
"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 None),
"tenant_scope_value": tenant.tenant_name or tenant.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"]),
@@ -1413,14 +1422,26 @@ class GovdocServiceImpl(IGovdocService):
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()
requestedTenantCodeNormalized, requestedRegion = self._normalize_scope_value(RequestedRegion, RequestedTenantCode, CurrentUser)
currentTenantCode = str(CurrentUser.get("tenant_code") or "").strip()
area = str(CurrentUser.get("tenant_scope_value") or CurrentUser["area"] or "").strip()
if CurrentUser["is_global"]:
if requestedRegion:
if requestedTenantCodeNormalized:
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenantCode=requestedTenantCodeNormalized,
region=requestedRegion,
prefix="requested",
)
)
elif requestedRegion:
filters.append(f"{DocumentAlias}.region = :requested_region")
Params["requested_region"] = requestedRegion
if RequestedUserId is not None:
@@ -1429,14 +1450,23 @@ class GovdocServiceImpl(IGovdocService):
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 currentTenantCode and not area:
return ["1 = 0"]
effectiveTenantCode = currentTenantCode or requestedTenantCodeNormalized
effectiveRegion = area or requestedRegion
if currentTenantCode and requestedTenantCodeNormalized and requestedTenantCodeNormalized != currentTenantCode:
return ["1 = 0"]
if requestedRegion and area and requestedRegion != area:
return ["1 = 0"]
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenantCode=effectiveTenantCode or None,
region=effectiveRegion or None,
prefix="scope",
)
)
if RequestedUserId is not None:
filters.append(f"{FileAlias}.created_by = :requested_user_id")
Params["requested_user_id"] = RequestedUserId
@@ -1444,29 +1474,142 @@ class GovdocServiceImpl(IGovdocService):
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 requestedTenantCodeNormalized:
if currentTenantCode and requestedTenantCodeNormalized != currentTenantCode:
filters.append("1 = 0")
elif not currentTenantCode and area and requestedRegion != area:
filters.append("1 = 0")
else:
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenantCode=(currentTenantCode or requestedTenantCodeNormalized) or None,
region=(area or requestedRegion) or None,
prefix="requested",
)
)
elif requestedRegion:
if area and requestedRegion != area:
filters.append("1 = 0")
elif currentTenantCode:
filters.extend(
self._document_tenant_filter_sql(
Params,
DocumentAlias=DocumentAlias,
tenantCode=currentTenantCode or None,
region=area or requestedRegion,
prefix="requested",
)
)
else:
filters.append(f"{DocumentAlias}.region = :requested_region")
Params["requested_region"] = requestedRegion
if RequestedUserId is not None and RequestedUserId != CurrentUserId:
filters.append("1 = 0")
return filters
def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str) -> str:
area = str(currentUser["area"] or "").strip()
def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None, requestedTenantCode: str | None = None) -> str:
requestedTenantCodeNormalized, requestedRegion = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser)
currentTenantCode = str(currentUser.get("tenant_code") or "").strip()
area = str(currentUser.get("tenant_scope_value") or currentUser["area"] or "").strip()
if currentUser["is_global"]:
return requestedRegion or area or "default"
if currentUser["can_manage"]:
if area and requestedRegion and requestedRegion != area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本地区")
return area or requestedRegion or "default"
return requestedRegion or area or "公共"
if requestedTenantCodeNormalized:
if currentTenantCode and requestedTenantCodeNormalized != currentTenantCode:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户")
if not currentTenantCode and area and requestedRegion and requestedRegion != area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户")
return area or requestedRegion or "公共"
if area and requestedRegion and requestedRegion != area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人地区")
return area or requestedRegion or "default"
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人所属租户")
return area or requestedRegion or "公共"
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", "公共"
if tenant_code == "PROVINCIAL":
return "PROVINCIAL", "省级"
return tenant_code or None, str(requestedRegion or "").strip()
return None, str(requestedRegion or "").strip()
def _document_tenant_filter_sql(
self,
params: dict[str, Any],
*,
DocumentAlias: str,
tenantCode: str | None,
region: str | None,
prefix: str,
) -> list[str]:
normalizedRegion = str(region or "").strip()
normalizedTenantCode = str(tenantCode or "").strip()
if normalizedTenantCode:
params[f"{prefix}_tenant_code"] = normalizedTenantCode
return [f"{DocumentAlias}.tenant_code = :{prefix}_tenant_code"]
if normalizedRegion:
params[f"{prefix}_region"] = normalizedRegion
return [f"{DocumentAlias}.region = :{prefix}_region"]
return ["1 = 0"]
def _normalize_document_name(self, fileName: str) -> str:
suffix = Path(fileName).suffix
return fileName[: -len(suffix)] if suffix else fileName
def _tenant_name_sql(self, alias: str) -> str:
return (
"CASE "
f"WHEN NULLIF(BTRIM({alias}.tenant_code), '') = 'PUBLIC' THEN '公共' "
f"WHEN NULLIF(BTRIM({alias}.tenant_code), '') = 'PROVINCIAL' THEN '省级' "
f"ELSE COALESCE(NULLIF(BTRIM({alias}.region), ''), '公共') "
"END"
)
def _version_partition_key_sql(self, doc_alias: str, file_alias: str) -> str:
version_scope_expr = self._version_scope_key_sql(doc_alias)
return (
"CONCAT_WS('|', "
f"{version_scope_expr}, "
f"COALESCE({doc_alias}.normalized_name, ''), "
f"COALESCE({file_alias}.file_ext, '')"
")"
)
def _version_scope_key_sql(self, alias: str) -> str:
return (
"CASE "
f"WHEN NULLIF(BTRIM({alias}.tenant_code), '') IS NOT NULL "
f"THEN CONCAT('TENANT:', NULLIF(BTRIM({alias}.tenant_code), '')) "
f"ELSE CONCAT('REGION:', COALESCE(NULLIF(BTRIM({alias}.region), ''), '公共')) "
"END"
)
def _tenant_version_match_sql(self, doc_alias: str, tenant_param: str, region_param: str) -> str:
return (
"("
f"NULLIF(BTRIM({doc_alias}.tenant_code), '') = NULLIF(BTRIM(:{tenant_param}), '') "
"OR ("
f"(:{tenant_param} = '') "
f"AND ({doc_alias}.tenant_code IS NULL OR BTRIM({doc_alias}.tenant_code) = '') "
f"AND {doc_alias}.region = :{region_param}"
")"
")"
)
async def _get_document_for_run(
self,
documentId: int,
@@ -1500,7 +1643,9 @@ class GovdocServiceImpl(IGovdocService):
f"""
SELECT
d.id AS document_id,
COALESCE(d.region, 'default') AS region,
COALESCE(NULLIF(d.region, ''), '公共') AS region,
COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code,
{self._tenant_name_sql('d')} AS tenant_name,
COALESCE(d.processing_status, 'waiting') AS processing_status,
d.current_run_id,
COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key,
@@ -1568,28 +1713,28 @@ class GovdocServiceImpl(IGovdocService):
SELECT
d2.id AS document_id,
COUNT(*) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
) AS total_versions,
ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_version_no,
LAG(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_previous_version_id,
FIRST_VALUE(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_root_version_id,
CASE
WHEN ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at DESC, d2.id DESC
) = 1 THEN true
ELSE false
END AS derived_is_latest_version,
md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key
md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key
FROM leaudit_documents d2
JOIN leaudit_document_files f2
ON f2.document_id = d2.id
@@ -1620,7 +1765,9 @@ class GovdocServiceImpl(IGovdocService):
"""
SELECT
d.id AS document_id,
COALESCE(d.region, 'default') AS region,
COALESCE(NULLIF(d.region, ''), '公共') AS region,
COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL) AS tenant_code,
{self._tenant_name_sql('d')} AS tenant_name,
COALESCE(d.processing_status, 'waiting') AS processing_status,
d.current_run_id,
COALESCE(NULLIF(d.version_group_key, ''), fallback_vc.derived_version_group_key, '') AS version_group_key,
@@ -1688,28 +1835,28 @@ class GovdocServiceImpl(IGovdocService):
SELECT
d2.id AS document_id,
COUNT(*) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
) AS total_versions,
ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_version_no,
LAG(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_previous_version_id,
FIRST_VALUE(d2.id) OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at ASC, d2.id ASC
) AS derived_root_version_id,
CASE
WHEN ROW_NUMBER() OVER (
PARTITION BY d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, '')
PARTITION BY {self._version_partition_key_sql('d2', 'f2')}
ORDER BY d2.created_at DESC, d2.id DESC
) = 1 THEN true
ELSE false
END AS derived_is_latest_version,
md5(CONCAT_WS('|', d2.region, COALESCE(d2.normalized_name, ''), COALESCE(f2.file_ext, ''))) AS derived_version_group_key
md5({self._version_partition_key_sql('d2', 'f2')}) AS derived_version_group_key
FROM leaudit_documents d2
JOIN leaudit_document_files f2
ON f2.document_id = d2.id
@@ -1734,6 +1881,74 @@ class GovdocServiceImpl(IGovdocService):
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "公文文档不存在")
return self._map_document_row(row)
async def _get_scoped_run_row(self, runId: int, *, userId: int | None) -> dict[str, Any]:
if userId is None:
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录")
currentUser = await self._getCurrentUserContext(userId)
params: dict[str, Any] = {"run_id": runId}
filters = [
"gr.id = :run_id",
"gr.deleted_at IS NULL",
"d.deleted_at IS NULL",
"f.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'original'",
"COALESCE(d.engine_type, 'leaudit') = 'govdoc'",
]
filters.extend(
self._buildDocumentScopeFilters(
CurrentUserId=userId,
CurrentUser=currentUser,
Params=params,
DocumentAlias="d",
FileAlias="f",
)
)
whereClause = " AND ".join(filters)
async with GetAsyncSession() as session:
await self._ensureGovdocSchema(session)
row = (
await session.execute(
text(
f"""
SELECT
gr.id,
gr.document_id,
gr.status,
gr.phase,
gr.result_status,
gr.total_score,
gr.passed_count,
gr.failed_count,
gr.skipped_count,
gr.error_message,
gr.task_id,
gr.result_summary_json,
gr.created_at,
gr.updated_at,
gr.started_at,
gr.finished_at
FROM govdoc_runs gr
JOIN leaudit_documents d
ON d.id = gr.document_id
JOIN leaudit_document_files f
ON f.document_id = d.id
AND f.is_active = true
AND f.file_role = 'original'
AND f.deleted_at IS NULL
WHERE {whereClause}
LIMIT 1
"""
),
params,
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在或无权访问")
return dict(row)
async def _get_active_original_file(self, documentId: int):
async with GetAsyncSession() as session:
row = (
@@ -1866,7 +2081,9 @@ class GovdocServiceImpl(IGovdocService):
return _GovdocDocumentRow(
documentId=int(row["document_id"]),
region=str(row["region"] or "default"),
region=str(row["region"] or "公共"),
tenantCode=str(row["tenant_code"]) if row.get("tenant_code") else None,
tenantName=str(row["tenant_name"]) if row.get("tenant_name") else None,
processingStatus=str(row["processing_status"] or "waiting"),
currentRunId=int(row["current_run_id"]) if row.get("current_run_id") is not None else None,
versionGroupKey=str(row["version_group_key"]) if row.get("version_group_key") else None,
@@ -1921,6 +2138,8 @@ class GovdocServiceImpl(IGovdocService):
"mimeType": mapped.mimeType,
"fileSize": mapped.fileSize,
"region": mapped.region,
"tenantCode": mapped.tenantCode,
"tenantName": mapped.tenantName or mapped.region,
"processingStatus": mapped.processingStatus,
"currentRunId": mapped.currentRunId,
"latestRunId": mapped.currentRunId,
@@ -1955,12 +2174,20 @@ class GovdocServiceImpl(IGovdocService):
session,
*,
region: str,
tenantCode: str | None = None,
normalizedName: str,
fileExt: str | None,
) -> dict[str, Any] | None:
resolved_tenant = await self.TenantResolver.Resolve(
RawValue=region,
Source="govdoc_version_lookup",
PreferredTenantCode=str(tenantCode or "").strip() or None,
)
normalized_region = str(resolved_tenant.tenant_name or resolved_tenant.normalized_value or region or "公共").strip() or "公共"
extClause = ""
params: dict[str, Any] = {
"region": region,
"tenant_code": str(resolved_tenant.tenant_code or "").strip(),
"region": normalized_region,
"normalized_name": normalizedName,
}
if fileExt:
@@ -1985,7 +2212,7 @@ class GovdocServiceImpl(IGovdocService):
WHERE d.deleted_at IS NULL
AND d.review_scope = 'govdoc'
AND COALESCE(d.engine_type, 'leaudit') = 'govdoc'
AND d.region = :region
AND {self._tenant_version_match_sql('d', 'tenant_code', 'region')}
AND COALESCE(d.normalized_name, '') = :normalized_name
AND COALESCE(d.is_latest_version, true) = true{extClause}
ORDER BY d.version_no DESC, d.id DESC
@@ -2002,12 +2229,20 @@ class GovdocServiceImpl(IGovdocService):
session,
*,
region: str,
tenantCode: str | None = None,
normalizedName: str,
fileExt: str | None,
) -> dict[str, Any] | None:
resolved_tenant = await self.TenantResolver.Resolve(
RawValue=region,
Source="govdoc_version_backfill",
PreferredTenantCode=str(tenantCode or "").strip() or None,
)
normalized_region = str(resolved_tenant.tenant_name or resolved_tenant.normalized_value or region or "公共").strip() or "公共"
extClause = ""
params: dict[str, Any] = {
"region": region,
"tenant_code": str(resolved_tenant.tenant_code or "").strip(),
"region": normalized_region,
"normalized_name": normalizedName,
}
if fileExt:
@@ -2028,7 +2263,7 @@ class GovdocServiceImpl(IGovdocService):
WHERE d.deleted_at IS NULL
AND d.review_scope = 'govdoc'
AND COALESCE(d.engine_type, 'leaudit') = 'govdoc'
AND d.region = :region
AND {self._tenant_version_match_sql('d', 'tenant_code', 'region')}
AND COALESCE(d.normalized_name, '') = :normalized_name{extClause}
ORDER BY d.created_at ASC, d.id ASC
"""
@@ -2085,7 +2320,9 @@ class GovdocServiceImpl(IGovdocService):
text(
"""
SELECT
d.region,
COALESCE(NULLIF(BTRIM(d.region), ''), '公共') AS region,
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,
COALESCE(d.normalized_name, '') AS normalized_name,
COALESCE(f.file_ext, '') AS file_ext,
ARRAY_AGG(d.id ORDER BY d.created_at ASC, d.id ASC) AS document_ids,
@@ -2099,7 +2336,7 @@ class GovdocServiceImpl(IGovdocService):
WHERE d.deleted_at IS NULL
AND d.review_scope = 'govdoc'
AND COALESCE(d.engine_type, 'leaudit') = 'govdoc'
GROUP BY d.region, COALESCE(d.normalized_name, ''), COALESCE(f.file_ext, '')
GROUP BY COALESCE(NULLIF(BTRIM(d.region), ''), '公共'), COALESCE(NULLIF(BTRIM(d.tenant_code), ''), NULL), CASE WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PUBLIC' THEN '公共' WHEN NULLIF(BTRIM(d.tenant_code), '') = 'PROVINCIAL' THEN '省级' ELSE COALESCE(NULLIF(BTRIM(d.region), ''), '公共') END, COALESCE(d.normalized_name, ''), COALESCE(f.file_ext, '')
HAVING BOOL_OR(COALESCE(d.version_group_key, '') = '')
"""
)
@@ -2110,7 +2347,9 @@ class GovdocServiceImpl(IGovdocService):
return
for group in groups:
region = str(group["region"] or "default")
region = str(group["region"] or "公共")
tenantCode = str(group.get("tenant_code") or "").strip() or None
tenantName = str(group.get("tenant_name") or region).strip() or region
normalizedName = str(group["normalized_name"] or "")
fileExt = str(group["file_ext"] or "")
documentIds = [int(value) for value in (group["document_ids"] or [])]
@@ -2118,6 +2357,8 @@ class GovdocServiceImpl(IGovdocService):
continue
versionGroupKey = str(group["existing_version_group_key"] or "").strip() or self._derive_version_group_key(
tenantCode=tenantCode,
tenantName=tenantName,
region=region,
normalizedName=normalizedName,
fileExt=fileExt or None,
@@ -2153,8 +2394,21 @@ class GovdocServiceImpl(IGovdocService):
await session.commit()
def _derive_version_group_key(self, *, region: str, normalizedName: str, fileExt: str | None) -> str:
raw = f"{region}|{normalizedName}|{fileExt or ''}"
def _derive_version_group_key(
self,
*,
tenantCode: str | None,
tenantName: str | None,
region: str,
normalizedName: str,
fileExt: str | None,
) -> str:
versionScopeKey = (
f"TENANT:{tenantCode.strip()}"
if str(tenantCode or "").strip()
else f"REGION:{(tenantName or region or '公共').strip() or '公共'}"
)
raw = f"{versionScopeKey}|{normalizedName}|{fileExt or ''}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _resolve_ruleset_metadata(self, rulesPath: str | None) -> dict[str, str]: