feat: update audit platform workspace

This commit is contained in:
wren
2026-05-25 09:50:01 +08:00
parent ba8e93c0d3
commit 68d0b4c878
73 changed files with 12196 additions and 367 deletions
@@ -21,6 +21,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import (
EntryModuleUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.entryModuleAdminVo import (
EntryModuleBusinessScopeVO,
EntryModuleImageUploadVO,
EntryModuleListVO,
EntryModuleTenantVO,
@@ -31,6 +32,31 @@ from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServ
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolution, TenantResolver
_ALLOWED_MENU_PROFILES = {"document_review", "contract", "govdoc", "cross_checking", "custom"}
_ALLOWED_FEATURES = {
"home",
"documents",
"upload",
"rules",
"rule_groups",
"contract_template_search",
"contract_template_list",
"govdoc_audits",
"govdoc_upload",
"cross_checking",
"cross_checking_upload",
"cross_checking_list",
"usage_stats",
}
_DEFAULT_FEATURES_BY_PROFILE = {
"document_review": ["home", "documents", "upload", "rules", "rule_groups"],
"contract": ["home", "documents", "upload", "rules", "contract_template_search", "contract_template_list"],
"govdoc": ["home", "govdoc_audits", "govdoc_upload", "rule_groups"],
"cross_checking": ["cross_checking", "cross_checking_upload", "cross_checking_list"],
"custom": ["home", "documents"],
}
class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
"""入口模块管理服务实现。"""
@@ -39,6 +65,7 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
self.TenantResolver = TenantResolver()
self._tenant_table_exists_cache: bool | None = None
self._entry_module_tenant_table_exists_cache: bool | None = None
self._entry_module_menu_columns_exist_cache: bool | None = None
async def ListModules(
self,
@@ -128,6 +155,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
if has_tenant_mapping_table
else "'[]'::jsonb AS tenants"
)
menu_select_sql = await self._entry_module_menu_select_sql()
business_scope_select_sql = self._entry_module_business_scope_select_sql()
async with GetAsyncSession() as session:
total = int(
@@ -154,6 +183,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
em.is_enabled,
em.created_at,
em.updated_at,
{menu_select_sql},
{business_scope_select_sql},
{tenant_select_sql}
FROM leaudit_entry_modules em
WHERE {where_clause}
@@ -182,29 +213,37 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
)
self._ensureTenantAssignments(normalized_tenants)
legacy_areas_json = self._legacyAreasJson(normalized_tenants)
menu_profile = self._normalizeMenuProfile(Body.menu_profile)
features = self._normalizeFeatures(Body.features, menu_profile)
has_menu_columns = await self._entry_module_menu_columns_exist()
async with GetAsyncSession() as session:
try:
menu_insert_columns = ", menu_profile, features" if has_menu_columns else ""
menu_insert_values = ", :menu_profile, CAST(:features AS jsonb)" if has_menu_columns else ""
params = {
"name": Body.name.strip(),
"description": (Body.description or "").strip() or None,
"route_path": route_path,
"icon_path": None,
"areas": legacy_areas_json,
"sort_order": await self._nextSortOrder(session),
"menu_profile": menu_profile,
"features": json.dumps(features, ensure_ascii=False),
}
row = (
await session.execute(
text(
"""
f"""
INSERT INTO leaudit_entry_modules (
name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at, deleted_at
name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at, deleted_at{menu_insert_columns}
) VALUES (
:name, :description, :route_path, :icon_path, CAST(:areas AS jsonb), :sort_order, TRUE, NOW(), NOW(), NULL
:name, :description, :route_path, :icon_path, CAST(:areas AS jsonb), :sort_order, TRUE, NOW(), NOW(), NULL{menu_insert_values}
)
RETURNING id
"""
),
{
"name": Body.name.strip(),
"description": (Body.description or "").strip() or None,
"route_path": route_path,
"icon_path": None,
"areas": legacy_areas_json,
"sort_order": await self._nextSortOrder(session),
},
params,
)
).mappings().one()
module_id = int(row["id"])
@@ -226,12 +265,30 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
else:
normalized_tenants = await self._extractTenantsFromRow(current)
self._ensureTenantAssignments(normalized_tenants)
current_menu_profile = self._safeMenuProfile(current.get("menu_profile"))
menu_profile = self._normalizeMenuProfile(Body.menu_profile) if Body.menu_profile is not None else current_menu_profile
features = (
self._normalizeFeatures(Body.features, menu_profile)
if Body.features is not None
else self._parseFeatures(current.get("features"), menu_profile)
)
has_menu_columns = await self._entry_module_menu_columns_exist()
async with GetAsyncSession() as session:
menu_update_sql = ", menu_profile = :menu_profile, features = CAST(:features AS jsonb)" if has_menu_columns else ""
params = {
"module_id": ModuleId,
"name": Body.name.strip() if Body.name is not None else current["name"],
"description": Body.description.strip() if Body.description is not None else current.get("description"),
"route_path": incoming_route_path.strip() if incoming_route_path is not None else current.get("path"),
"areas": self._legacyAreasJson(normalized_tenants),
"menu_profile": menu_profile,
"features": json.dumps(features, ensure_ascii=False),
}
row = (
await session.execute(
text(
"""
f"""
UPDATE leaudit_entry_modules
SET
name = :name,
@@ -239,18 +296,13 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
path = :route_path,
areas = CAST(:areas AS jsonb),
updated_at = NOW()
{menu_update_sql}
WHERE id = :module_id
AND deleted_at IS NULL
RETURNING id
"""
),
{
"module_id": ModuleId,
"name": Body.name.strip() if Body.name is not None else current["name"],
"description": Body.description.strip() if Body.description is not None else current.get("description"),
"route_path": incoming_route_path.strip() if incoming_route_path is not None else current.get("path"),
"areas": self._legacyAreasJson(normalized_tenants),
},
params,
)
).mappings().first()
if not row:
@@ -341,6 +393,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
if has_tenant_mapping_table
else "'[]'::jsonb AS tenants"
)
menu_select_sql = await self._entry_module_menu_select_sql()
business_scope_select_sql = self._entry_module_business_scope_select_sql()
async with GetAsyncSession() as session:
row = (
await session.execute(
@@ -357,6 +411,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
em.is_enabled,
em.created_at,
em.updated_at,
{menu_select_sql},
{business_scope_select_sql},
{tenant_select_sql}
FROM leaudit_entry_modules em
WHERE em.id = :module_id
@@ -386,9 +442,12 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
description=Row.get("description"),
path=Row.get("icon_path"),
route_path=Row.get("path"),
menu_profile=self._safeMenuProfile(Row.get("menu_profile")),
features=self._parseFeatures(Row.get("features"), Row.get("menu_profile")),
sort_order=int(Row.get("sort_order") or 0),
is_enabled=bool(Row.get("is_enabled", True)),
tenants=tenants,
business_scope=self._parseBusinessScope(Row.get("business_scope")),
created_at=self._toIso(Row.get("created_at")),
updated_at=self._toIso(Row.get("updated_at")),
)
@@ -568,6 +627,102 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
self._entry_module_tenant_table_exists_cache = exists
return exists
async def _entry_module_menu_columns_exist(self) -> bool:
if self._entry_module_menu_columns_exist_cache is not None:
return self._entry_module_menu_columns_exist_cache
async with GetAsyncSession() as session:
count = int(
(
await session.execute(
text(
"""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'leaudit_entry_modules'
AND column_name IN ('menu_profile', 'features')
"""
)
)
).scalar_one()
)
self._entry_module_menu_columns_exist_cache = count == 2
return self._entry_module_menu_columns_exist_cache
async def _entry_module_menu_select_sql(self) -> str:
if await self._entry_module_menu_columns_exist():
return "em.menu_profile, em.features"
return "'document_review'::varchar AS menu_profile, '[]'::jsonb AS features"
def _entry_module_business_scope_select_sql(self) -> str:
return """
COALESCE(
(
SELECT jsonb_build_object(
'category_count', COUNT(DISTINCT dt.id),
'business_type_count', GREATEST(
COUNT(DISTINCT child_by_type.id),
COALESCE(
(
SELECT COUNT(DISTINCT child_by_entry.id)
FROM leaudit_evaluation_point_groups root_by_entry
JOIN leaudit_evaluation_point_groups child_by_entry
ON child_by_entry.pid = root_by_entry.id
AND child_by_entry.deleted_at IS NULL
WHERE root_by_entry.entry_module_id = em.id
AND root_by_entry.pid = 0
AND root_by_entry.deleted_at IS NULL
),
0
)
),
'categories', COALESCE(
(
SELECT jsonb_agg(category_name ORDER BY category_name)
FROM (
SELECT DISTINCT dt2.name AS category_name
FROM leaudit_document_types dt2
WHERE dt2.entry_module_id = em.id
AND dt2.deleted_at IS NULL
AND dt2.is_enabled = TRUE
) category_rows
),
'[]'::jsonb
)
)
FROM leaudit_document_types dt
LEFT JOIN leaudit_evaluation_point_groups root
ON root.document_type_id = dt.id
AND root.pid = 0
AND root.deleted_at IS NULL
LEFT JOIN leaudit_evaluation_point_groups child_by_type
ON child_by_type.pid = root.id
AND child_by_type.deleted_at IS NULL
WHERE dt.entry_module_id = em.id
AND dt.deleted_at IS NULL
AND dt.is_enabled = TRUE
),
jsonb_build_object('category_count', 0, 'business_type_count', 0, 'categories', '[]'::jsonb)
) AS business_scope
"""
def _parseBusinessScope(self, RawValue: object) -> EntryModuleBusinessScopeVO:
if isinstance(RawValue, str):
try:
RawValue = json.loads(RawValue)
except json.JSONDecodeError:
RawValue = {}
if not isinstance(RawValue, dict):
RawValue = {}
categories_raw = RawValue.get("categories") or []
categories = [str(item).strip() for item in categories_raw if str(item or "").strip()] if isinstance(categories_raw, list) else []
return EntryModuleBusinessScopeVO(
category_count=int(RawValue.get("category_count") or len(categories)),
business_type_count=int(RawValue.get("business_type_count") or 0),
categories=categories,
)
async def _resolveLegacyTenantValue(self, *, RawValue: str, Source: str) -> TenantResolution:
resolution = await self.TenantResolver.Resolve(
RawValue=RawValue,
@@ -705,6 +860,65 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
"入口模块至少需要配置一个适用租户",
)
@staticmethod
def _normalizeMenuProfile(MenuProfile: str | None) -> str:
value = str(MenuProfile or "document_review").strip() or "document_review"
if value not in _ALLOWED_MENU_PROFILES:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"不支持的菜单模板: {value}",
)
return value
@staticmethod
def _safeMenuProfile(MenuProfile: str | None) -> str:
value = str(MenuProfile or "document_review").strip() or "document_review"
return value if value in _ALLOWED_MENU_PROFILES else "document_review"
@staticmethod
def _normalizeFeatures(Features: list[str] | None, MenuProfile: str) -> list[str]:
raw_features = Features if Features else _DEFAULT_FEATURES_BY_PROFILE.get(MenuProfile, _DEFAULT_FEATURES_BY_PROFILE["document_review"])
normalized: list[str] = []
invalid: list[str] = []
for item in raw_features:
feature = str(item or "").strip()
if not feature:
continue
if feature not in _ALLOWED_FEATURES:
invalid.append(feature)
continue
if feature not in normalized:
normalized.append(feature)
if invalid:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"不支持的功能编码: {', '.join(invalid)}",
)
return normalized or list(_DEFAULT_FEATURES_BY_PROFILE.get(MenuProfile, _DEFAULT_FEATURES_BY_PROFILE["document_review"]))
@classmethod
def _parseFeatures(cls, RawFeatures: Any, MenuProfile: str | None) -> list[str]:
menu_profile = cls._safeMenuProfile(MenuProfile)
if isinstance(RawFeatures, list):
return cls._filterFeatures([str(item) for item in RawFeatures], menu_profile)
if isinstance(RawFeatures, str) and RawFeatures.strip():
try:
parsed = json.loads(RawFeatures)
except json.JSONDecodeError:
parsed = []
if isinstance(parsed, list):
return cls._filterFeatures([str(item) for item in parsed], menu_profile)
return list(_DEFAULT_FEATURES_BY_PROFILE.get(menu_profile, _DEFAULT_FEATURES_BY_PROFILE["document_review"]))
@staticmethod
def _filterFeatures(Features: list[str], MenuProfile: str) -> list[str]:
normalized: list[str] = []
for item in Features:
feature = str(item or "").strip()
if feature in _ALLOWED_FEATURES and feature not in normalized:
normalized.append(feature)
return normalized or list(_DEFAULT_FEATURES_BY_PROFILE.get(MenuProfile, _DEFAULT_FEATURES_BY_PROFILE["document_review"]))
@staticmethod
def _toIso(Value) -> str | None:
"""时间转 ISO 字符串。"""