feat: add tenant-scoped rule and permission management
This commit is contained in:
@@ -0,0 +1,868 @@
|
||||
"""租户主数据服务实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
|
||||
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.Dto.tenantDto import (
|
||||
TenantCreateDTO,
|
||||
TenantStatusUpdateDTO,
|
||||
TenantUpdateDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ruleTenantMaterializer import (
|
||||
GetRuleTenantMaterializerSingleton,
|
||||
RuleTenantMaterializer,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.tenantService import ITenantService
|
||||
|
||||
|
||||
class TenantServiceImpl(ITenantService):
|
||||
"""租户主数据服务实现。"""
|
||||
|
||||
_BUILTIN_TENANT_CODES: tuple[str, ...] = ("PUBLIC", "PROVINCIAL")
|
||||
_SUPPORTED_FEATURE_KEYS: tuple[str, ...] = (
|
||||
"home.entry_module",
|
||||
"documents.upload",
|
||||
"rag.dataset",
|
||||
)
|
||||
|
||||
def __init__(self, RuleTenantMaterializer: RuleTenantMaterializer | None = None) -> None:
|
||||
self._table_exists_cache: dict[str, bool] = {}
|
||||
self.RuleTenantMaterializer = RuleTenantMaterializer or GetRuleTenantMaterializerSingleton()
|
||||
|
||||
async def ListTenants(self, IncludeDisabled: bool = False) -> list[dict[str, Any]]:
|
||||
if not await self._table_exists("sys_tenants"):
|
||||
return await self._list_legacy_tenants()
|
||||
filters = ["t.deleted_at IS NULL"]
|
||||
params: dict[str, Any] = {}
|
||||
if not IncludeDisabled:
|
||||
filters.append("t.is_enabled = TRUE")
|
||||
|
||||
where_sql = " AND ".join(filters)
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
t.tenant_code,
|
||||
t.tenant_name,
|
||||
t.tenant_short_name,
|
||||
t.tenant_type,
|
||||
t.parent_tenant_code,
|
||||
t.display_order,
|
||||
t.is_enabled,
|
||||
t.is_builtin,
|
||||
t.is_public,
|
||||
t.can_host_entry_module,
|
||||
t.can_host_documents,
|
||||
t.can_host_rag,
|
||||
t.can_host_templates,
|
||||
t.ext,
|
||||
COALESCE(
|
||||
ARRAY(
|
||||
SELECT f.feature_key
|
||||
FROM sys_tenant_feature_flags f
|
||||
WHERE f.tenant_code = t.tenant_code
|
||||
AND f.deleted_at IS NULL
|
||||
AND f.is_enabled = TRUE
|
||||
ORDER BY f.feature_key ASC
|
||||
),
|
||||
ARRAY[]::VARCHAR[]
|
||||
) AS feature_keys,
|
||||
COALESCE(
|
||||
ARRAY(
|
||||
SELECT a.alias_value
|
||||
FROM sys_tenant_aliases a
|
||||
WHERE a.tenant_code = t.tenant_code
|
||||
AND a.deleted_at IS NULL
|
||||
AND a.is_enabled = TRUE
|
||||
ORDER BY
|
||||
CASE a.alias_type
|
||||
WHEN 'DISPLAY' THEN 1
|
||||
WHEN 'SHORT_NAME' THEN 2
|
||||
WHEN 'LEGACY_AREA' THEN 3
|
||||
ELSE 9
|
||||
END ASC,
|
||||
a.id ASC
|
||||
),
|
||||
ARRAY[]::VARCHAR[]
|
||||
) AS alias_values
|
||||
FROM sys_tenants t
|
||||
WHERE {where_sql}
|
||||
ORDER BY t.display_order ASC, t.id ASC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def ListTenantOptions(self, FeatureKey: str | None = None) -> list[dict[str, Any]]:
|
||||
if not await self._table_exists("sys_tenants"):
|
||||
items = await self._list_legacy_tenant_options()
|
||||
return items
|
||||
filters = ["t.deleted_at IS NULL", "t.is_enabled = TRUE"]
|
||||
params: dict[str, Any] = {}
|
||||
join_sql = ""
|
||||
if FeatureKey and FeatureKey.strip() and await self._table_exists("sys_tenant_feature_flags"):
|
||||
join_sql = """
|
||||
JOIN sys_tenant_feature_flags f
|
||||
ON f.tenant_code = t.tenant_code
|
||||
AND f.deleted_at IS NULL
|
||||
AND f.is_enabled = TRUE
|
||||
AND f.feature_key = :feature_key
|
||||
"""
|
||||
params["feature_key"] = FeatureKey.strip()
|
||||
|
||||
where_sql = " AND ".join(filters)
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
t.tenant_code,
|
||||
t.tenant_name,
|
||||
t.tenant_short_name,
|
||||
t.tenant_type,
|
||||
t.is_public,
|
||||
t.display_order
|
||||
FROM sys_tenants t
|
||||
{join_sql}
|
||||
WHERE {where_sql}
|
||||
ORDER BY t.display_order ASC, t.id ASC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def GetTenant(self, TenantCode: str) -> dict[str, Any] | None:
|
||||
tenant_code = str(TenantCode or "").strip()
|
||||
if not tenant_code:
|
||||
return None
|
||||
if not await self._table_exists("sys_tenants"):
|
||||
for item in await self._list_legacy_tenants():
|
||||
if str(item.get("tenant_code") or "").strip() == tenant_code:
|
||||
return item
|
||||
return None
|
||||
async with GetAsyncSession() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
t.tenant_code,
|
||||
t.tenant_name,
|
||||
t.tenant_short_name,
|
||||
t.tenant_type,
|
||||
t.parent_tenant_code,
|
||||
t.display_order,
|
||||
t.is_enabled,
|
||||
t.is_builtin,
|
||||
t.is_public,
|
||||
t.can_host_entry_module,
|
||||
t.can_host_documents,
|
||||
t.can_host_rag,
|
||||
t.can_host_templates,
|
||||
t.ext
|
||||
FROM sys_tenants t
|
||||
WHERE t.tenant_code = :tenant_code
|
||||
AND t.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def GetTenantFeatures(self, TenantCode: str) -> list[str]:
|
||||
tenant_code = str(TenantCode or "").strip()
|
||||
if not tenant_code:
|
||||
return []
|
||||
if not await self._table_exists("sys_tenant_feature_flags"):
|
||||
return []
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT feature_key
|
||||
FROM sys_tenant_feature_flags
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
AND is_enabled = TRUE
|
||||
ORDER BY feature_key ASC
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).all()
|
||||
return [str(row[0]) for row in rows]
|
||||
|
||||
async def GetTenantAliases(self, TenantCode: str) -> list[str]:
|
||||
tenant_code = str(TenantCode or "").strip()
|
||||
if not tenant_code:
|
||||
return []
|
||||
return await self._getTenantAliases(tenant_code)
|
||||
|
||||
async def CreateTenant(self, CurrentUserId: int, Body: TenantCreateDTO) -> dict[str, Any]:
|
||||
del CurrentUserId
|
||||
await self._ensureWritableTenantFoundation()
|
||||
|
||||
tenant_code = self._normalizeTenantCode(Body.tenant_code)
|
||||
tenant_name = self._normalizeRequiredText(Body.tenant_name, "租户名称")
|
||||
tenant_short_name = self._normalizeOptionalText(Body.tenant_short_name) or tenant_name
|
||||
tenant_type = self._normalizeTenantType(Body.tenant_type)
|
||||
parent_tenant_code = self._normalizeOptionalCode(Body.parent_tenant_code)
|
||||
feature_keys = self._normalizeFeatureKeys(Body.feature_keys)
|
||||
alias_values = self._normalizeAliasValues(Body.alias_values, tenant_name=tenant_name, tenant_short_name=tenant_short_name)
|
||||
ext = Body.ext or {}
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
exists = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM sys_tenants
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"租户编码已存在: {tenant_code}")
|
||||
|
||||
if parent_tenant_code:
|
||||
await self._assertTenantExists(session, parent_tenant_code, "父级租户不存在")
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_tenants (
|
||||
tenant_code,
|
||||
tenant_name,
|
||||
tenant_short_name,
|
||||
tenant_type,
|
||||
parent_tenant_code,
|
||||
display_order,
|
||||
is_enabled,
|
||||
is_builtin,
|
||||
is_public,
|
||||
can_host_entry_module,
|
||||
can_host_documents,
|
||||
can_host_rag,
|
||||
can_host_templates,
|
||||
ext,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
) VALUES (
|
||||
:tenant_code,
|
||||
:tenant_name,
|
||||
:tenant_short_name,
|
||||
:tenant_type,
|
||||
:parent_tenant_code,
|
||||
:display_order,
|
||||
:is_enabled,
|
||||
FALSE,
|
||||
:is_public,
|
||||
:can_host_entry_module,
|
||||
:can_host_documents,
|
||||
:can_host_rag,
|
||||
:can_host_templates,
|
||||
CAST(:ext AS jsonb),
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_code": tenant_code,
|
||||
"tenant_name": tenant_name,
|
||||
"tenant_short_name": tenant_short_name,
|
||||
"tenant_type": tenant_type,
|
||||
"parent_tenant_code": parent_tenant_code,
|
||||
"display_order": int(Body.display_order or 0),
|
||||
"is_enabled": bool(Body.is_enabled),
|
||||
"is_public": bool(Body.is_public),
|
||||
"can_host_entry_module": bool(Body.can_host_entry_module),
|
||||
"can_host_documents": bool(Body.can_host_documents),
|
||||
"can_host_rag": bool(Body.can_host_rag),
|
||||
"can_host_templates": bool(Body.can_host_templates),
|
||||
"ext": self._dumpJson(ext),
|
||||
},
|
||||
)
|
||||
await self._replaceTenantAliases(session, tenant_code, alias_values)
|
||||
await self._replaceTenantFeatures(session, tenant_code, feature_keys)
|
||||
await session.commit()
|
||||
|
||||
await self.RuleTenantMaterializer.MaterializeTenant(tenant_code)
|
||||
return await self._getTenantDetailOrFail(tenant_code)
|
||||
|
||||
async def UpdateTenant(self, CurrentUserId: int, TenantCode: str, Body: TenantUpdateDTO) -> dict[str, Any]:
|
||||
del CurrentUserId
|
||||
await self._ensureWritableTenantFoundation()
|
||||
tenant_code = self._normalizeTenantCode(TenantCode)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
current = await self._loadTenantRow(session, tenant_code)
|
||||
if not current:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在")
|
||||
|
||||
is_builtin = bool(current.get("is_builtin"))
|
||||
tenant_name = self._normalizeOptionalText(Body.tenant_name) if Body.tenant_name is not None else str(current.get("tenant_name") or "")
|
||||
if not tenant_name:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户名称不能为空")
|
||||
tenant_short_name = (
|
||||
self._normalizeOptionalText(Body.tenant_short_name)
|
||||
if Body.tenant_short_name is not None
|
||||
else self._normalizeOptionalText(current.get("tenant_short_name"))
|
||||
) or tenant_name
|
||||
tenant_type = self._normalizeTenantType(Body.tenant_type) if Body.tenant_type is not None else str(current.get("tenant_type") or "CUSTOM")
|
||||
parent_tenant_code = self._normalizeOptionalCode(Body.parent_tenant_code) if Body.parent_tenant_code is not None else current.get("parent_tenant_code")
|
||||
if parent_tenant_code == tenant_code:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "父级租户不能指向自身")
|
||||
if parent_tenant_code:
|
||||
await self._assertTenantExists(session, str(parent_tenant_code), "父级租户不存在", exclude_tenant_code=tenant_code)
|
||||
|
||||
if is_builtin and tenant_code in self._BUILTIN_TENANT_CODES:
|
||||
tenant_type = str(current.get("tenant_type") or tenant_type)
|
||||
parent_tenant_code = current.get("parent_tenant_code")
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE sys_tenants
|
||||
SET tenant_name = :tenant_name,
|
||||
tenant_short_name = :tenant_short_name,
|
||||
tenant_type = :tenant_type,
|
||||
parent_tenant_code = :parent_tenant_code,
|
||||
display_order = :display_order,
|
||||
is_public = :is_public,
|
||||
can_host_entry_module = :can_host_entry_module,
|
||||
can_host_documents = :can_host_documents,
|
||||
can_host_rag = :can_host_rag,
|
||||
can_host_templates = :can_host_templates,
|
||||
ext = CAST(:ext AS jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_code": tenant_code,
|
||||
"tenant_name": tenant_name,
|
||||
"tenant_short_name": tenant_short_name,
|
||||
"tenant_type": tenant_type,
|
||||
"parent_tenant_code": parent_tenant_code,
|
||||
"display_order": int(Body.display_order if Body.display_order is not None else current.get("display_order") or 0),
|
||||
"is_public": bool(Body.is_public if Body.is_public is not None else current.get("is_public")),
|
||||
"can_host_entry_module": bool(
|
||||
Body.can_host_entry_module if Body.can_host_entry_module is not None else current.get("can_host_entry_module")
|
||||
),
|
||||
"can_host_documents": bool(
|
||||
Body.can_host_documents if Body.can_host_documents is not None else current.get("can_host_documents")
|
||||
),
|
||||
"can_host_rag": bool(Body.can_host_rag if Body.can_host_rag is not None else current.get("can_host_rag")),
|
||||
"can_host_templates": bool(
|
||||
Body.can_host_templates if Body.can_host_templates is not None else current.get("can_host_templates")
|
||||
),
|
||||
"ext": self._dumpJson(Body.ext if Body.ext is not None else (current.get("ext") or {})),
|
||||
},
|
||||
)
|
||||
|
||||
if Body.alias_values is not None:
|
||||
alias_values = self._normalizeAliasValues(Body.alias_values, tenant_name=tenant_name, tenant_short_name=tenant_short_name)
|
||||
await self._replaceTenantAliases(session, tenant_code, alias_values)
|
||||
if Body.feature_keys is not None:
|
||||
feature_keys = self._normalizeFeatureKeys(Body.feature_keys)
|
||||
await self._replaceTenantFeatures(session, tenant_code, feature_keys)
|
||||
await session.commit()
|
||||
|
||||
return await self._getTenantDetailOrFail(tenant_code)
|
||||
|
||||
async def UpdateTenantStatus(self, CurrentUserId: int, TenantCode: str, Body: TenantStatusUpdateDTO) -> dict[str, Any]:
|
||||
del CurrentUserId
|
||||
await self._ensureWritableTenantFoundation()
|
||||
tenant_code = self._normalizeTenantCode(TenantCode)
|
||||
is_enabled = bool(Body.is_enabled)
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
current = await self._loadTenantRow(session, tenant_code)
|
||||
if not current:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在")
|
||||
if bool(current.get("is_builtin")) and tenant_code in self._BUILTIN_TENANT_CODES and not is_enabled:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "内建核心租户不允许禁用")
|
||||
if not is_enabled:
|
||||
await self._assertTenantCanBeDisabled(session, tenant_code)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE sys_tenants
|
||||
SET is_enabled = :is_enabled,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code, "is_enabled": is_enabled},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return await self._getTenantDetailOrFail(tenant_code)
|
||||
|
||||
async def _table_exists(self, table_name: str) -> bool:
|
||||
cached = self._table_exists_cache.get(table_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
exists = bool(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
self._table_exists_cache[table_name] = exists
|
||||
return exists
|
||||
|
||||
async def _list_legacy_tenants(self) -> list[dict[str, Any]]:
|
||||
async with GetAsyncSession() as session:
|
||||
user_rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT COALESCE(NULLIF(area, ''), '') AS area
|
||||
FROM sso_users
|
||||
WHERE deleted_at IS NULL
|
||||
AND COALESCE(NULLIF(area, ''), '') <> ''
|
||||
ORDER BY COALESCE(NULLIF(area, ''), '') ASC
|
||||
"""
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
items: list[dict[str, Any]] = [
|
||||
{
|
||||
"tenant_code": "PUBLIC",
|
||||
"tenant_name": "公共",
|
||||
"tenant_short_name": "公共",
|
||||
"tenant_type": "PUBLIC",
|
||||
"parent_tenant_code": None,
|
||||
"display_order": 0,
|
||||
"is_enabled": True,
|
||||
"is_builtin": True,
|
||||
"is_public": True,
|
||||
"can_host_entry_module": True,
|
||||
"can_host_documents": True,
|
||||
"can_host_rag": True,
|
||||
"can_host_templates": True,
|
||||
"ext": {},
|
||||
"feature_keys": [],
|
||||
}
|
||||
]
|
||||
for index, row in enumerate(user_rows, start=1):
|
||||
area = str(row[0] or "").strip()
|
||||
if not area or area == "公共":
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"tenant_code": area,
|
||||
"tenant_name": area,
|
||||
"tenant_short_name": area,
|
||||
"tenant_type": "LEGACY_AREA",
|
||||
"parent_tenant_code": None,
|
||||
"display_order": index * 10,
|
||||
"is_enabled": True,
|
||||
"is_builtin": False,
|
||||
"is_public": False,
|
||||
"can_host_entry_module": True,
|
||||
"can_host_documents": True,
|
||||
"can_host_rag": True,
|
||||
"can_host_templates": True,
|
||||
"ext": {},
|
||||
"feature_keys": [],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
async def _list_legacy_tenant_options(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"tenant_code": str(item.get("tenant_code") or ""),
|
||||
"tenant_name": str(item.get("tenant_name") or ""),
|
||||
"tenant_short_name": item.get("tenant_short_name"),
|
||||
"tenant_type": item.get("tenant_type"),
|
||||
"is_public": bool(item.get("is_public")),
|
||||
"display_order": item.get("display_order"),
|
||||
}
|
||||
for item in await self._list_legacy_tenants()
|
||||
]
|
||||
|
||||
async def _ensureWritableTenantFoundation(self) -> None:
|
||||
required_tables = ("sys_tenants", "sys_tenant_aliases", "sys_tenant_feature_flags")
|
||||
missing = [table_name for table_name in required_tables if not await self._table_exists(table_name)]
|
||||
if missing:
|
||||
joined = ", ".join(missing)
|
||||
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, f"租户主数据底座未初始化,缺少表: {joined}")
|
||||
|
||||
async def _getTenantDetailOrFail(self, tenant_code: str) -> dict[str, Any]:
|
||||
item = await self.GetTenant(tenant_code)
|
||||
if not item:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "租户不存在")
|
||||
item["feature_keys"] = await self.GetTenantFeatures(tenant_code)
|
||||
item["alias_values"] = await self.GetTenantAliases(tenant_code)
|
||||
return item
|
||||
|
||||
async def _getTenantAliases(self, tenant_code: str) -> list[str]:
|
||||
if not await self._table_exists("sys_tenant_aliases"):
|
||||
return []
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT alias_value
|
||||
FROM sys_tenant_aliases
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
AND is_enabled = TRUE
|
||||
ORDER BY
|
||||
CASE alias_type
|
||||
WHEN 'DISPLAY' THEN 1
|
||||
WHEN 'SHORT_NAME' THEN 2
|
||||
WHEN 'LEGACY_AREA' THEN 3
|
||||
ELSE 9
|
||||
END ASC,
|
||||
id ASC
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).all()
|
||||
return [str(row[0]).strip() for row in rows if str(row[0] or "").strip()]
|
||||
|
||||
async def _loadTenantRow(self, session: Any, tenant_code: str) -> dict[str, Any] | None:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
tenant_code,
|
||||
tenant_name,
|
||||
tenant_short_name,
|
||||
tenant_type,
|
||||
parent_tenant_code,
|
||||
display_order,
|
||||
is_enabled,
|
||||
is_builtin,
|
||||
is_public,
|
||||
can_host_entry_module,
|
||||
can_host_documents,
|
||||
can_host_rag,
|
||||
can_host_templates,
|
||||
ext
|
||||
FROM sys_tenants
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def _assertTenantExists(
|
||||
self,
|
||||
session: Any,
|
||||
tenant_code: str,
|
||||
error_message: str,
|
||||
exclude_tenant_code: str | None = None,
|
||||
) -> None:
|
||||
params: dict[str, Any] = {"tenant_code": tenant_code}
|
||||
sql = """
|
||||
SELECT 1
|
||||
FROM sys_tenants
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
if exclude_tenant_code:
|
||||
sql += " AND tenant_code <> :exclude_tenant_code"
|
||||
params["exclude_tenant_code"] = exclude_tenant_code
|
||||
sql += " LIMIT 1"
|
||||
exists = (await session.execute(text(sql), params)).scalar_one_or_none()
|
||||
if not exists:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, error_message)
|
||||
|
||||
async def _assertTenantCanBeDisabled(self, session: Any, tenant_code: str) -> None:
|
||||
references = await self._collectDisableReferences(session, tenant_code)
|
||||
if not references:
|
||||
return
|
||||
joined = ";".join(references)
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"当前租户仍被引用,不能禁用:{joined}")
|
||||
|
||||
async def _collectDisableReferences(self, session: Any, tenant_code: str) -> list[str]:
|
||||
references: list[str] = []
|
||||
|
||||
if await self._table_exists("sys_tenants"):
|
||||
child_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sys_tenants
|
||||
WHERE parent_tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
AND is_enabled = TRUE
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
if child_count > 0:
|
||||
references.append(f"存在 {child_count} 个启用中的子租户")
|
||||
|
||||
if await self._table_exists("leaudit_entry_module_tenants"):
|
||||
entry_module_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT emt.entry_module_id)
|
||||
FROM leaudit_entry_module_tenants emt
|
||||
WHERE emt.tenant_code = :tenant_code
|
||||
AND emt.deleted_at IS NULL
|
||||
AND COALESCE(emt.is_enabled, TRUE) = TRUE
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
if entry_module_count > 0:
|
||||
references.append(f"仍绑定 {entry_module_count} 个入口模块")
|
||||
|
||||
if await self._table_exists("sso_users") and await self._column_exists("sso_users", "tenant_code"):
|
||||
user_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sso_users
|
||||
WHERE tenant_code = :tenant_code
|
||||
AND deleted_at IS NULL
|
||||
AND status = 0
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
if user_count > 0:
|
||||
references.append(f"仍有 {user_count} 个启用用户归属该租户")
|
||||
|
||||
return references
|
||||
|
||||
async def _replaceTenantAliases(self, session: Any, tenant_code: str, alias_values: list[str]) -> None:
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM sys_tenant_aliases
|
||||
WHERE tenant_code = :tenant_code
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
for index, alias in enumerate(alias_values):
|
||||
alias_type = "DISPLAY" if index == 0 else "SHORT_NAME"
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_tenant_aliases (
|
||||
tenant_code,
|
||||
alias_type,
|
||||
alias_value,
|
||||
is_enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
) VALUES (
|
||||
:tenant_code,
|
||||
:alias_type,
|
||||
:alias_value,
|
||||
TRUE,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_code": tenant_code,
|
||||
"alias_type": alias_type,
|
||||
"alias_value": alias,
|
||||
},
|
||||
)
|
||||
|
||||
async def _replaceTenantFeatures(self, session: Any, tenant_code: str, feature_keys: list[str]) -> None:
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM sys_tenant_feature_flags
|
||||
WHERE tenant_code = :tenant_code
|
||||
"""
|
||||
),
|
||||
{"tenant_code": tenant_code},
|
||||
)
|
||||
for feature_key in feature_keys:
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_tenant_feature_flags (
|
||||
tenant_code,
|
||||
feature_key,
|
||||
is_enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
) VALUES (
|
||||
:tenant_code,
|
||||
:feature_key,
|
||||
TRUE,
|
||||
NOW(),
|
||||
NOW(),
|
||||
NULL
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_code": tenant_code,
|
||||
"feature_key": feature_key,
|
||||
},
|
||||
)
|
||||
|
||||
def _normalizeTenantCode(self, value: str | None) -> str:
|
||||
tenant_code = str(value or "").strip()
|
||||
if not tenant_code:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户编码不能为空")
|
||||
if len(tenant_code) > 64:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户编码长度不能超过 64")
|
||||
return tenant_code
|
||||
|
||||
def _normalizeOptionalCode(self, value: str | None) -> str | None:
|
||||
normalized = self._normalizeOptionalText(value)
|
||||
return normalized or None
|
||||
|
||||
def _normalizeRequiredText(self, value: str | None, field_name: str) -> str:
|
||||
normalized = self._normalizeOptionalText(value)
|
||||
if not normalized:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"{field_name}不能为空")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalizeOptionalText(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text_value = str(value).strip()
|
||||
return text_value or None
|
||||
|
||||
def _normalizeTenantType(self, value: str | None) -> str:
|
||||
tenant_type = self._normalizeOptionalText(value) or "CUSTOM"
|
||||
if len(tenant_type) > 32:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "租户类型长度不能超过 32")
|
||||
return tenant_type.upper()
|
||||
|
||||
def _normalizeFeatureKeys(self, values: list[str] | None) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in values or []:
|
||||
feature_key = self._normalizeOptionalText(item)
|
||||
if not feature_key:
|
||||
continue
|
||||
if feature_key not in self._SUPPORTED_FEATURE_KEYS:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"不支持的功能标识: {feature_key}")
|
||||
if feature_key in seen:
|
||||
continue
|
||||
seen.add(feature_key)
|
||||
normalized.append(feature_key)
|
||||
return normalized
|
||||
|
||||
def _normalizeAliasValues(self, values: list[str] | None, *, tenant_name: str, tenant_short_name: str) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
seeds = [tenant_name, tenant_short_name, *(values or [])]
|
||||
for item in seeds:
|
||||
alias = self._normalizeOptionalText(item)
|
||||
if not alias or alias in seen:
|
||||
continue
|
||||
if len(alias) > 100:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"租户别名过长: {alias}")
|
||||
seen.add(alias)
|
||||
normalized.append(alias)
|
||||
return normalized
|
||||
|
||||
async def _column_exists(self, table_name: str, column_name: str) -> bool:
|
||||
cache_key = f"{table_name}.{column_name}"
|
||||
cached = self._table_exists_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
exists = bool(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = :table_name
|
||||
AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
self._table_exists_cache[cache_key] = exists
|
||||
return exists
|
||||
|
||||
@staticmethod
|
||||
def _dumpJson(value: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
Reference in New Issue
Block a user