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
@@ -26,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateSearchResultVO,
)
from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService
from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver
from fastapi_modules.fastapi_leaudit.services.ossService import IOssService
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
@@ -40,8 +42,9 @@ _ALLOWED_SORT_FIELDS = {
class ContractTemplateServiceImpl(IContractTemplateService):
"""合同模板服务实现。"""
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.TenantResolver = TenantResolverService or TenantResolver()
async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]:
count_select = "COUNT(t.id)::int AS template_count" if WithTemplateCount else "0::int AS template_count"
@@ -83,6 +86,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_id=Query.category_id,
category_name=Query.category_name,
region=Query.region,
tenant_code=Query.tenant_code,
file_format=Query.file_format,
is_featured=Query.is_featured,
currentUser=currentUser,
@@ -111,6 +115,14 @@ class ContractTemplateServiceImpl(IContractTemplateService):
c.icon AS category_icon,
c.description AS category_description,
t.region,
COALESCE(NULLIF(BTRIM(t.tenant_code), ''), NULL) AS tenant_code,
CASE
WHEN t.tenant_name IS NOT NULL AND BTRIM(t.tenant_name) <> '' THEN t.tenant_name
WHEN t.region IS NOT NULL AND BTRIM(t.region) <> '' THEN t.region
WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PUBLIC' THEN '公共'
WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PROVINCIAL' THEN '省级'
ELSE NULL
END AS tenant_name,
t.description,
t.file_path,
t.pdf_file_path,
@@ -148,13 +160,14 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_id=Query.category_id,
category_name=Query.category_name,
region=Query.region,
tenant_code=Query.tenant_code,
page=Query.page,
page_size=Query.page_size,
sort_by=Query.sort_by,
sort_order=Query.sort_order,
)
page_result = await self.ListTemplates(list_query, CurrentUserId)
category_stats = await self._load_search_category_stats(Query.q, Query.region, CurrentUserId)
category_stats = await self._load_search_category_stats(Query.q, Query.region, Query.tenant_code, CurrentUserId)
return ContractTemplateSearchResultVO(
total=page_result.total,
@@ -182,6 +195,14 @@ class ContractTemplateServiceImpl(IContractTemplateService):
c.icon AS category_icon,
c.description AS category_description,
t.region,
COALESCE(NULLIF(BTRIM(t.tenant_code), ''), NULL) AS tenant_code,
CASE
WHEN t.tenant_name IS NOT NULL AND BTRIM(t.tenant_name) <> '' THEN t.tenant_name
WHEN t.region IS NOT NULL AND BTRIM(t.region) <> '' THEN t.region
WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PUBLIC' THEN '公共'
WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PROVINCIAL' THEN '省级'
ELSE NULL
END AS tenant_name,
t.description,
t.file_path,
t.pdf_file_path,
@@ -252,7 +273,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
resolvedRegion = self._resolve_upload_region(currentUser, Body.region)
resolvedTenantCode, resolvedTenantName, resolvedRegion = self._resolve_upload_scope(currentUser, Body.region, Body.tenant_code)
categoryRow = (
await session.execute(
text(
@@ -276,17 +297,23 @@ class ContractTemplateServiceImpl(IContractTemplateService):
"""
SELECT id
FROM contract_templates
WHERE region = :region
WHERE (
tenant_code = :tenant_code
OR (
(tenant_code IS NULL OR BTRIM(tenant_code) = '')
AND region = :region
)
)
AND template_code = :template_code
AND deleted_at IS NULL
LIMIT 1
"""
),
{"region": resolvedRegion, "template_code": normalizedCode},
{"tenant_code": resolvedTenantCode, "region": resolvedRegion, "template_code": normalizedCode},
)
).mappings().first()
if duplicateRow:
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前地区已存在模板编码 {normalizedCode}")
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前租户已存在模板编码 {normalizedCode}")
categoryName = str(categoryRow["name"] or "未分类")
objectKey = OssPathUtils.BuildContractTemplateKey(
@@ -325,6 +352,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
template_code,
title,
category_id,
tenant_code,
tenant_name,
region,
description,
file_path,
@@ -343,6 +372,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
:template_code,
:title,
:category_id,
:tenant_code,
:tenant_name,
:region,
:description,
:file_path,
@@ -365,6 +396,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
"template_code": normalizedCode,
"title": normalizedTitle,
"category_id": Body.category_id,
"tenant_code": resolvedTenantCode,
"tenant_name": resolvedTenantName,
"region": resolvedRegion,
"description": (Body.description or "").strip() or None,
"file_path": filePath,
@@ -433,6 +466,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
self,
keyword: str,
requestedRegion: str | None,
requestedTenantCode: str | None,
CurrentUserId: int,
) -> list[ContractTemplateSearchCategoryVO]:
clean_keyword = (keyword or "").strip()
@@ -443,7 +477,12 @@ class ContractTemplateServiceImpl(IContractTemplateService):
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=requestedRegion)
scope_filters = self._build_template_scope_filters(
currentUser,
params,
requestedRegion=requestedRegion,
requestedTenantCode=requestedTenantCode,
)
filters = [
"c.deleted_at IS NULL",
"t.deleted_at IS NULL",
@@ -487,6 +526,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_id: int | None,
category_name: str | None,
region: str | None,
tenant_code: str | None,
file_format: str | None,
is_featured: bool | None,
currentUser: dict[str, Any],
@@ -495,7 +535,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
params: dict[str, Any] = {}
needs_category_name_filter = False
filters.extend(self._build_template_scope_filters(currentUser, params, region))
filters.extend(self._build_template_scope_filters(currentUser, params, region, tenant_code))
if category_id is not None:
filters.append("t.category_id = :category_id")
@@ -543,6 +583,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
def _bind_expanding(self, *sql_objects_and_params: Any):
sql_objects = list(sql_objects_and_params[:-1])
params = sql_objects_and_params[-1]
if "visible_tenant_codes" in params:
sql_objects = [sql.bindparams(bindparam("visible_tenant_codes", expanding=True)) for sql in sql_objects]
if "visible_regions" in params:
sql_objects = [sql.bindparams(bindparam("visible_regions", expanding=True)) for sql in sql_objects]
return tuple(sql_objects)
@@ -559,6 +601,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
)
def _to_list_item_vo(self, row: Any) -> ContractTemplateListItemVO:
tenant_name = row.get("tenant_name") or row.get("region") or "省级"
return ContractTemplateListItemVO(
id=int(row["id"]),
template_code=str(row.get("template_code") or ""),
@@ -567,7 +610,9 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_name=row.get("category_name"),
category_icon=row.get("category_icon"),
description=row.get("description"),
region=str(row.get("region") or "省级"),
region=str(row.get("region") or tenant_name),
tenant_code=row.get("tenant_code"),
tenant_name=tenant_name,
file_path=row.get("file_path"),
pdf_file_path=row.get("pdf_file_path"),
file_format=str(row.get("file_format") or ""),
@@ -600,53 +645,145 @@ class ContractTemplateServiceImpl(IContractTemplateService):
currentUser: dict[str, Any],
params: dict[str, Any],
requestedRegion: str | None,
requestedTenantCode: str | None = None,
writable: bool = False,
) -> list[str]:
requested = (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["tenant_scope_value"] or currentUser["area"] or "").strip()
if currentUser["is_global"]:
if requested:
params["requested_region"] = requested
if requested_tenant_code:
return self._tenant_filter_sql(
params,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
if requested_region:
if requested_region in {"省级", "公共"}:
return self._tenant_filter_sql(
params,
tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC",
region=requested_region,
prefix="requested",
)
params["requested_region"] = requested_region
return ["t.region = :requested_region"]
return ["1=1"]
if writable:
if not area:
if not current_tenant_code and not current_region:
return ["1=0"]
if requested and requested != area:
if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code:
return ["1=0"]
params["scope_region"] = area
return ["t.region = :scope_region"]
if requested_tenant_code and not current_tenant_code:
return ["1=0"]
if requested_region and requested_region != current_region:
return ["1=0"]
return self._tenant_filter_sql(
params,
tenant_code=current_tenant_code or requested_tenant_code,
region=current_region or requested_region,
prefix="scope",
)
if currentUser["can_manage"]:
if not area:
if not current_tenant_code and not current_region:
return ["1=0"]
if requested:
if requested == "省级":
params["requested_region"] = requested
return ["t.region = :requested_region"]
if requested != area:
if requested_tenant_code:
if requested_tenant_code in {"PUBLIC", "PROVINCIAL"}:
return self._tenant_filter_sql(
params,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
if current_tenant_code and requested_tenant_code != current_tenant_code:
return ["1=0"]
params["requested_region"] = requested
return ["t.region = :requested_region"]
params["visible_regions"] = ["省级", area]
return ["t.region IN :visible_regions"]
if not current_tenant_code and requested_region != current_region:
return ["1=0"]
return self._tenant_filter_sql(
params,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
if requested_region:
if requested_region in {"省级", "公共"}:
return self._tenant_filter_sql(
params,
tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC",
region=requested_region,
prefix="requested",
)
if requested_region != current_region:
return ["1=0"]
return self._tenant_filter_sql(
params,
tenant_code=current_tenant_code or None,
region=current_region or requested_region,
prefix="requested",
)
return self._visible_tenant_filters(
params,
tenant_codes=["PROVINCIAL", "PUBLIC", current_tenant_code],
legacy_regions=["省级", "公共", current_region],
)
if requested:
if requested == "省级":
params["requested_region"] = requested
return ["t.region = :requested_region"]
if area and requested == area:
params["requested_region"] = requested
return ["t.region = :requested_region"]
if requested_tenant_code:
if requested_tenant_code in {"PUBLIC", "PROVINCIAL"}:
return self._tenant_filter_sql(
params,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
if current_tenant_code and requested_tenant_code == current_tenant_code:
return self._tenant_filter_sql(
params,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
if not current_tenant_code and current_region and requested_region == current_region:
return self._tenant_filter_sql(
params,
tenant_code=requested_tenant_code,
region=requested_region,
prefix="requested",
)
return ["1=0"]
if area:
params["visible_regions"] = ["省级", area]
return ["t.region IN :visible_regions"]
params["requested_region"] = "省级"
return ["t.region = :requested_region"]
if requested_region:
if requested_region in {"省级", "公共"}:
return self._tenant_filter_sql(
params,
tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC",
region=requested_region,
prefix="requested",
)
if current_region and requested_region == current_region:
return self._tenant_filter_sql(
params,
tenant_code=current_tenant_code or None,
region=current_region or requested_region,
prefix="requested",
)
return ["1=0"]
if current_tenant_code or current_region:
return self._visible_tenant_filters(
params,
tenant_codes=["PROVINCIAL", "PUBLIC", current_tenant_code],
legacy_regions=["省级", "公共", current_region],
)
return self._tenant_filter_sql(
params,
tenant_code="PROVINCIAL",
region="省级",
prefix="requested",
)
async def _getCurrentUserContext(self, CurrentUserId: int, session=None) -> dict[str, Any]:
own_session = False
@@ -655,13 +792,28 @@ class ContractTemplateServiceImpl(IContractTemplateService):
session_cm = GetAsyncSession()
session = await session_cm.__aenter__()
try:
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
FROM sso_users u
@@ -676,9 +828,18 @@ class ContractTemplateServiceImpl(IContractTemplateService):
).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="contract_template_user_context",
)
return {
"id": int(row["id"]),
"area": 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_area_admin": bool(row["can_manage"]) and not bool(row["is_global"]),
@@ -687,14 +848,104 @@ class ContractTemplateServiceImpl(IContractTemplateService):
if own_session:
await session_cm.__aexit__(None, None, None)
def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None) -> str:
_ = requestedRegion
area = str(currentUser["area"] or "").strip()
def _resolve_upload_scope(
self,
currentUser: dict[str, Any],
requestedRegion: str | None,
requestedTenantCode: str | None = None,
) -> tuple[str | None, str | None, str]:
requested_tenant_code, requested_region = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser)
current_tenant_code = str(currentUser.get("tenant_code") or "").strip() or None
current_tenant_name = str(currentUser.get("tenant_name") or "").strip() or None
current_region = str(currentUser["tenant_scope_value"] or currentUser["area"] or "").strip()
if not currentUser.get("is_area_admin"):
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持地区管理员上传合同模板")
if not area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前地区管理员账号未配置所属地区,无法上传合同模板")
return area
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持租户管理员上传合同模板")
if not current_region:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户管理员账号未配置所属租户,无法上传合同模板")
if requested_tenant_code and not current_tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前账号未配置标准租户编码,不能显式指定模板租户")
if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许上传到本人所属租户")
if requested_region and requested_region != current_region:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许上传到本人所属租户")
return current_tenant_code, current_tenant_name or current_region, current_region
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, str(requestedRegion or "").strip()
return None, str(requestedRegion or "").strip()
def _tenant_filter_sql(
self,
params: dict[str, Any],
*,
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
if normalized_region:
params[f"{prefix}_region"] = normalized_region
return [
"("
f"t.tenant_code = :{prefix}_tenant_code "
"OR ("
"(t.tenant_code IS NULL OR BTRIM(t.tenant_code) = '') "
f"AND t.region = :{prefix}_region"
")"
")"
]
return [f"t.tenant_code = :{prefix}_tenant_code"]
if normalized_region:
params[f"{prefix}_region"] = normalized_region
return [f"t.region = :{prefix}_region"]
return ["1=0"]
def _visible_tenant_filters(
self,
params: dict[str, Any],
*,
tenant_codes: list[str | None],
legacy_regions: list[str | None],
) -> list[str]:
normalized_tenant_codes = [code.strip() for code in tenant_codes if code and str(code).strip()]
normalized_regions = [region.strip() for region in legacy_regions if region and str(region).strip()]
if normalized_tenant_codes:
params["visible_tenant_codes"] = normalized_tenant_codes
if normalized_regions:
params["visible_regions"] = normalized_regions
return [
"("
"t.tenant_code IN :visible_tenant_codes "
"OR ("
"(t.tenant_code IS NULL OR BTRIM(t.tenant_code) = '') "
"AND t.region IN :visible_regions"
")"
")"
]
return ["t.tenant_code IN :visible_tenant_codes"]
if normalized_regions:
params["visible_regions"] = normalized_regions
return ["t.region IN :visible_regions"]
return ["1=0"]
async def _ensureContractTemplateSchema(self, session) -> None:
statements = [
@@ -716,6 +967,14 @@ class ContractTemplateServiceImpl(IContractTemplateService):
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64)
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(128)
""",
"""
ALTER TABLE contract_templates
ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500)
""",
"""
@@ -758,6 +1017,17 @@ class ContractTemplateServiceImpl(IContractTemplateService):
"""
)
)
await session.execute(
text(
"""
UPDATE contract_templates
SET tenant_name = region
WHERE (tenant_name IS NULL OR BTRIM(tenant_name) = '')
AND region IS NOT NULL
AND BTRIM(region) <> ''
"""
)
)
await session.execute(
text(
"""