diff --git a/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py new file mode 100644 index 0000000..3e5fc68 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointController.py @@ -0,0 +1,103 @@ +"""评查点控制器。""" + +from fastapi import Depends, Query +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointDto import ( + EvaluationPointCreateDTO, + EvaluationPointUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService +from fastapi_modules.fastapi_leaudit.services.impl.evaluationPointServiceImpl import EvaluationPointServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService + + +class EvaluationPointController(BaseController): + """评查点控制器。""" + + _PERMISSIONS = { + "list": "evaluation_point:list:read", + "detail": "evaluation_point:detail:read", + "create": "evaluation_point:create:write", + "update": "evaluation_point:update:write", + "delete": "evaluation_point:delete:delete", + } + + def __init__(self): + super().__init__(prefix="/v3/evaluation-points", tags=["评查点"]) + self.PointService: IEvaluationPointService = EvaluationPointServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.get("") + async def ListEvaluationPoints( + name: str | None = Query(None, description="名称模糊搜索"), + code: str | None = Query(None, description="编码模糊搜索"), + risk: str | None = Query(None, description="风险等级"), + is_enabled: bool | None = Query(None, description="是否启用"), + evaluation_point_groups_pid: int | None = Query(None, description="一级分组ID"), + evaluation_point_groups_id: int | None = Query(None, description="二级分组ID"), + document_attribute_type: str | None = Query(None, description="文档属性类型"), + area: str | None = Query(None, description="地区"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=500, description="分页大小"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["list"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None}) + data = await self.PointService.ListPoints( + name, + code, + risk, + is_enabled, + evaluation_point_groups_pid, + evaluation_point_groups_id, + document_attribute_type, + area, + page, + page_size, + ) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.get("/attribute-types") + async def GetEvaluationPointAttributeTypes(payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["list"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None}) + data = await self.PointService.GetAttributeTypes() + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.get("/{PointId}") + async def GetEvaluationPoint(PointId: int, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["detail"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None}) + data = await self.PointService.GetPoint(PointId) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.post("") + async def CreateEvaluationPoint(body: EvaluationPointCreateDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["create"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建评查点权限", "data": None}) + data = await self.PointService.CreatePoint(body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.put("/{PointId}") + async def UpdateEvaluationPoint(PointId: int, body: EvaluationPointUpdateDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["update"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新评查点权限", "data": None}) + data = await self.PointService.UpdatePoint(PointId, body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.delete("/{PointId}") + async def DeleteEvaluationPoint(PointId: int, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["delete"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除评查点权限", "data": None}) + data = await self.PointService.DeletePoint(PointId) + return JSONResponse(status_code=200, content=data.model_dump()) + + async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: + for permission_key in permission_keys: + if await self.PermissionService.CheckPermission(user_id, permission_key): + return True + return False diff --git a/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py new file mode 100644 index 0000000..220f91b --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/evaluationPointGroupController.py @@ -0,0 +1,167 @@ +"""评查点分组控制器。""" + +from fastapi import Body, Depends, Query +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointGroupDto import ( + EvaluationPointGroupBatchDeleteDTO, + EvaluationPointGroupBatchStatusDTO, + EvaluationPointGroupBindingCreateDTO, + EvaluationPointGroupBindingUpdateDTO, + EvaluationPointGroupCreateDTO, + EvaluationPointGroupRebindDTO, + EvaluationPointGroupUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.services.evaluationPointGroupService import IEvaluationPointGroupService +from fastapi_modules.fastapi_leaudit.services.impl.evaluationPointGroupServiceImpl import EvaluationPointGroupServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService + + +class EvaluationPointGroupController(BaseController): + """评查点分组控制器。""" + + def __init__(self): + super().__init__(prefix="/v3/evaluation-point-groups", tags=["评查点分组"]) + self.GroupService: IEvaluationPointGroupService = EvaluationPointGroupServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.get("") + async def ListEvaluationPointGroups( + name: str | None = Query(None, description="分组名称模糊搜索"), + code: str | None = Query(None, description="分组编码模糊搜索"), + is_enabled: bool | None = Query(None, description="是否启用"), + pid: int | None = Query(None, description="父分组ID,0 表示一级分组"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=500, description="分页大小"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) + data = await self.GroupService.ListGroups(name, code, is_enabled, pid, page, page_size) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.get("/all") + async def ListAllEvaluationPointGroups( + include_disabled: bool = Query(False, description="是否包含禁用分组"), + with_rule_count: bool = Query(True, description="是否返回评查点数"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) + data = await self.GroupService.ListAllGroups(include_disabled, with_rule_count) + return JSONResponse(status_code=200, content=[item.model_dump() for item in data]) + + @self.router.get("/by-document-types") + async def ListEvaluationPointGroupsByDocumentTypes( + document_type_ids: str = Query(..., description="逗号分隔的文档类型ID列表"), + include_disabled: bool = Query(False, description="是否包含禁用分组"), + with_rule_count: bool = Query(False, description="是否返回评查点数"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) + document_type_id_list = [int(item.strip()) for item in document_type_ids.split(",") if item.strip().isdigit()] + data = await self.GroupService.ListGroupsByDocumentTypes(document_type_id_list, include_disabled, with_rule_count) + return JSONResponse(status_code=200, content=[item.model_dump() for item in data]) + + @self.router.post("") + async def CreateEvaluationPointGroup(body: EvaluationPointGroupCreateDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:create:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建评查点分组权限", "data": None}) + data = await self.GroupService.CreateGroup(body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.patch("/batch/status") + async def BatchUpdateEvaluationPointGroupStatus( + body: EvaluationPointGroupBatchStatusDTO, + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有批量更新评查点分组权限", "data": None}) + data = await self.GroupService.BatchUpdateStatus(body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.delete("/batch") + async def BatchDeleteEvaluationPointGroups( + body: EvaluationPointGroupBatchDeleteDTO = Body(...), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:delete:delete"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有批量删除评查点分组权限", "data": None}) + data = await self.GroupService.BatchDelete(body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.get("/{GroupId}") + async def GetEvaluationPointGroup( + GroupId: int, + with_rule_count: bool = Query(True, description="是否返回评查点数"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) + data = await self.GroupService.GetGroup(GroupId, with_rule_count) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.put("/{GroupId}") + async def UpdateEvaluationPointGroup(GroupId: int, body: EvaluationPointGroupUpdateDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新评查点分组权限", "data": None}) + data = await self.GroupService.UpdateGroup(GroupId, body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.delete("/{GroupId}") + async def DeleteEvaluationPointGroup(GroupId: int, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:batch:write", "evaluation_group:delete:delete"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除评查点分组权限", "data": None}) + data = await self.GroupService.DeleteGroup(GroupId) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.get("/{GroupId}/children") + async def GetEvaluationPointGroupChildren( + GroupId: int, + is_enabled: bool | None = Query(None, description="是否启用"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=500, description="分页大小"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None}) + data = await self.GroupService.GetChildren(GroupId, is_enabled, page, page_size) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.put("/{GroupId}/rebind") + async def RebindEvaluationPointGroup(GroupId: int, body: EvaluationPointGroupRebindDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有换绑评查点分组权限", "data": None}) + data = await self.GroupService.RebindGroup(GroupId, body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.post("/{GroupId}/bindings") + async def CreateEvaluationPointGroupBinding(GroupId: int, body: EvaluationPointGroupBindingCreateDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有绑定规则集权限", "data": None}) + data = await self.GroupService.CreateBinding(GroupId, body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.put("/bindings/{BindingId}") + async def UpdateEvaluationPointGroupBinding(BindingId: int, body: EvaluationPointGroupBindingUpdateDTO, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新规则集绑定权限", "data": None}) + data = await self.GroupService.UpdateBinding(BindingId, body) + return JSONResponse(status_code=200, content=data.model_dump()) + + @self.router.delete("/bindings/{BindingId}") + async def DeleteEvaluationPointGroupBinding(BindingId: int, payload: dict = Depends(verify_access_token)): + if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除规则集绑定权限", "data": None}) + await self.GroupService.DeleteBinding(BindingId) + return JSONResponse(status_code=200, content={"success": True}) + + async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: + for permission_key in permission_keys: + if await self.PermissionService.CheckPermission(user_id, permission_key): + return True + return False diff --git a/fastapi_modules/fastapi_leaudit/controllers/promptTemplateController.py b/fastapi_modules/fastapi_leaudit/controllers/promptTemplateController.py new file mode 100644 index 0000000..9f37bee --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/promptTemplateController.py @@ -0,0 +1,66 @@ +"""提示词模板控制器。""" + +from fastapi import Depends, Query +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_modules.fastapi_leaudit.domian.Dto.promptTemplateDto import PromptTemplateCreateDTO, PromptTemplateUpdateDTO +from fastapi_modules.fastapi_leaudit.services.promptTemplateService import IPromptTemplateService +from fastapi_modules.fastapi_leaudit.services.impl.promptTemplateServiceImpl import PromptTemplateServiceImpl + + +class PromptTemplateController(BaseController): + def __init__(self): + super().__init__(prefix='/v3/prompt-templates', tags=['提示词模板']) + self.Service: IPromptTemplateService = PromptTemplateServiceImpl() + + @self.router.get('') + async def ListPromptTemplates( + search: str | None = Query(None, description='名称/编码搜索'), + template_type: str | None = Query(None, description='模板类型,逗号分隔'), + status: int | None = Query(None, description='状态'), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=500), + payload: dict = Depends(verify_access_token), + ): + _ = payload + template_types = [item.strip() for item in str(template_type or '').split(',') if item.strip()] + data = await self.Service.ListTemplates(search, template_types or None, status, page, page_size) + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': data.model_dump()}) + + @self.router.get('/types') + async def GetPromptTemplateTypes(payload: dict = Depends(verify_access_token)): + _ = payload + data = await self.Service.GetTemplateTypes() + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': data.model_dump()}) + + @self.router.get('/{TemplateId}') + async def GetPromptTemplate(TemplateId: int, payload: dict = Depends(verify_access_token)): + _ = payload + data = await self.Service.GetTemplate(TemplateId) + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': data.model_dump()}) + + @self.router.post('') + async def CreatePromptTemplate(body: PromptTemplateCreateDTO, payload: dict = Depends(verify_access_token)): + _ = payload + data = await self.Service.CreateTemplate(body) + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': data.model_dump()}) + + @self.router.put('/{TemplateId}') + async def UpdatePromptTemplate(TemplateId: int, body: PromptTemplateUpdateDTO, payload: dict = Depends(verify_access_token)): + _ = payload + data = await self.Service.UpdateTemplate(TemplateId, body) + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': data.model_dump()}) + + @self.router.delete('/{TemplateId}') + async def DeletePromptTemplate(TemplateId: int, payload: dict = Depends(verify_access_token)): + _ = payload + await self.Service.DeleteTemplate(TemplateId) + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': True}) + + @self.router.post('/{TemplateId}/duplicate') + async def DuplicatePromptTemplate(TemplateId: int, new_code: str | None = Query(None), payload: dict = Depends(verify_access_token)): + _ = payload + data = await self.Service.DuplicateTemplate(TemplateId, new_code) + return JSONResponse(status_code=200, content={'code': 200, 'message': 'ok', 'data': data.model_dump()}) diff --git a/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py b/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py index 3ee4e24..a6113b6 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py @@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse from fastapi_common.fastapi_common_security.security import verify_access_token from fastapi_common.fastapi_common_web.controller import BaseController -from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, UserRolesAssignDTO +from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import RoleAccessSaveDTO, RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, UserRolesAssignDTO from fastapi_modules.fastapi_leaudit.services.impl.rbacAdminServiceImpl import RbacAdminServiceImpl from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService @@ -128,6 +128,12 @@ class RbacAdminController(BaseController): data = await self.RbacAdminService.SaveRolePermissions(int(payload["user_id"]), Body) return JSONResponse(status_code=200, content={"code": 200, "message": "权限分配成功", "data": data.model_dump()}) + @self.router.post("/v3/rbac/roles/{RoleId}/access") + async def SaveRoleAccess(RoleId: int, Body: RoleAccessSaveDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """原子保存角色菜单与接口权限。""" + data = await self.RbacAdminService.SaveRoleAccess(int(payload["user_id"]), RoleId, Body) + return JSONResponse(status_code=200, content={"code": 200, "message": "角色权限保存成功", "data": data.model_dump()}) + @self.router.get("/v3/routes/{RouteId}/permissions") async def GetRoutePermissions(RouteId: int, payload: dict[str, Any] = Depends(verify_access_token)): """查询路由关联权限。""" diff --git a/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py b/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py new file mode 100644 index 0000000..691c3a6 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/ruleConfigController.py @@ -0,0 +1,45 @@ +"""规则配置页聚合控制器。""" + +from typing import Any + +from fastapi import Depends +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController + +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ruleConfigServiceImpl import RuleConfigServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService +from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService + + +class RuleConfigController(BaseController): + """规则配置页聚合控制器。""" + + def __init__(self): + super().__init__(prefix="/v3/rule-config-packs", tags=["规则配置"]) + self.RuleConfigService: IRuleConfigService = RuleConfigServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.get("") + async def ListRuleConfigPacks(payload: dict[str, Any] = Depends(verify_access_token)): + """列出规则配置页 pack。""" + if not await self._check_permission(int(payload["user_id"])): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则配置查看权限", "data": None}) + data = await self.RuleConfigService.ListPacks() + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": [item.model_dump() for item in data]}) + + @self.router.get("/{PackId}") + async def GetRuleConfigPack(PackId: int, payload: dict[str, Any] = Depends(verify_access_token)): + """获取单个规则配置 pack。""" + if not await self._check_permission(int(payload["user_id"])): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有规则配置查看权限", "data": None}) + data = await self.RuleConfigService.GetPack(PackId) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) + + async def _check_permission(self, user_id: int) -> bool: + for permission_key in ("rules:list:read", "rules:content:read", "evaluation_group:list:read"): + if await self.PermissionService.CheckPermission(user_id, permission_key): + return True + return False diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py new file mode 100644 index 0000000..3377f3d --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointDto.py @@ -0,0 +1,38 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class EvaluationPointBaseDTO(BaseModel): + name: str | None = Field(None, description="评查点名称") + code: str | None = Field(None, description="评查点编码") + risk: str | None = Field(None, description="风险等级") + is_enabled: bool | None = Field(None, description="是否启用") + description: str | None = Field(None, description="评查点描述") + evaluation_point_groups_id: int | None = Field(None, description="二级分组ID") + evaluation_point_groups_pid: int | None = Field(None, description="一级分组ID") + document_attribute_type: str | None = Field(None, description="适用属性类型") + references_laws: dict[str, Any] | None = Field(None, description="法律依据") + extraction_config: dict[str, Any] | None = Field(None, description="抽取配置") + evaluation_config: dict[str, Any] | None = Field(None, description="评查配置") + pass_message: str | None = Field(None, description="通过提示") + fail_message: str | None = Field(None, description="不通过提示") + suggestion_message: str | None = Field(None, description="建议提示") + suggestion_message_type: str | None = Field(None, description="建议提示类型") + post_action: str | None = Field(None, description="后置动作") + action_config: str | None = Field(None, description="动作配置") + score: float | int | None = Field(None, description="分值") + area: str | None = Field(None, description="地区") + + +class EvaluationPointCreateDTO(EvaluationPointBaseDTO): + name: str = Field(..., description="评查点名称") + code: str = Field(..., description="评查点编码") + risk: str = Field(..., description="风险等级") + is_enabled: bool = Field(True, description="是否启用") + evaluation_point_groups_id: int = Field(..., description="二级分组ID") + evaluation_point_groups_pid: int = Field(..., description="一级分组ID") + + +class EvaluationPointUpdateDTO(EvaluationPointBaseDTO): + pass diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointGroupDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointGroupDto.py new file mode 100755 index 0000000..3e24ba4 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/evaluationPointGroupDto.py @@ -0,0 +1,63 @@ +from pydantic import BaseModel, Field + + +class EvaluationPointGroupCreateDTO(BaseModel): + """创建评查点分组请求。""" + + name: str = Field(..., description="分组名称") + code: str = Field(..., description="分组编码") + pid: int | None = Field(None, description="父分组ID,空或 0 表示一级业务分组") + description: str | None = Field(None, description="分组描述") + document_type_id: int | None = Field(None, description="关联文档类型ID;一级通常留空仅用于兼容旧数据,二级需绑定具体文档类型") + entry_module_id: int | None = Field(None, description="关联入口模块ID;一级业务分组可绑定,允许后补") + sort_order: int = Field(0, description="排序") + is_enabled: bool = Field(True, description="是否启用") + + +class EvaluationPointGroupUpdateDTO(BaseModel): + """更新评查点分组请求。""" + + name: str | None = Field(None, description="分组名称") + code: str | None = Field(None, description="分组编码") + pid: int | None = Field(None, description="父分组ID,空或 0 表示一级业务分组") + description: str | None = Field(None, description="分组描述") + document_type_id: int | None = Field(None, description="关联文档类型ID;一级通常留空仅用于兼容旧数据,二级需绑定具体文档类型") + entry_module_id: int | None = Field(None, description="关联入口模块ID;一级业务分组可绑定,允许后补") + sort_order: int | None = Field(None, description="排序") + is_enabled: bool | None = Field(None, description="是否启用") + + +class EvaluationPointGroupRebindDTO(BaseModel): + """文档类型分组换绑请求。""" + + new_parent_id: int = Field(..., ge=1, description="新的文档类型分组ID") + + +class EvaluationPointGroupBatchStatusDTO(BaseModel): + """批量更新状态请求。""" + + ids: list[int] = Field(default_factory=list, description="分组ID列表") + is_enabled: bool = Field(..., description="目标启用状态") + + +class EvaluationPointGroupBatchDeleteDTO(BaseModel): + """批量删除分组请求。""" + + ids: list[int] = Field(default_factory=list, description="分组ID列表") + + +class EvaluationPointGroupBindingCreateDTO(BaseModel): + """规则组绑定创建请求。""" + + rule_set_id: int = Field(..., ge=1, description="规则集ID") + priority: int = Field(0, description="优先级") + is_active: bool = Field(True, description="是否启用") + note: str | None = Field(None, description="备注") + + +class EvaluationPointGroupBindingUpdateDTO(BaseModel): + """规则组绑定更新请求。""" + + priority: int | None = Field(None, description="优先级") + is_active: bool | None = Field(None, description="是否启用") + note: str | None = Field(None, description="备注") diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/promptTemplateDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/promptTemplateDto.py new file mode 100644 index 0000000..e5a5daf --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/promptTemplateDto.py @@ -0,0 +1,28 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class PromptTemplateBaseDTO(BaseModel): + template_name: str | None = Field(None, description='模板名称') + template_code: str | None = Field(None, description='模板code') + template_type: str | None = Field(None, description='模板类型') + description: str | None = Field(None, description='模板描述') + template_content: str | None = Field(None, description='模板内容') + template_abbreviation: str | None = Field(None, description='模板简称') + variables: dict[str, Any] | None = Field(None, description='变量定义') + status: int | None = Field(None, description='状态') + version: str | None = Field(None, description='版本') + created_by: int | None = Field(None, description='创建人ID') + + +class PromptTemplateCreateDTO(PromptTemplateBaseDTO): + template_name: str = Field(..., description='模板名称') + template_type: str = Field(..., description='模板类型') + template_content: str = Field(..., description='模板内容') + status: int = Field(1, description='状态') + version: str = Field('v1.0', description='版本') + + +class PromptTemplateUpdateDTO(PromptTemplateBaseDTO): + pass diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py index efff1ba..6737a4b 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py @@ -44,6 +44,16 @@ class RolePermissionsBatchDTO(BaseModel): role_id: int = Field(..., description="角色ID") permissions: list[RolePermissionConfigDTO] = Field(default_factory=list, description="权限列表") replace: bool = Field(False, description="是否替换当前角色已有权限") + replace_scope_permission_ids: list[int] = Field(default_factory=list, description="替换模式下仅允许清理的权限ID范围") + + +class RoleAccessSaveDTO(BaseModel): + """角色菜单与接口权限联合保存请求。""" + + route_ids: list[int] = Field(default_factory=list, description="启用路由ID列表") + permission_ids: list[int] = Field(default_factory=list, description="启用接口权限ID列表") + route_permission: str = Field("RW", description="路由授权类型") + replace_scope_permission_ids: list[int] = Field(default_factory=list, description="允许本次替换清理的权限ID范围") class UserRolesAssignDTO(BaseModel): diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/ruleGroupDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/ruleGroupDto.py new file mode 100644 index 0000000..80c2b08 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/ruleGroupDto.py @@ -0,0 +1,43 @@ +"""规则分组 DTO。""" + +from pydantic import BaseModel, Field + + +class RuleGroupCreateDTO(BaseModel): + """创建规则分组。""" + + name: str = Field(..., description="分组名称") + code: str = Field(..., description="分组编码") + pid: int | None = Field(None, description="父分组ID,空或0表示一级分组") + description: str | None = Field(None, description="分组描述") + documentTypeId: int | None = Field(None, description="关联文档类型ID,仅二级分组使用") + isEnabled: bool = Field(True, description="是否启用") + sortOrder: int = Field(0, description="排序") + + +class RuleGroupUpdateDTO(BaseModel): + """更新规则分组。""" + + name: str | None = Field(None, description="分组名称") + code: str | None = Field(None, description="分组编码") + description: str | None = Field(None, description="分组描述") + documentTypeId: int | None = Field(None, description="关联文档类型ID") + isEnabled: bool | None = Field(None, description="是否启用") + sortOrder: int | None = Field(None, description="排序") + + +class RuleGroupBindingCreateDTO(BaseModel): + """创建规则组绑定。""" + + ruleSetId: int = Field(..., description="规则集ID") + priority: int = Field(0, description="优先级") + isActive: bool = Field(True, description="是否启用") + note: str | None = Field(None, description="备注") + + +class RuleGroupBindingUpdateDTO(BaseModel): + """更新规则组绑定。""" + + priority: int | None = Field(None, description="优先级") + isActive: bool | None = Field(None, description="是否启用") + note: str | None = Field(None, description="备注") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py index 8f98319..19a46f6 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py @@ -18,6 +18,7 @@ class DocumentUploadVO(BaseModel): fileId: int = Field(..., description="文档文件ID") typeId: int = Field(..., description="文档类型ID") typeCode: str = Field(..., description="文档类型编码") + groupId: int | None = Field(None, description="命中的二级分组ID") region: str = Field(..., description="所属地区") fileName: str = Field(..., description="文件名") ossUrl: str = Field(..., description="OSS 对象路径") @@ -27,6 +28,16 @@ class DocumentUploadVO(BaseModel): run: AuditRunVO | None = Field(None, description="自动触发后的运行信息") +class DocumentStatusItemVO(BaseModel): + """文档状态项。""" + + documentId: int = Field(..., description="文档ID") + processingStatus: str | None = Field(None, description="处理状态") + runStatus: str | None = Field(None, description="当前运行状态") + resultStatus: str | None = Field(None, description="当前结果状态") + updatedAt: str | None = Field(None, description="更新时间") + + class DocumentHistoryVersionVO(BaseModel): """历史版本摘要。""" @@ -41,6 +52,20 @@ class DocumentHistoryVersionVO(BaseModel): updatedAt: str | None = Field(None, description="更新时间") +class DocumentAttachmentVO(BaseModel): + """文档附件项。""" + + fileId: int = Field(..., description="附件文件ID") + fileName: str = Field(..., description="附件文件名") + fileExt: str | None = Field(None, description="附件扩展名") + mimeType: str | None = Field(None, description="附件 MIME 类型") + fileSize: int | None = Field(None, description="附件文件大小") + fileRole: str = Field(..., description="文件角色") + ossUrl: str | None = Field(None, description="附件 OSS 路径") + createdBy: int | None = Field(None, description="上传人") + createdAt: str | None = Field(None, description="上传时间") + + class DocumentListItemVO(BaseModel): """文档列表项。""" @@ -52,6 +77,9 @@ class DocumentListItemVO(BaseModel): previousVersionId: int | None = Field(None, description="上一版本文档ID") typeId: int | None = Field(None, description="文档类型ID") typeCode: str | None = Field(None, description="文档类型编码") + typeName: str | None = Field(None, description="文档类型名称") + groupId: int | None = Field(None, description="命中的二级分组ID") + groupName: str | None = Field(None, description="二级分组名称") region: str = Field(..., description="区域") normalizedName: str | None = Field(None, description="归一化名称") fileId: int | None = Field(None, description="文件ID") @@ -68,12 +96,23 @@ class DocumentListItemVO(BaseModel): passedCount: int | None = Field(None, description="通过数") failedCount: int | None = Field(None, description="失败数") skippedCount: int | None = Field(None, description="跳过数") + documentNumber: str | None = Field(None, description="业务文号/案号") + auditStatus: int | None = Field(None, description="人工维护审核状态") + isTestDocument: bool = Field(False, description="是否测试文档") updatedAt: str | None = Field(None, description="更新时间") hasHistory: bool = Field(False, description="是否存在历史版本") totalVersions: int = Field(1, description="总版本数") historyVersions: list[DocumentHistoryVersionVO] = Field(default_factory=list, description="历史版本摘要") +class DocumentDetailVO(DocumentListItemVO): + """文档详情。""" + + remark: str | None = Field(None, description="备注") + pageCount: int | None = Field(None, description="页数,暂无精确值时可为空") + attachments: list[DocumentAttachmentVO] = Field(default_factory=list, description="附件列表") + + class DocumentTypeItemVO(BaseModel): """文档类型列表项。""" @@ -117,3 +156,12 @@ class DocumentListPageVO(BaseModel): pageSize: int = Field(..., description="每页数量") totalPages: int = Field(..., description="总页数") documents: list[DocumentListItemVO] = Field(default_factory=list, description="文档列表") + + +class DocumentUpdateDTO(BaseModel): + """文档元数据更新请求。""" + + documentNumber: str | None = Field(None, description="业务文号/案号") + auditStatus: int | None = Field(None, description="人工维护审核状态(若表结构支持则写入)") + isTestDocument: bool | None = Field(None, description="是否测试文档") + remark: str | None = Field(None, description="备注") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py new file mode 100755 index 0000000..e8fd719 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointGroupVo.py @@ -0,0 +1,99 @@ +from pydantic import BaseModel, Field + + +class RuleGroupBindingVO(BaseModel): + """二级分组下的规则集绑定。""" + + id: int = Field(..., description="绑定ID") + group_id: int = Field(..., description="分组ID") + rule_set_id: int = Field(..., description="规则集ID") + rule_type_binding_id: int | None = Field(None, description="镜像的运行时绑定ID") + priority: int = Field(0, description="优先级") + is_active: bool = Field(True, description="是否启用") + note: str | None = Field(None, description="备注") + rule_type: str | None = Field(None, description="规则类型编码") + rule_name: str | None = Field(None, description="规则集名称") + current_version_id: int | None = Field(None, description="当前版本ID") + fallback_version_id: int | None = Field(None, description="回退版本ID") + has_usable_version: bool = Field(False, description="是否存在可用版本") + usable_rule_count: int = Field(0, description="可用规则数") + + +class EvaluationPointGroupVO(BaseModel): + """评查点分组详情。""" + + id: int = Field(..., description="分组ID") + pid: int | None = Field(None, description="父分组ID") + name: str = Field(..., description="分组名称") + code: str = Field(..., description="分组编码") + description: str | None = Field(None, description="分组描述") + document_type_id: int | None = Field(None, description="关联文档类型ID") + document_type_name: str | None = Field(None, description="关联文档类型名称") + entry_module_id: int | None = Field(None, description="入口模块ID") + entry_module_name: str | None = Field(None, description="入口模块名称") + sort_order: int = Field(0, description="排序") + is_enabled: bool = Field(True, description="是否启用") + created_at: str | None = Field(None, description="创建时间") + updated_at: str | None = Field(None, description="更新时间") + rule_count: int | None = Field(None, description="绑定的规则集数量") + bindings: list[RuleGroupBindingVO] = Field(default_factory=list, description="二级分组绑定的规则集") + children: list["EvaluationPointGroupVO"] | None = Field(None, description="子分组") + + +class EvaluationPointGroupListVO(BaseModel): + """评查点分组列表分页。""" + + data: list[EvaluationPointGroupVO] = Field(default_factory=list, description="分组列表") + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + page_size: int = Field(20, description="分页大小") + + +class DocTypeInfoVO(BaseModel): + """换绑提示中的文档类型信息。""" + + id: int = Field(..., description="文档类型ID") + name: str = Field(..., description="文档类型名称") + + +class EvaluationPointGroupDeleteVO(BaseModel): + """删除分组响应。""" + + success: bool = Field(..., description="是否成功") + message: str | None = Field(None, description="提示信息") + deleted_count: int | None = Field(None, description="兼容字段") + deleted_groups: int | None = Field(None, description="删除的分组数") + deleted_points: int | None = Field(None, description="兼容旧字段:已删除绑定数") + need_rebind: bool = Field(False, description="是否需要先换绑") + points_count: int | None = Field(None, description="兼容旧字段") + single_bound_doc_types: list[DocTypeInfoVO] = Field(default_factory=list, description="兼容旧字段") + multi_bound_doc_types: list[DocTypeInfoVO] = Field(default_factory=list, description="兼容旧字段") + + +class EvaluationPointGroupRebindVO(BaseModel): + """换绑结果。""" + + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + rebind_count: int = Field(0, description="迁移的二级分组数量") + doc_types_updated: int = Field(0, description="兼容字段") + + +class EvaluationPointGroupBatchStatusVO(BaseModel): + """批量更新状态结果。""" + + success: bool = Field(..., description="是否成功") + updated_count: int = Field(0, description="更新数量") + message: str = Field(..., description="结果消息") + + +class EvaluationPointGroupBatchDeleteVO(BaseModel): + """批量删除结果。""" + + success: bool = Field(..., description="是否成功") + deleted_groups: int = Field(0, description="删除的分组数量") + deleted_points: int = Field(0, description="兼容旧字段:删除的绑定数量") + message: str = Field(..., description="结果消息") + + +EvaluationPointGroupVO.model_rebuild() diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py new file mode 100644 index 0000000..7e18f86 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/evaluationPointVo.py @@ -0,0 +1,52 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class EvaluationPointVO(BaseModel): + id: int = Field(..., description="评查点ID") + code: str = Field(..., description="评查点编码") + name: str = Field(..., description="评查点名称") + evaluation_point_groups_id: int | None = Field(None, description="二级分组ID") + evaluation_point_groups_pid: int | None = Field(None, description="一级分组ID") + ruleType: str = Field("", description="一级分组名称") + groupName: str = Field("", description="二级分组名称") + groupId: str = Field("", description="二级分组ID字符串") + risk: str = Field("", description="风险等级") + description: str = Field("", description="评查点描述") + is_enabled: bool = Field(True, description="是否启用") + document_attribute_type: str = Field("通用", description="适用属性类型") + references_laws: dict[str, Any] = Field(default_factory=dict, description="法律依据") + extraction_config: dict[str, Any] = Field(default_factory=dict, description="抽取配置") + evaluation_config: dict[str, Any] = Field(default_factory=dict, description="评查配置") + pass_message: str = Field("", description="通过提示") + fail_message: str = Field("", description="不通过提示") + suggestion_message: str = Field("", description="建议提示") + suggestion_message_type: str = Field("warning", description="建议提示类型") + post_action: str = Field("none", description="后置动作") + action_config: str = Field("", description="动作配置") + score: float = Field(0, description="分值") + area: str = Field("", description="地区") + created_at: str | None = Field(None, description="创建时间") + updated_at: str | None = Field(None, description="更新时间") + + +class EvaluationPointListVO(BaseModel): + data: list[EvaluationPointVO] = Field(default_factory=list, description="评查点列表") + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + page_size: int = Field(20, description="分页大小") + + +class EvaluationPointDeleteVO(BaseModel): + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + + +class AttributeTypeVO(BaseModel): + code: str = Field(..., description="属性类型编码") + label: str = Field(..., description="属性类型名称") + + +class AttributeTypeListVO(BaseModel): + types: list[AttributeTypeVO] = Field(default_factory=list, description="属性类型列表") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/promptTemplateVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/promptTemplateVo.py new file mode 100644 index 0000000..fdce868 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/promptTemplateVo.py @@ -0,0 +1,37 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class PromptTemplateVO(BaseModel): + id: int = Field(..., description='模板ID') + template_name: str = Field(..., description='模板名称') + template_code: str | None = Field(None, description='模板code') + template_type: str = Field(..., description='模板类型') + description: str | None = Field(None, description='模板描述') + template_content: str = Field(..., description='模板内容') + template_abbreviation: str | None = Field(None, description='模板简称') + variables: dict[str, Any] = Field(default_factory=dict, description='变量定义') + status: int = Field(0, description='状态') + version: str = Field('v1.0', description='版本') + created_by: int | None = Field(None, description='创建人ID') + created_by_username: str | None = Field(None, description='创建人用户名') + created_at: str = Field('', description='创建时间') + updated_at: str = Field('', description='更新时间') + + +class PromptTemplateListVO(BaseModel): + total: int = Field(0, description='总数') + page: int = Field(1, description='页码') + page_size: int = Field(20, description='分页大小') + items: list[PromptTemplateVO] = Field(default_factory=list, description='模板列表') + + +class PromptTemplateTypeOptionVO(BaseModel): + value: str = Field(..., description='类型值') + label: str = Field(..., description='类型名称') + count: int = Field(0, description='数量') + + +class PromptTemplateTypeListVO(BaseModel): + items: list[PromptTemplateTypeOptionVO] = Field(default_factory=list, description='模板类型列表') diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py index b9bc0ac..14dc1d9 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py @@ -131,6 +131,14 @@ class RolePermissionsVO(BaseModel): permissions: list[RolePermissionDetailVO] = Field(default_factory=list, description="权限列表") +class RoleAccessSaveVO(BaseModel): + """角色菜单与接口权限联合保存响应。""" + + role_id: int = Field(..., description="角色ID") + route_result: RoleRouteUpdateResultVO = Field(..., description="菜单保存结果") + permission_result: RolePermissionsVO = Field(..., description="接口权限保存结果") + + class UserRolesVO(BaseModel): """用户角色响应。""" diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py new file mode 100644 index 0000000..1f7fefd --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/ruleConfigVo.py @@ -0,0 +1,27 @@ +"""规则配置页聚合 VO。""" + +from pydantic import BaseModel, Field + + +class RuleConfigPackVO(BaseModel): + """规则配置页的单个 pack。""" + + packId: int = Field(..., description="pack 标识,当前等于二级分组ID") + groupId: int = Field(..., description="二级分组ID") + rootGroupId: int | None = Field(None, description="一级分组ID") + bindingId: int | None = Field(None, description="当前命中的规则集绑定ID") + ruleSetId: int | None = Field(None, description="命中的规则集ID") + ruleType: str | None = Field(None, description="规则类型编码") + ruleName: str | None = Field(None, description="规则集名称") + currentVersionId: int | None = Field(None, description="规则集当前版本ID") + fallbackVersionId: int | None = Field(None, description="规则集回退版本ID") + resolvedVersionId: int | None = Field(None, description="当前实际使用的版本ID") + hasUsableVersion: bool = Field(False, description="是否存在可用规则版本") + usableRuleCount: int = Field(0, description="可用规则数") + documentTypeId: int | None = Field(None, description="文档类型ID") + documentType: str = Field("", description="文档类型名称") + moduleType: str = Field("", description="模块名称") + mainType: str = Field("", description="一级业务类型名称") + subtype: str = Field("", description="二级业务子类型名称") + yamlText: str = Field("", description="当前规则 YAML 正文") + sourceStatus: str = Field(..., description="ready/empty/missing") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py index 0501931..6b97a5a 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/ruleVo.py @@ -11,6 +11,9 @@ class RuleSetVO(BaseModel): ruleName: str = Field(..., description="规则集名称") domainType: str | None = Field(None, description="域类型") currentVersionId: int | None = Field(None, description="当前激活版本ID") + fallbackVersionId: int | None = Field(None, description="最近一个可回退使用的已发布版本ID") + hasUsableVersion: bool = Field(False, description="是否存在可用于上传评查的规则版本") + usableRuleCount: int = Field(0, description="当前可用于上传评查的规则数") status: str = Field(..., description="draft/active/inactive/archived") diff --git a/fastapi_modules/fastapi_leaudit/leaudit_bridge/fileSourceResolver.py b/fastapi_modules/fastapi_leaudit/leaudit_bridge/fileSourceResolver.py index cfb995b..5d2503e 100644 --- a/fastapi_modules/fastapi_leaudit/leaudit_bridge/fileSourceResolver.py +++ b/fastapi_modules/fastapi_leaudit/leaudit_bridge/fileSourceResolver.py @@ -47,6 +47,13 @@ class FileSourceResolver: raise ValueError("当前文档文件既无可用 localPath,也无可用 ossUrl") + async def ResolvePayloads(self, DocumentFiles: list[LeauditDocumentFile]) -> list[FileSourcePayload]: + """批量解析文档文件。""" + payloads: list[FileSourcePayload] = [] + for documentFile in DocumentFiles: + payloads.append(await self.ResolvePayload(documentFile)) + return payloads + async def _DownloadFromUrl(self, FileName: str, Url: str) -> FileSourcePayload: """从 OSS 或 URL 下载文件内容。""" try: diff --git a/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py b/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py index 094e1a9..9bb8c70 100644 --- a/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py +++ b/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py @@ -9,7 +9,9 @@ import tempfile import time from typing import Any, Dict, Optional +import fitz from fastapi_common.fastapi_common_logger import logger +from leaudit.converters import doc2pdf from fastapi_admin.celery_app import celery_app from fastapi_admin.config import ( @@ -70,6 +72,7 @@ def leaudit_process_document( rules_resolution = _resolve_rules_runtime(document_id, run_id, rules_path, loop) loop.run_until_complete(_update_run_status_safe(run_id, "running")) rules_path_resolved = rules_resolution["rules_path"] + attachment_inputs = list((upload_info or {}).get("attachments") or []) rules_file = None if rules_path_resolved: @@ -88,11 +91,31 @@ def leaudit_process_document( "will classify from document content after OCR" ) - suffix = _get_suffix(filename) - with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp: - temp.write(file_content) - temp_path = temp.name - temp_paths.append(temp_path) + attachment_metadata: list[dict[str, Any]] = [] + for attachment in attachment_inputs: + attachment_metadata.append( + { + "fileId": attachment.get("file_id"), + "fileName": attachment.get("file_name") or attachment.get("filename"), + "sourceType": attachment.get("source_type"), + "sourcePath": attachment.get("source_path"), + } + ) + + temp_path, merged_attachment_metadata = _prepare_execution_input( + filename=filename, + file_content=file_content, + attachments=attachment_inputs, + temp_paths=temp_paths, + ) + if merged_attachment_metadata: + for item in attachment_metadata: + for merged in merged_attachment_metadata: + if item["fileId"] == merged["fileId"]: + item["localPath"] = merged["localPath"] + item["mergedPdfPath"] = merged["mergedPdfPath"] + break + log.info("[任务ID: %s] 已装配并合并附件 %s 个到单输入文件", task_id, len(merged_attachment_metadata)) runner = NativeRunner() @@ -104,7 +127,11 @@ def leaudit_process_document( run_id=run_id, document_id=document_id, rule_version_id=_optional_int(upload_info, "rule_version_id", "ruleVersionId"), - extras={"taskId": task_id}, + extras={ + "taskId": task_id, + "attachments": attachment_metadata, + "mergedInput": bool(merged_attachment_metadata), + }, ), local_file_path=temp_path, rules_file=rules_file, @@ -203,6 +230,7 @@ def leaudit_process_document_by_run( "source_type": context["source_type"], "source_path": context["source_path"], "trigger_source": context["trigger_source"], + "attachments": context.get("attachments") or [], }, rules_path=rules_path, ) @@ -443,6 +471,17 @@ async def _load_run_context(run_id: int) -> dict[str, Any]: resolver = FileSourceResolver() payload = await resolver.ResolvePayload(document_file) + attachmentResult = await session.execute( + select(LeauditDocumentFile) + .where( + LeauditDocumentFile.documentId == document.Id, + LeauditDocumentFile.isActive.is_(True), + LeauditDocumentFile.fileRole == "attachment", + ) + .order_by(LeauditDocumentFile.Id.asc()) + ) + attachmentFiles = list(attachmentResult.scalars().all()) + attachmentPayloads = await resolver.ResolvePayloads(attachmentFiles) if attachmentFiles else [] return { "document_id": document.Id, @@ -453,6 +492,16 @@ async def _load_run_context(run_id: int) -> dict[str, Any]: "rule_version_id": run.ruleVersionId, "rule_source_oss_url": run.ruleSourceOssUrl, "trigger_source": run.triggerSource, + "attachments": [ + { + "file_id": attachmentFile.Id, + "file_name": attachmentPayload.fileName, + "file_content": attachmentPayload.fileContent, + "source_type": attachmentPayload.sourceType, + "source_path": attachmentPayload.sourcePath, + } + for attachmentFile, attachmentPayload in zip(attachmentFiles, attachmentPayloads) + ], } @@ -462,6 +511,92 @@ def _get_suffix(filename: str) -> str: return ext if ext else ".pdf" +def _write_temp_file(*, filename: str, content: bytes, temp_paths: list[str]) -> str: + """Write bytes to a temp file preserving original suffix.""" + suffix = _get_suffix(filename) + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp: + temp.write(content) + temp_path = temp.name + temp_paths.append(temp_path) + return temp_path + + +def _convert_to_pdf_path(*, source_path: str, temp_paths: list[str]) -> str: + """Convert a local source file to PDF temp path when needed.""" + source = Path(source_path) + if source.suffix.lower() == ".pdf": + return source_path + pdf_temp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + pdf_temp.close() + temp_paths.append(pdf_temp.name) + doc2pdf.convert(source, pdf_temp.name, soffice="auto", pdfa=False, force=True, verify=False) + return pdf_temp.name + + +def _merge_pdf_paths(*, pdf_paths: list[str], temp_paths: list[str]) -> str: + """Merge many pdf files into one temp pdf.""" + merged_temp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + merged_temp.close() + temp_paths.append(merged_temp.name) + output = fitz.open() + try: + for pdf_path in pdf_paths: + src = fitz.open(pdf_path) + try: + output.insert_pdf(src) + finally: + src.close() + output.save(merged_temp.name) + finally: + output.close() + return merged_temp.name + + +def _prepare_execution_input( + *, + filename: str, + file_content: bytes, + attachments: list[dict[str, Any]], + temp_paths: list[str], +) -> tuple[str, list[dict[str, Any]]]: + """Prepare the actual execution input file. + + Without attachments: use the main file directly. + With attachments: convert main file and attachments to PDFs, merge into one PDF. + """ + main_local_path = _write_temp_file(filename=filename, content=file_content, temp_paths=temp_paths) + if not attachments: + return main_local_path, [] + + main_pdf_path = _convert_to_pdf_path(source_path=main_local_path, temp_paths=temp_paths) + merged_attachment_metadata: list[dict[str, Any]] = [] + attachment_pdf_paths: list[str] = [] + for attachment in attachments: + attachment_name = str(attachment.get("file_name") or attachment.get("filename") or "attachment.bin") + attachment_content = attachment["file_content"] + attachment_local_path = _write_temp_file( + filename=attachment_name, + content=attachment_content, + temp_paths=temp_paths, + ) + attachment_pdf_path = _convert_to_pdf_path(source_path=attachment_local_path, temp_paths=temp_paths) + attachment_pdf_paths.append(attachment_pdf_path) + merged_attachment_metadata.append( + { + "fileId": attachment.get("file_id"), + "fileName": attachment_name, + "localPath": attachment_local_path, + "mergedPdfPath": attachment_pdf_path, + } + ) + + merged_pdf_path = _merge_pdf_paths( + pdf_paths=[main_pdf_path, *attachment_pdf_paths], + temp_paths=temp_paths, + ) + return merged_pdf_path, merged_attachment_metadata + + def dispatch_leaudit_task( run_id: int, *, diff --git a/fastapi_modules/fastapi_leaudit/models/leauditDocument.py b/fastapi_modules/fastapi_leaudit/models/leauditDocument.py index 50323b6..0a6d371 100644 --- a/fastapi_modules/fastapi_leaudit/models/leauditDocument.py +++ b/fastapi_modules/fastapi_leaudit/models/leauditDocument.py @@ -21,6 +21,7 @@ class LeauditDocument(BaseModel): Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True) bizDocumentId: Mapped[int] = mapped_column("biz_document_id", BigInteger, unique=True, comment="内部追踪号(兼容旧字段名)") typeId: Mapped[int | None] = mapped_column("type_id", BigInteger, comment="文档类型ID") + groupId: Mapped[int | None] = mapped_column("group_id", BigInteger, comment="命中的二级分组ID") processingStatus: Mapped[str | None] = mapped_column("processing_status", String(64), default="waiting", comment="waiting/processing/completed/failed") currentRunId: Mapped[int | None] = mapped_column("current_run_id", BigInteger, comment="最新有效 run id") region: Mapped[str] = mapped_column(String(32), default="default", comment="所属地区: mz/yf/jy/cz/default") diff --git a/fastapi_modules/fastapi_leaudit/services/__init__.py b/fastapi_modules/fastapi_leaudit/services/__init__.py index c21f0d0..4d68279 100644 --- a/fastapi_modules/fastapi_leaudit/services/__init__.py +++ b/fastapi_modules/fastapi_leaudit/services/__init__.py @@ -2,24 +2,30 @@ from fastapi_modules.fastapi_leaudit.services.auditService import IAuditService from fastapi_modules.fastapi_leaudit.services.documentService import IDocumentService +from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService from fastapi_modules.fastapi_leaudit.services.entryModuleAdminService import IEntryModuleAdminService from fastapi_modules.fastapi_leaudit.services.authService import IAuthService from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService from fastapi_modules.fastapi_leaudit.services.ossService import IOssService from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService +from fastapi_modules.fastapi_leaudit.services.promptTemplateService import IPromptTemplateService from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService from fastapi_modules.fastapi_leaudit.services.rbacService import IRbacService +from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService __all__ = [ "IAuditService", "IDocumentService", + "IEvaluationPointService", "IEntryModuleAdminService", "IAuthService", "IHomeService", "IOssService", "IPermissionService", + "IPromptTemplateService", "IRbacAdminService", "IRbacService", + "IRuleConfigService", "IRuleService", ] diff --git a/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py b/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py new file mode 100755 index 0000000..db5ca07 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/evaluationPointGroupService.py @@ -0,0 +1,93 @@ +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointGroupDto import ( + EvaluationPointGroupBatchDeleteDTO, + EvaluationPointGroupBatchStatusDTO, + EvaluationPointGroupBindingCreateDTO, + EvaluationPointGroupBindingUpdateDTO, + EvaluationPointGroupCreateDTO, + EvaluationPointGroupRebindDTO, + EvaluationPointGroupUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointGroupVo import ( + EvaluationPointGroupBatchDeleteVO, + EvaluationPointGroupBatchStatusVO, + EvaluationPointGroupDeleteVO, + EvaluationPointGroupListVO, + EvaluationPointGroupRebindVO, + EvaluationPointGroupVO, + RuleGroupBindingVO, +) + + +class IEvaluationPointGroupService(ABC): + """评查点分组服务接口。""" + + @abstractmethod + async def ListGroups( + self, + Name: str | None, + Code: str | None, + IsEnabled: bool | None, + Pid: int | None, + Page: int, + PageSize: int, + ) -> EvaluationPointGroupListVO: + ... + + @abstractmethod + async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool) -> list[EvaluationPointGroupVO]: + ... + + @abstractmethod + async def ListGroupsByDocumentTypes( + self, + DocumentTypeIds: list[int], + IncludeDisabled: bool, + WithRuleCount: bool, + ) -> list[EvaluationPointGroupVO]: + ... + + @abstractmethod + async def GetGroup(self, GroupId: int, WithRuleCount: bool) -> EvaluationPointGroupVO: + ... + + @abstractmethod + async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int) -> EvaluationPointGroupListVO: + ... + + @abstractmethod + async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO) -> EvaluationPointGroupVO: + ... + + @abstractmethod + async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO) -> EvaluationPointGroupVO: + ... + + @abstractmethod + async def DeleteGroup(self, GroupId: int) -> EvaluationPointGroupDeleteVO: + ... + + @abstractmethod + async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO) -> EvaluationPointGroupRebindVO: + ... + + @abstractmethod + async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO) -> EvaluationPointGroupBatchStatusVO: + ... + + @abstractmethod + async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO) -> EvaluationPointGroupBatchDeleteVO: + ... + + @abstractmethod + async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO) -> RuleGroupBindingVO: + ... + + @abstractmethod + async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO) -> RuleGroupBindingVO: + ... + + @abstractmethod + async def DeleteBinding(self, BindingId: int) -> None: + ... diff --git a/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py b/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py new file mode 100644 index 0000000..a9e56ef --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/evaluationPointService.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointDto import ( + EvaluationPointCreateDTO, + EvaluationPointUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointVo import ( + AttributeTypeListVO, + EvaluationPointDeleteVO, + EvaluationPointListVO, + EvaluationPointVO, +) + + +class IEvaluationPointService(ABC): + """评查点服务接口。""" + + @abstractmethod + async def ListPoints( + self, + Name: str | None, + Code: str | None, + Risk: str | None, + IsEnabled: bool | None, + GroupPid: int | None, + GroupId: int | None, + DocumentAttributeType: str | None, + Area: str | None, + Page: int, + PageSize: int, + ) -> EvaluationPointListVO: + ... + + @abstractmethod + async def GetPoint(self, PointId: int) -> EvaluationPointVO: + ... + + @abstractmethod + async def CreatePoint(self, Body: EvaluationPointCreateDTO) -> EvaluationPointVO: + ... + + @abstractmethod + async def UpdatePoint(self, PointId: int, Body: EvaluationPointUpdateDTO) -> EvaluationPointVO: + ... + + @abstractmethod + async def DeletePoint(self, PointId: int) -> EvaluationPointDeleteVO: + ... + + @abstractmethod + async def GetAttributeTypes(self) -> AttributeTypeListVO: + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py index f1fdbdd..45f416a 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/auditServiceImpl.py @@ -58,6 +58,15 @@ class AuditServiceImpl(IAuditService): async with GetAsyncSession() as session: logger.info(f"触发评查: documentId={DocumentId}, ruleType={RuleType}") normalizedSpeed = _normalize_speed(Speed) + await session.execute( + text( + """ + ALTER TABLE leaudit_documents + ADD COLUMN IF NOT EXISTS group_id BIGINT NULL + REFERENCES leaudit_evaluation_point_groups(id) + """ + ) + ) document = await session.get(LeauditDocument, DocumentId) if not document: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "评查文档不存在") @@ -99,6 +108,7 @@ class AuditServiceImpl(IAuditService): .where( LeauditDocumentFile.documentId == DocumentId, LeauditDocumentFile.isActive.is_(True), + LeauditDocumentFile.fileRole == "primary", ) .order_by(LeauditDocumentFile.Id.desc()) .limit(1) @@ -115,30 +125,91 @@ class AuditServiceImpl(IAuditService): ) latestRunNo = runNoResult.scalar_one_or_none() or 0 - bindingResult = await session.execute( - text( - """ - SELECT - rs.id AS rule_set_id, - rs.current_version_id AS rule_version_id, - rv.oss_url AS rule_source_oss_url, - rv.file_sha256 AS rule_source_sha256, - rv.metadata_type_id AS rule_type_id - FROM leaudit_rule_type_bindings b - JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id - LEFT JOIN leaudit_rule_versions rv ON rv.id = rs.current_version_id - WHERE b.doc_type_id = :doc_type_id - AND b.is_active = true - AND b.region = :region - ORDER BY b.priority DESC, b.id DESC - LIMIT 1 - """ - ), - {"doc_type_id": document.typeId, "region": document.region}, - ) - binding = bindingResult.mappings().first() - if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]: - raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档类型未绑定可用规则版本") + binding = None + if getattr(document, "groupId", None): + groupBindingResult = await session.execute( + text( + """ + SELECT + rs.id AS rule_set_id, + COALESCE(rs.current_version_id, fallback_rv.id) AS rule_version_id, + COALESCE(current_rv.oss_url, fallback_rv.oss_url) AS rule_source_oss_url, + COALESCE(current_rv.file_sha256, fallback_rv.file_sha256) AS rule_source_sha256, + COALESCE(current_rv.metadata_type_id, fallback_rv.metadata_type_id) AS rule_type_id + FROM leaudit_rule_group_bindings rgb + JOIN leaudit_rule_sets rs ON rs.id = rgb.rule_set_id + LEFT JOIN leaudit_rule_versions current_rv ON current_rv.id = rs.current_version_id + LEFT JOIN LATERAL ( + SELECT + rv.id, + rv.oss_url, + rv.file_sha256, + rv.metadata_type_id + FROM leaudit_rule_versions rv + WHERE rv.rule_set_id = rs.id + AND rv.status IN ('published', 'rollback') + ORDER BY rv.version_seq DESC, rv.id DESC + LIMIT 1 + ) fallback_rv ON TRUE + WHERE rgb.group_id = :group_id + AND rgb.is_active = TRUE + AND rgb.deleted_at IS NULL + ORDER BY rgb.priority DESC, rgb.id ASC + LIMIT 1 + """ + ), + {"group_id": int(document.groupId)}, + ) + binding = groupBindingResult.mappings().first() + if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前子类型未绑定可执行规则集,请先检查二级分组规则配置") + + if binding is None: + bindingResult = await session.execute( + text( + """ + SELECT + rs.id AS rule_set_id, + COALESCE(rs.current_version_id, fallback_rv.id) AS rule_version_id, + COALESCE(current_rv.oss_url, fallback_rv.oss_url) AS rule_source_oss_url, + COALESCE(current_rv.file_sha256, fallback_rv.file_sha256) AS rule_source_sha256, + COALESCE(current_rv.metadata_type_id, fallback_rv.metadata_type_id) AS rule_type_id + FROM leaudit_rule_type_bindings b + JOIN leaudit_rule_sets rs ON rs.id = b.rule_set_id + LEFT JOIN leaudit_rule_versions current_rv ON current_rv.id = rs.current_version_id + LEFT JOIN LATERAL ( + SELECT + rv.id, + rv.oss_url, + rv.file_sha256, + rv.metadata_type_id + FROM leaudit_rule_versions rv + WHERE rv.rule_set_id = rs.id + AND rv.status IN ('published', 'rollback') + ORDER BY rv.version_seq DESC, rv.id DESC + LIMIT 1 + ) fallback_rv ON TRUE + WHERE b.doc_type_id = :doc_type_id + AND b.is_active = true + AND b.deleted_at IS NULL + AND ( + b.region = :region + OR b.region = 'default' + OR b.region IS NULL + OR b.region = '' + ) + ORDER BY + CASE WHEN b.region = :region THEN 0 ELSE 1 END, + b.priority DESC, + b.id DESC + LIMIT 1 + """ + ), + {"doc_type_id": document.typeId, "region": document.region}, + ) + binding = bindingResult.mappings().first() + if not binding or not binding["rule_set_id"] or not binding["rule_version_id"]: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档类型未绑定可用规则版本") triggerSource = f"{'retry' if Force else 'upload'}:{normalizedSpeed}" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py new file mode 100644 index 0000000..3ee03c8 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointGroupServiceImpl.py @@ -0,0 +1,814 @@ +"""评查点分组服务实现(新链路:文档类型 -> 子类型 -> 规则集)。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import bindparam, 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.evaluationPointGroupDto import ( + EvaluationPointGroupBatchDeleteDTO, + EvaluationPointGroupBatchStatusDTO, + EvaluationPointGroupBindingCreateDTO, + EvaluationPointGroupBindingUpdateDTO, + EvaluationPointGroupCreateDTO, + EvaluationPointGroupRebindDTO, + EvaluationPointGroupUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointGroupVo import ( + EvaluationPointGroupBatchDeleteVO, + EvaluationPointGroupBatchStatusVO, + EvaluationPointGroupDeleteVO, + EvaluationPointGroupListVO, + EvaluationPointGroupRebindVO, + EvaluationPointGroupVO, + RuleGroupBindingVO, +) +from fastapi_modules.fastapi_leaudit.services.evaluationPointGroupService import IEvaluationPointGroupService +from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import ( + bootstrap_rule_groups, + ensure_rule_group_schema, + sync_doc_type_bindings_from_group, +) +from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl + + +class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): + """评查点分组服务实现。""" + + def __init__(self) -> None: + self.RuleService = RuleServiceImpl() + + async def ListGroups( + self, + Name: str | None, + Code: str | None, + IsEnabled: bool | None, + Pid: int | None, + Page: int, + PageSize: int, + ) -> EvaluationPointGroupListVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + offset = max(Page - 1, 0) * PageSize + filters = ["g.deleted_at IS NULL", "(COALESCE(g.pid, 0) <> 0 OR g.document_type_id IS NOT NULL OR g.entry_module_id IS NOT NULL)"] + params: dict[str, Any] = {"limit": PageSize, "offset": offset} + + if Name: + filters.append("g.name ILIKE :name") + params["name"] = f"%{Name.strip()}%" + if Code: + filters.append("g.code ILIKE :code") + params["code"] = f"%{Code.strip()}%" + if IsEnabled is not None: + filters.append("g.is_enabled = :is_enabled") + params["is_enabled"] = IsEnabled + if Pid is not None: + filters.append("COALESCE(g.pid, 0) = :pid") + params["pid"] = self._normalize_pid(Pid) + + where_clause = " AND ".join(filters) + total = int( + ( + await session.execute( + text(f"SELECT COUNT(*) FROM leaudit_evaluation_point_groups g WHERE {where_clause}"), + params, + ) + ).scalar_one() + ) + rows = ( + await session.execute( + text( + f""" + SELECT + g.id, + g.pid, + g.name, + g.code, + g.description, + g.document_type_id, + dt.name AS document_type_name, + COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id, + em.name AS entry_module_name, + g.sort_order, + g.is_enabled, + g.created_at, + g.updated_at, + COALESCE(bg.binding_count, 0) AS rule_count + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) + LEFT JOIN ( + SELECT group_id, COUNT(*)::int AS binding_count + FROM leaudit_rule_group_bindings + WHERE deleted_at IS NULL + GROUP BY group_id + ) bg ON bg.group_id = g.id + WHERE {where_clause} + ORDER BY COALESCE(g.sort_order, 0) ASC, g.id ASC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows]) + return EvaluationPointGroupListVO( + data=[self._to_group_vo(row, binding_map.get(int(row["id"]), []), include_rule_count=True) for row in rows], + total=total, + page=Page, + page_size=PageSize, + ) + + async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool) -> list[EvaluationPointGroupVO]: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + filters = ["g.deleted_at IS NULL", "(COALESCE(g.pid, 0) <> 0 OR g.document_type_id IS NOT NULL OR g.entry_module_id IS NOT NULL)"] + if not IncludeDisabled: + filters.append("g.is_enabled = TRUE") + rows = ( + await session.execute( + text( + f""" + SELECT + g.id, + g.pid, + g.name, + g.code, + g.description, + g.document_type_id, + dt.name AS document_type_name, + COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id, + em.name AS entry_module_name, + g.sort_order, + g.is_enabled, + g.created_at, + g.updated_at, + COALESCE(bg.binding_count, 0) AS rule_count + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) + LEFT JOIN ( + SELECT group_id, COUNT(*)::int AS binding_count + FROM leaudit_rule_group_bindings + WHERE deleted_at IS NULL + GROUP BY group_id + ) bg ON bg.group_id = g.id + WHERE {' AND '.join(filters)} + ORDER BY COALESCE(g.sort_order, 0) ASC, g.id ASC + """ + ) + ) + ).mappings().all() + binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows]) + + groups = [self._to_group_vo(row, binding_map.get(int(row["id"]), []), include_rule_count=WithRuleCount) for row in rows] + by_parent: dict[int, list[EvaluationPointGroupVO]] = {} + roots: list[EvaluationPointGroupVO] = [] + for group in groups: + parent_id = self._normalize_pid(group.pid) + if parent_id == 0: + roots.append(group) + else: + by_parent.setdefault(parent_id, []).append(group) + for group in groups: + children = by_parent.get(group.id) + if children: + group.children = children + return roots + + async def ListGroupsByDocumentTypes( + self, + DocumentTypeIds: list[int], + IncludeDisabled: bool, + WithRuleCount: bool, + ) -> list[EvaluationPointGroupVO]: + normalized_ids = sorted({int(item) for item in DocumentTypeIds if item}) + if not normalized_ids: + return [] + roots = await self.ListAllGroups(IncludeDisabled=IncludeDisabled, WithRuleCount=WithRuleCount) + result: list[EvaluationPointGroupVO] = [] + for root in roots: + if root.document_type_id in normalized_ids: + result.append(root) + continue + children = root.children or [] + if any(child.document_type_id in normalized_ids for child in children): + result.append(root) + return result + + async def GetGroup(self, GroupId: int, WithRuleCount: bool) -> EvaluationPointGroupVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + row = await self._get_group_row(session, GroupId) + binding_map = await self._load_binding_map(session, [GroupId]) + return self._to_group_vo(row, binding_map.get(GroupId, []), include_rule_count=WithRuleCount) + + async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int) -> EvaluationPointGroupListVO: + await self.GetGroup(GroupId, WithRuleCount=False) + return await self.ListGroups(Name=None, Code=None, IsEnabled=IsEnabled, Pid=GroupId, Page=Page, PageSize=PageSize) + + async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO) -> EvaluationPointGroupVO: + payload = self._normalize_create_payload(Body) + async with GetAsyncSession() as session: + await self._ensure_ready(session) + await self._ensure_code_unique(session, payload["code"], None) + parent = await self._ensure_parent_valid(session, payload["pid"]) + payload["entry_module_id"] = await self._ensure_entry_module_valid( + session, payload["pid"], payload["entry_module_id"], parent + ) + payload["document_type_id"] = await self._ensure_document_type_valid( + session, payload["pid"], payload["document_type_id"], payload["entry_module_id"], None, parent + ) + row = ( + await session.execute( + text( + """ + INSERT INTO leaudit_evaluation_point_groups ( + pid, code, name, description, document_type_id, entry_module_id, sort_order, is_enabled, created_at, updated_at + ) VALUES ( + :pid, :code, :name, :description, :document_type_id, :entry_module_id, :sort_order, :is_enabled, NOW(), NOW() + ) + RETURNING id + """ + ), + payload, + ) + ).mappings().one() + await session.commit() + group_id = int(row["id"]) + return await self.GetGroup(group_id, WithRuleCount=True) + + async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO) -> EvaluationPointGroupVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + current = await self._get_group_row(session, GroupId) + provided_fields = set(getattr(Body, "model_fields_set", set())) + next_pid = self._normalize_pid(Body.pid) if Body.pid is not None else self._normalize_pid(current["pid"]) + name = (Body.name.strip() if Body.name is not None else str(current.get("name") or "")).strip() + code = (Body.code.strip() if Body.code is not None else str(current.get("code") or "")).strip() + description = Body.description.strip() if Body.description is not None and Body.description else current.get("description") + document_type_id = Body.document_type_id if "document_type_id" in provided_fields else current.get("document_type_id") + entry_module_id = Body.entry_module_id if "entry_module_id" in provided_fields else current.get("entry_module_id") + sort_order = Body.sort_order if Body.sort_order is not None else int(current.get("sort_order") or 0) + is_enabled = Body.is_enabled if Body.is_enabled is not None else bool(current.get("is_enabled", True)) + + if not name: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组名称不能为空") + if not code: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码不能为空") + + await self._ensure_code_unique(session, code, GroupId) + parent = await self._ensure_parent_valid(session, next_pid) + entry_module_id = await self._ensure_entry_module_valid(session, next_pid, entry_module_id, parent) + document_type_id = await self._ensure_document_type_valid( + session, next_pid, document_type_id, entry_module_id, GroupId, parent + ) + await session.execute( + text( + """ + UPDATE leaudit_evaluation_point_groups + SET pid = :pid, + code = :code, + name = :name, + description = :description, + document_type_id = :document_type_id, + entry_module_id = :entry_module_id, + sort_order = :sort_order, + is_enabled = :is_enabled, + updated_at = NOW() + WHERE id = :group_id + """ + ), + { + "group_id": GroupId, + "pid": next_pid, + "code": code, + "name": name, + "description": description, + "document_type_id": document_type_id, + "entry_module_id": entry_module_id, + "sort_order": sort_order, + "is_enabled": is_enabled, + }, + ) + await sync_doc_type_bindings_from_group(session, GroupId) + await session.commit() + return await self.GetGroup(GroupId, WithRuleCount=True) + + async def DeleteGroup(self, GroupId: int) -> EvaluationPointGroupDeleteVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + current = await self._get_group_row(session, GroupId) + is_root = self._normalize_pid(current["pid"]) == 0 + if is_root: + child_count = int( + ( + await session.execute( + text( + "SELECT COUNT(*) FROM leaudit_evaluation_point_groups WHERE pid = :group_id AND deleted_at IS NULL" + ), + {"group_id": GroupId}, + ) + ).scalar_one() + ) + if child_count > 0: + return EvaluationPointGroupDeleteVO( + success=False, + message="当前一级分组下仍存在二级分组,请先迁移或删除二级分组", + deleted_groups=0, + deleted_points=0, + ) + + binding_count = int( + ( + await session.execute( + text( + "SELECT COUNT(*) FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND deleted_at IS NULL" + ), + {"group_id": GroupId}, + ) + ).scalar_one() + ) + await session.execute( + text( + "UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE group_id = :group_id AND deleted_at IS NULL" + ), + {"group_id": GroupId}, + ) + await sync_doc_type_bindings_from_group(session, GroupId) + result = await session.execute( + text( + "UPDATE leaudit_evaluation_point_groups SET deleted_at = NOW(), updated_at = NOW() WHERE id = :group_id AND deleted_at IS NULL" + ), + {"group_id": GroupId}, + ) + await session.commit() + deleted_groups = int(result.rowcount or 0) + return EvaluationPointGroupDeleteVO( + success=True, + message="规则分组删除成功", + deleted_count=deleted_groups, + deleted_groups=deleted_groups, + deleted_points=binding_count, + ) + + async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO) -> EvaluationPointGroupRebindVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + current = await self._get_group_row(session, GroupId) + target = await self._get_group_row(session, Body.new_parent_id) + if self._normalize_pid(current["pid"]) != 0 or self._normalize_pid(target["pid"]) != 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "仅支持一级分组之间迁移二级分组") + if GroupId == Body.new_parent_id: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "迁移目标不能与原分组相同") + result = await session.execute( + text( + """ + UPDATE leaudit_evaluation_point_groups + SET pid = :new_parent_id, + updated_at = NOW() + WHERE pid = :old_group_id AND deleted_at IS NULL + """ + ), + {"old_group_id": GroupId, "new_parent_id": Body.new_parent_id}, + ) + await session.commit() + moved = int(result.rowcount or 0) + return EvaluationPointGroupRebindVO(success=True, message="二级分组迁移成功", rebind_count=moved, doc_types_updated=moved) + + async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO) -> EvaluationPointGroupBatchStatusVO: + ids = sorted({int(item) for item in Body.ids if item}) + if not ids: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请选择至少一个分组") + async with GetAsyncSession() as session: + await self._ensure_ready(session) + result = await session.execute( + text( + """ + UPDATE leaudit_evaluation_point_groups + SET is_enabled = :is_enabled, + updated_at = NOW() + WHERE id IN :ids AND deleted_at IS NULL + """ + ).bindparams(bindparam("ids", expanding=True)), + {"ids": ids, "is_enabled": Body.is_enabled}, + ) + await session.commit() + updated_count = int(result.rowcount or 0) + return EvaluationPointGroupBatchStatusVO(success=True, updated_count=updated_count, message=f"成功更新 {updated_count} 个分组状态") + + async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO) -> EvaluationPointGroupBatchDeleteVO: + ids = sorted({int(item) for item in Body.ids if item}) + if not ids: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请选择至少一个分组") + async with GetAsyncSession() as session: + await self._ensure_ready(session) + rows = ( + await session.execute( + text( + "SELECT id, pid, document_type_id FROM leaudit_evaluation_point_groups WHERE id IN :ids AND deleted_at IS NULL" + ).bindparams(bindparam("ids", expanding=True)), + {"ids": ids}, + ) + ).mappings().all() + if len(rows) != len(ids): + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "部分分组不存在") + if any(self._normalize_pid(row["pid"]) == 0 for row in rows): + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "批量删除仅支持二级分组") + + deleted_bindings = 0 + for row in rows: + deleted_bindings += int( + ( + await session.execute( + text( + "UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE group_id = :group_id AND deleted_at IS NULL" + ), + {"group_id": int(row["id"])}, + ) + ).rowcount + or 0 + ) + await sync_doc_type_bindings_from_group(session, int(row["id"])) + result = await session.execute( + text( + "UPDATE leaudit_evaluation_point_groups SET deleted_at = NOW(), updated_at = NOW() WHERE id IN :ids AND deleted_at IS NULL" + ).bindparams(bindparam("ids", expanding=True)), + {"ids": ids}, + ) + await session.commit() + deleted_groups = int(result.rowcount or 0) + return EvaluationPointGroupBatchDeleteVO(success=True, deleted_groups=deleted_groups, deleted_points=deleted_bindings, message=f"成功删除 {deleted_groups} 个分组") + + async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO) -> RuleGroupBindingVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + group = await self._get_group_row(session, GroupId) + if self._normalize_pid(group["pid"]) == 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "一级分组不能直接绑定规则集,请先选择二级分组") + await self._ensure_rule_set_valid(session, Body.rule_set_id) + existing = ( + await session.execute( + text( + "SELECT id FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND rule_set_id = :rule_set_id AND deleted_at IS NULL LIMIT 1" + ), + {"group_id": GroupId, "rule_set_id": Body.rule_set_id}, + ) + ).mappings().first() + if existing: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "该规则集已绑定到当前二级分组") + row = ( + await session.execute( + text( + """ + INSERT INTO leaudit_rule_group_bindings ( + group_id, rule_set_id, priority, is_active, note, created_at, updated_at + ) VALUES ( + :group_id, :rule_set_id, :priority, :is_active, :note, NOW(), NOW() + ) + RETURNING id + """ + ), + { + "group_id": GroupId, + "rule_set_id": Body.rule_set_id, + "priority": Body.priority, + "is_active": Body.is_active, + "note": Body.note.strip() if Body.note else None, + }, + ) + ).mappings().one() + await sync_doc_type_bindings_from_group(session, GroupId) + await session.commit() + binding_id = int(row["id"]) + binding_row = await self._get_binding_row(session, binding_id) + binding_vo = await self._build_binding_vo(binding_row) + return binding_vo + + async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO) -> RuleGroupBindingVO: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + current = await self._get_binding_row(session, BindingId) + await session.execute( + text( + """ + UPDATE leaudit_rule_group_bindings + SET priority = :priority, + is_active = :is_active, + note = :note, + updated_at = NOW() + WHERE id = :binding_id + """ + ), + { + "binding_id": BindingId, + "priority": Body.priority if Body.priority is not None else int(current.get("priority") or 0), + "is_active": Body.is_active if Body.is_active is not None else bool(current.get("is_active", True)), + "note": Body.note.strip() if Body.note else (current.get("note") if Body.note is None else None), + }, + ) + await sync_doc_type_bindings_from_group(session, int(current["group_id"])) + await session.commit() + binding_row = await self._get_binding_row(session, BindingId) + binding_vo = await self._build_binding_vo(binding_row) + return binding_vo + + async def DeleteBinding(self, BindingId: int) -> None: + async with GetAsyncSession() as session: + await self._ensure_ready(session) + current = await self._get_binding_row(session, BindingId) + await session.execute( + text( + "UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE id = :binding_id" + ), + {"binding_id": BindingId}, + ) + await sync_doc_type_bindings_from_group(session, int(current["group_id"])) + await session.commit() + + async def _ensure_ready(self, session) -> None: + self._rule_set_meta_cache = None + await ensure_rule_group_schema(session) + await bootstrap_rule_groups(session) + + async def _get_group_row(self, session, group_id: int): + row = ( + await session.execute( + text( + """ + SELECT + g.id, + g.pid, + g.name, + g.code, + g.description, + g.document_type_id, + dt.name AS document_type_name, + COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id, + em.name AS entry_module_name, + g.sort_order, + g.is_enabled, + g.created_at, + g.updated_at, + COALESCE(bg.binding_count, 0) AS rule_count + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id + LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL + LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) + LEFT JOIN ( + SELECT group_id, COUNT(*)::int AS binding_count + FROM leaudit_rule_group_bindings + WHERE deleted_at IS NULL + GROUP BY group_id + ) bg ON bg.group_id = g.id + WHERE g.id = :group_id AND g.deleted_at IS NULL + LIMIT 1 + """ + ), + {"group_id": group_id}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则分组不存在") + return row + + async def _get_binding_row(self, session, binding_id: int): + row = ( + await session.execute( + text( + """ + SELECT id, group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at + FROM leaudit_rule_group_bindings + WHERE id = :binding_id AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"binding_id": binding_id}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则组绑定不存在") + return row + + async def _load_binding_map(self, session, group_ids: list[int]) -> dict[int, list[RuleGroupBindingVO]]: + group_ids = [int(item) for item in group_ids if item] + if not group_ids: + return {} + rows = ( + await session.execute( + text( + """ + SELECT id, group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at + FROM leaudit_rule_group_bindings + WHERE group_id IN :group_ids AND deleted_at IS NULL + ORDER BY priority DESC, id ASC + """ + ).bindparams(bindparam("group_ids", expanding=True)), + {"group_ids": group_ids}, + ) + ).mappings().all() + result: dict[int, list[RuleGroupBindingVO]] = {} + for row in rows: + result.setdefault(int(row["group_id"]), []).append(await self._build_binding_vo(row)) + return result + + async def _build_binding_vo(self, row) -> RuleGroupBindingVO: + rule_set_meta = await self._get_rule_set_meta(int(row["rule_set_id"])) + return RuleGroupBindingVO( + id=int(row["id"]), + group_id=int(row["group_id"]), + rule_set_id=int(row["rule_set_id"]), + rule_type_binding_id=int(row["rule_type_binding_id"]) if row.get("rule_type_binding_id") else None, + priority=int(row.get("priority") or 0), + is_active=bool(row.get("is_active", True)), + note=row.get("note"), + rule_type=rule_set_meta.get("rule_type"), + rule_name=rule_set_meta.get("rule_name"), + current_version_id=rule_set_meta.get("current_version_id"), + fallback_version_id=rule_set_meta.get("fallback_version_id"), + has_usable_version=bool(rule_set_meta.get("has_usable_version", False)), + usable_rule_count=int(rule_set_meta.get("usable_rule_count") or 0), + ) + + async def _get_rule_set_meta(self, rule_set_id: int) -> dict[str, Any]: + if not getattr(self, "_rule_set_meta_cache", None): + rule_sets = await self.RuleService.ListSets() + self._rule_set_meta_cache = { + int(item.id): { + "rule_type": item.ruleType, + "rule_name": item.ruleName, + "current_version_id": item.currentVersionId, + "fallback_version_id": item.fallbackVersionId, + "has_usable_version": item.hasUsableVersion, + "usable_rule_count": item.usableRuleCount, + } + for item in rule_sets + } + return self._rule_set_meta_cache.get(rule_set_id, {}) + + async def _ensure_parent_valid(self, session, pid: int): + if pid == 0: + return None + parent = await self._get_group_row(session, pid) + if self._normalize_pid(parent["pid"]) != 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上级分组必须是一级分组") + return parent + + async def _ensure_entry_module_valid( + self, + session, + pid: int, + entry_module_id: int | None, + parent: Any | None, + ) -> int | None: + if pid != 0: + if parent is not None and parent.get("entry_module_id") is not None: + return int(parent["entry_module_id"]) + return None if entry_module_id in (None, 0, "0", "") else int(entry_module_id) + + if entry_module_id in (None, 0, "0", ""): + return None + + exists = ( + await session.execute( + text("SELECT id FROM leaudit_entry_modules WHERE id = :entry_module_id AND deleted_at IS NULL LIMIT 1"), + {"entry_module_id": int(entry_module_id)}, + ) + ).scalar_one_or_none() + if exists is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联入口模块不存在") + return int(entry_module_id) + + async def _ensure_document_type_valid( + self, + session, + pid: int, + document_type_id: int | None, + entry_module_id: int | None, + group_id: int | None, + parent: Any | None, + ) -> int | None: + if pid == 0: + if document_type_id is None and entry_module_id is None: + return None + else: + if parent is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "二级分组必须挂到一级分组下") + if parent.get("document_type_id") is not None: + parent_doc_type_id = int(parent["document_type_id"]) + if document_type_id is None: + document_type_id = parent_doc_type_id + elif int(document_type_id) != parent_doc_type_id: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "二级分组的文档类型必须与所属一级分组一致") + elif document_type_id is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "二级分组必须明确绑定具体文档类型") + + if document_type_id is None: + return None + exists = ( + await session.execute( + text( + "SELECT id FROM leaudit_document_types WHERE id = :doc_type_id AND deleted_at IS NULL LIMIT 1" + ), + {"doc_type_id": int(document_type_id)}, + ) + ).scalar_one_or_none() + if exists is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联文档类型不存在") + if pid == 0: + duplicated_root = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id = :doc_type_id + AND (:group_id IS NULL OR id <> :group_id) + LIMIT 1 + """ + ), + {"doc_type_id": int(document_type_id), "group_id": group_id}, + ) + ).scalar_one_or_none() + if duplicated_root is not None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "该文档类型已存在一级分组") + return int(document_type_id) + + async def _ensure_code_unique(self, session, code: str, group_id: int | None) -> None: + sql = "SELECT id FROM leaudit_evaluation_point_groups WHERE LOWER(code) = LOWER(:code) AND deleted_at IS NULL" + params: dict[str, Any] = {"code": code} + if group_id is not None: + sql += " AND id <> :group_id" + params["group_id"] = group_id + duplicated = (await session.execute(text(sql + " LIMIT 1"), params)).scalar_one_or_none() + if duplicated is not None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码已存在") + + async def _ensure_rule_set_valid(self, session, rule_set_id: int) -> None: + exists = ( + await session.execute( + text("SELECT id FROM leaudit_rule_sets WHERE id = :rule_set_id AND deleted_at IS NULL LIMIT 1"), + {"rule_set_id": rule_set_id}, + ) + ).scalar_one_or_none() + if exists is None: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "规则集不存在") + + def _normalize_create_payload(self, body: EvaluationPointGroupCreateDTO) -> dict[str, Any]: + name = body.name.strip() + code = body.code.strip() + if not name: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组名称不能为空") + if not code: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码不能为空") + return { + "name": name, + "code": code, + "pid": self._normalize_pid(body.pid), + "description": body.description.strip() if body.description else None, + "document_type_id": body.document_type_id, + "entry_module_id": body.entry_module_id, + "sort_order": int(body.sort_order or 0), + "is_enabled": body.is_enabled, + } + + def _to_group_vo(self, row, bindings: list[RuleGroupBindingVO], include_rule_count: bool) -> EvaluationPointGroupVO: + return EvaluationPointGroupVO( + id=int(row["id"]), + pid=self._normalize_pid(row.get("pid")), + name=str(row.get("name") or ""), + code=str(row.get("code") or ""), + description=row.get("description"), + document_type_id=int(row["document_type_id"]) if row.get("document_type_id") is not None else None, + document_type_name=row.get("document_type_name"), + entry_module_id=int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None, + entry_module_name=row.get("entry_module_name"), + sort_order=int(row.get("sort_order") or 0), + is_enabled=bool(row.get("is_enabled", True)), + created_at=self._to_iso(row.get("created_at")), + updated_at=self._to_iso(row.get("updated_at")), + rule_count=len(bindings) if include_rule_count else None, + bindings=bindings, + children=None, + ) + + def _normalize_pid(self, value: Any) -> int: + if value in (None, "", "0", 0): + return 0 + return int(value) + + def _to_iso(self, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py new file mode 100644 index 0000000..0531a44 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/evaluationPointServiceImpl.py @@ -0,0 +1,552 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime +from decimal import Decimal +from typing import Any +from urllib.parse import quote_plus + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from fastapi_admin.config import DB_HOST, DB_PASSWORD, DB_PORT, DB_USER +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.evaluationPointDto import ( + EvaluationPointCreateDTO, + EvaluationPointUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointVo import ( + AttributeTypeListVO, + AttributeTypeVO, + EvaluationPointDeleteVO, + EvaluationPointListVO, + EvaluationPointVO, +) +from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService + +_LEGACY_DB_NAME = os.getenv("LEGACY_RULE_DB_NAME", "docauditai") +_LEGACY_DB_URL = ( + f"postgresql+asyncpg://{quote_plus(str(DB_USER))}:{quote_plus(str(DB_PASSWORD))}" + f"@{DB_HOST}:{DB_PORT}/{quote_plus(_LEGACY_DB_NAME)}" +) +_LEGACY_ENGINE = create_async_engine(_LEGACY_DB_URL, pool_pre_ping=True) +_LegacySession = async_sessionmaker(_LEGACY_ENGINE, expire_on_commit=False) + + +class EvaluationPointServiceImpl(IEvaluationPointService): + """评查点服务实现。""" + + async def ListPoints( + self, + Name: str | None, + Code: str | None, + Risk: str | None, + IsEnabled: bool | None, + GroupPid: int | None, + GroupId: int | None, + DocumentAttributeType: str | None, + Area: str | None, + Page: int, + PageSize: int, + ) -> EvaluationPointListVO: + offset = max(Page - 1, 0) * PageSize + where_clause, params = self._build_list_filters( + Name=Name, + Code=Code, + Risk=Risk, + IsEnabled=IsEnabled, + GroupPid=GroupPid, + GroupId=GroupId, + DocumentAttributeType=DocumentAttributeType, + Area=Area, + ) + params.update({"limit": PageSize, "offset": offset}) + + async with _LegacySession() as session: + total = int( + ( + await session.execute( + text( + f""" + SELECT COUNT(*) + FROM evaluation_points ep + LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id + LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) + WHERE {where_clause} + """ + ), + params, + ) + ).scalar_one() + ) + rows = ( + await session.execute( + text( + f""" + SELECT + ep.id, + ep.code, + ep.name, + ep.evaluation_point_groups_id, + ep.evaluation_point_groups_pid, + ep.risk, + ep.description, + ep.is_enabled, + ep.document_attribute_type, + ep.references_laws, + ep.extraction_config, + ep.evaluation_config, + ep.pass_message, + ep.fail_message, + ep.suggestion_message, + ep.suggestion_message_type, + ep.post_action, + ep.action_config, + ep.score, + ep.area, + ep.created_at, + ep.updated_at, + child_group.name AS group_name, + parent_group.name AS rule_type + FROM evaluation_points ep + LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id + LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) + WHERE {where_clause} + ORDER BY COALESCE(ep.sort, 0) ASC, ep.updated_at DESC, ep.id DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + + return EvaluationPointListVO( + data=[self._to_point_vo(row) for row in rows], + total=total, + page=Page, + page_size=PageSize, + ) + + async def GetPoint(self, PointId: int) -> EvaluationPointVO: + async with _LegacySession() as session: + row = ( + await session.execute( + text( + """ + SELECT + ep.id, + ep.code, + ep.name, + ep.evaluation_point_groups_id, + ep.evaluation_point_groups_pid, + ep.risk, + ep.description, + ep.is_enabled, + ep.document_attribute_type, + ep.references_laws, + ep.extraction_config, + ep.evaluation_config, + ep.pass_message, + ep.fail_message, + ep.suggestion_message, + ep.suggestion_message_type, + ep.post_action, + ep.action_config, + ep.score, + ep.area, + ep.created_at, + ep.updated_at, + child_group.name AS group_name, + parent_group.name AS rule_type + FROM evaluation_points ep + LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id + LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) + WHERE ep.id = :point_id + """ + ), + {"point_id": PointId}, + ) + ).mappings().first() + + if not row: + raise LeauditException(StatusCodeEnum.NOT_FOUND, "评查点不存在") + return self._to_point_vo(row) + + async def CreatePoint(self, Body: EvaluationPointCreateDTO) -> EvaluationPointVO: + await self._validate_group_relation(Body.evaluation_point_groups_pid, Body.evaluation_point_groups_id) + await self._ensure_code_unique(str(Body.code).strip()) + + now = datetime.utcnow() + insert_params = self._build_write_params(Body, now) + insert_params["created_at"] = now + insert_params["updated_at"] = now + + async with _LegacySession() as session: + async with session.begin(): + new_id = await session.scalar( + text( + """ + INSERT INTO evaluation_points ( + code, + name, + evaluation_point_groups_id, + evaluation_point_groups_pid, + risk, + description, + is_enabled, + document_attribute_type, + references_laws, + extraction_config, + evaluation_config, + pass_message, + fail_message, + suggestion_message, + suggestion_message_type, + post_action, + action_config, + score, + area, + created_at, + updated_at + ) VALUES ( + :code, + :name, + :evaluation_point_groups_id, + :evaluation_point_groups_pid, + :risk, + :description, + :is_enabled, + :document_attribute_type, + CAST(:references_laws AS jsonb), + CAST(:extraction_config AS jsonb), + CAST(:evaluation_config AS jsonb), + :pass_message, + :fail_message, + :suggestion_message, + :suggestion_message_type, + :post_action, + :action_config, + :score, + :area, + :created_at, + :updated_at + ) + RETURNING id + """ + ), + insert_params, + ) + await session.commit() + + return await self.GetPoint(int(new_id)) + + async def UpdatePoint(self, PointId: int, Body: EvaluationPointUpdateDTO) -> EvaluationPointVO: + await self.GetPoint(PointId) + + payload = Body.model_dump(exclude_unset=True) + if not payload: + return await self.GetPoint(PointId) + + group_pid = payload.get("evaluation_point_groups_pid") + group_id = payload.get("evaluation_point_groups_id") + if group_pid is not None or group_id is not None: + current = await self.GetPoint(PointId) + await self._validate_group_relation( + group_pid if group_pid is not None else current.evaluation_point_groups_pid, + group_id if group_id is not None else current.evaluation_point_groups_id, + ) + + if "code" in payload and payload["code"]: + await self._ensure_code_unique(str(payload["code"]).strip(), PointId) + + updates: list[str] = [] + params: dict[str, Any] = {"point_id": PointId, "updated_at": datetime.utcnow()} + simple_fields = [ + "code", + "name", + "evaluation_point_groups_id", + "evaluation_point_groups_pid", + "risk", + "description", + "is_enabled", + "document_attribute_type", + "pass_message", + "fail_message", + "suggestion_message", + "suggestion_message_type", + "post_action", + "action_config", + "score", + "area", + ] + json_fields = ["references_laws", "extraction_config", "evaluation_config"] + + for field in simple_fields: + if field not in payload: + continue + params[field] = self._normalize_scalar_field(field, payload[field]) + updates.append(f"{field} = :{field}") + + for field in json_fields: + if field not in payload: + continue + params[field] = json.dumps(payload[field] if payload[field] is not None else self._default_json(field), ensure_ascii=False) + updates.append(f"{field} = CAST(:{field} AS jsonb)") + + updates.append("updated_at = :updated_at") + + async with _LegacySession() as session: + async with session.begin(): + await session.execute( + text(f"UPDATE evaluation_points SET {', '.join(updates)} WHERE id = :point_id"), + params, + ) + await session.commit() + + return await self.GetPoint(PointId) + + async def DeletePoint(self, PointId: int) -> EvaluationPointDeleteVO: + await self.GetPoint(PointId) + async with _LegacySession() as session: + async with session.begin(): + await session.execute(text("DELETE FROM evaluation_points WHERE id = :point_id"), {"point_id": PointId}) + await session.commit() + return EvaluationPointDeleteVO(success=True, message="评查点删除成功") + + async def GetAttributeTypes(self) -> AttributeTypeListVO: + async with _LegacySession() as session: + rows = ( + await session.execute( + text( + """ + SELECT DISTINCT TRIM(document_attribute_type) AS code + FROM evaluation_points + WHERE document_attribute_type IS NOT NULL + AND TRIM(document_attribute_type) <> '' + ORDER BY TRIM(document_attribute_type) ASC + """ + ) + ) + ).scalars().all() + + types = [AttributeTypeVO(code=str(item), label=str(item)) for item in rows if item] + if not any(item.code == "ALL" for item in types): + types.insert(0, AttributeTypeVO(code="ALL", label="通用")) + return AttributeTypeListVO(types=types) + + def _build_list_filters( + self, + Name: str | None, + Code: str | None, + Risk: str | None, + IsEnabled: bool | None, + GroupPid: int | None, + GroupId: int | None, + DocumentAttributeType: str | None, + Area: str | None, + ) -> tuple[str, dict[str, Any]]: + filters = ["1=1"] + params: dict[str, Any] = {} + + name = (Name or "").strip() + code = (Code or "").strip() + if name and code: + if name == code: + filters.append("(ep.name ILIKE :keyword OR ep.code ILIKE :keyword)") + params["keyword"] = f"%{name}%" + else: + filters.append("ep.name ILIKE :name") + filters.append("ep.code ILIKE :code") + params["name"] = f"%{name}%" + params["code"] = f"%{code}%" + elif name: + filters.append("ep.name ILIKE :name") + params["name"] = f"%{name}%" + elif code: + filters.append("ep.code ILIKE :code") + params["code"] = f"%{code}%" + + if Risk: + filters.append("ep.risk = :risk") + params["risk"] = Risk + if IsEnabled is not None: + filters.append("ep.is_enabled = :is_enabled") + params["is_enabled"] = IsEnabled + if GroupPid is not None: + filters.append("ep.evaluation_point_groups_pid = :group_pid") + params["group_pid"] = GroupPid + if GroupId is not None: + filters.append("ep.evaluation_point_groups_id = :group_id") + params["group_id"] = GroupId + if DocumentAttributeType: + if DocumentAttributeType == "ALL": + filters.append("(ep.document_attribute_type = 'ALL' OR ep.document_attribute_type = '通用' OR ep.document_attribute_type IS NULL OR TRIM(ep.document_attribute_type) = '')") + else: + filters.append("ep.document_attribute_type = :document_attribute_type") + params["document_attribute_type"] = DocumentAttributeType + if Area: + filters.append("ep.area = :area") + params["area"] = Area + + return " AND ".join(filters), params + + async def _validate_group_relation(self, GroupPid: int | None, GroupId: int | None) -> None: + if not GroupPid: + raise LeauditException(StatusCodeEnum.BAD_REQUEST, "评查点类型不能为空") + if not GroupId: + raise LeauditException(StatusCodeEnum.BAD_REQUEST, "所属规则组不能为空") + + async with _LegacySession() as session: + parent = ( + await session.execute( + text("SELECT id, pid, is_enabled FROM evaluation_point_groups WHERE id = :group_pid"), + {"group_pid": GroupPid}, + ) + ).mappings().first() + child = ( + await session.execute( + text("SELECT id, pid, is_enabled FROM evaluation_point_groups WHERE id = :group_id"), + {"group_id": GroupId}, + ) + ).mappings().first() + + if not parent: + raise LeauditException(StatusCodeEnum.BAD_REQUEST, "评查点类型不存在") + if not child: + raise LeauditException(StatusCodeEnum.BAD_REQUEST, "所属规则组不存在") + if int(child.get("pid") or 0) != int(GroupPid): + raise LeauditException(StatusCodeEnum.BAD_REQUEST, "所属规则组与评查点类型不匹配") + + async def _ensure_code_unique(self, Code: str, PointId: int | None = None) -> None: + params: dict[str, Any] = {"code": Code} + sql = "SELECT id FROM evaluation_points WHERE LOWER(code) = LOWER(:code)" + if PointId is not None: + sql += " AND id <> :point_id" + params["point_id"] = PointId + + async with _LegacySession() as session: + exists = ( + await session.execute(text(sql), params) + ).scalar_one_or_none() + + if exists: + raise LeauditException(StatusCodeEnum.BAD_REQUEST, "评查点编码已存在") + + def _build_write_params(self, Body: EvaluationPointCreateDTO, Now: datetime) -> dict[str, Any]: + return { + "code": str(Body.code).strip(), + "name": str(Body.name).strip(), + "evaluation_point_groups_id": Body.evaluation_point_groups_id, + "evaluation_point_groups_pid": Body.evaluation_point_groups_pid, + "risk": Body.risk, + "description": Body.description or "", + "is_enabled": bool(Body.is_enabled), + "document_attribute_type": self._normalize_document_attribute_type(Body.document_attribute_type), + "references_laws": json.dumps(Body.references_laws or self._default_json("references_laws"), ensure_ascii=False), + "extraction_config": json.dumps(Body.extraction_config or self._default_json("extraction_config"), ensure_ascii=False), + "evaluation_config": json.dumps(Body.evaluation_config or self._default_json("evaluation_config"), ensure_ascii=False), + "pass_message": Body.pass_message or "", + "fail_message": Body.fail_message or "", + "suggestion_message": Body.suggestion_message or "", + "suggestion_message_type": Body.suggestion_message_type or "warning", + "post_action": Body.post_action or "none", + "action_config": Body.action_config or "", + "score": float(Body.score or 0), + "area": (Body.area or "").strip() or None, + "created_at": Now, + "updated_at": Now, + } + + def _to_point_vo(self, row: dict[str, Any]) -> EvaluationPointVO: + group_id = row.get("evaluation_point_groups_id") + return EvaluationPointVO( + id=int(row["id"]), + code=str(row.get("code") or ""), + name=str(row.get("name") or ""), + evaluation_point_groups_id=int(group_id) if group_id is not None else None, + evaluation_point_groups_pid=int(row["evaluation_point_groups_pid"]) if row.get("evaluation_point_groups_pid") is not None else None, + ruleType=str(row.get("rule_type") or ""), + groupName=str(row.get("group_name") or ""), + groupId=str(group_id) if group_id is not None else "", + risk=str(row.get("risk") or ""), + description=str(row.get("description") or ""), + is_enabled=bool(row.get("is_enabled")), + document_attribute_type=self._normalize_document_attribute_type(row.get("document_attribute_type")), + references_laws=self._parse_json(row.get("references_laws"), self._default_json("references_laws")), + extraction_config=self._parse_json(row.get("extraction_config"), self._default_json("extraction_config")), + evaluation_config=self._parse_json(row.get("evaluation_config"), self._default_json("evaluation_config")), + pass_message=str(row.get("pass_message") or ""), + fail_message=str(row.get("fail_message") or ""), + suggestion_message=str(row.get("suggestion_message") or ""), + suggestion_message_type=str(row.get("suggestion_message_type") or "warning"), + post_action=str(row.get("post_action") or "none"), + action_config=str(row.get("action_config") or ""), + score=self._normalize_score(row.get("score")), + area=str(row.get("area") or ""), + created_at=self._format_datetime(row.get("created_at")), + updated_at=self._format_datetime(row.get("updated_at")), + ) + + def _parse_json(self, value: Any, default: Any) -> Any: + if value is None: + return default + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return default + return value + + def _format_datetime(self, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + def _normalize_document_attribute_type(self, value: Any) -> str: + normalized = str(value or "").strip() + if not normalized: + return "通用" + if normalized == "ALL": + return "通用" + return normalized + + def _normalize_score(self, value: Any) -> float: + if value is None: + return 0 + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (int, float)): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return 0 + + def _normalize_scalar_field(self, field: str, value: Any) -> Any: + if field in {"code", "name"}: + return str(value or "").strip() + if field == "document_attribute_type": + return self._normalize_document_attribute_type(value) + if field == "area": + area = str(value or "").strip() + return area or None + if field == "score": + return self._normalize_score(value) + return value + + def _default_json(self, field: str) -> dict[str, Any]: + if field == "references_laws": + return {"name": "", "content": "", "articles": []} + if field == "evaluation_config": + return {"logicType": "and", "customLogic": "", "rules": []} + return { + "llm": {"fields": [], "prompt_setting": {"type": "system", "template": ""}}, + "vlm": {"fields": [], "prompt_setting": {"type": "system", "template": ""}}, + "regex": {"fields": []}, + } diff --git a/fastapi_modules/fastapi_leaudit/services/impl/promptTemplateServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/promptTemplateServiceImpl.py new file mode 100644 index 0000000..ae8b21c --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/promptTemplateServiceImpl.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime +from typing import Any +from urllib.parse import quote_plus + +from sqlalchemy import bindparam, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from fastapi_admin.config import DB_HOST, DB_PASSWORD, DB_PORT, DB_USER +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_modules.fastapi_leaudit.domian.Dto.promptTemplateDto import PromptTemplateCreateDTO, PromptTemplateUpdateDTO +from fastapi_modules.fastapi_leaudit.domian.vo.promptTemplateVo import PromptTemplateListVO, PromptTemplateTypeListVO, PromptTemplateTypeOptionVO, PromptTemplateVO +from fastapi_modules.fastapi_leaudit.services.promptTemplateService import IPromptTemplateService + +_LEGACY_DB_NAME = os.getenv("LEGACY_RULE_DB_NAME", "docauditai") +_LEGACY_DB_URL = ( + f"postgresql+asyncpg://{quote_plus(str(DB_USER))}:{quote_plus(str(DB_PASSWORD))}" + f"@{DB_HOST}:{DB_PORT}/{quote_plus(_LEGACY_DB_NAME)}" +) +_LEGACY_ENGINE = create_async_engine(_LEGACY_DB_URL, pool_pre_ping=True) +_LegacySession = async_sessionmaker(_LEGACY_ENGINE, expire_on_commit=False) + +_ALLOWED_TEMPLATE_TYPES = {"LLM_Extraction", "VLM_Extraction", "Evaluation", "Summary", "Common"} +_TYPE_LABELS = { + "LLM_Extraction": "LLM抽取", + "VLM_Extraction": "VLM抽取", + "Evaluation": "评查", + "Summary": "摘要", + "Common": "通用", +} + + +class PromptTemplateServiceImpl(IPromptTemplateService): + async def ListTemplates( + self, + Search: str | None, + TemplateTypes: list[str] | None, + Status: int | None, + Page: int, + PageSize: int, + ) -> PromptTemplateListVO: + offset = max(Page - 1, 0) * PageSize + filters = ["1=1"] + params: dict[str, Any] = {"limit": PageSize, "offset": offset} + + if Search: + filters.append("(pt.template_name ILIKE :search OR pt.template_code ILIKE :search)") + params["search"] = f"%{Search.strip()}%" + normalized_types = [item for item in (TemplateTypes or []) if item in _ALLOWED_TEMPLATE_TYPES] + if normalized_types: + filters.append("pt.template_type IN :template_types") + params["template_types"] = tuple(normalized_types) + if Status is not None: + filters.append("pt.status = :status") + params["status"] = Status + + where_clause = " AND ".join(filters) + + count_sql = text(f"SELECT COUNT(*) FROM prompt_templates pt WHERE {where_clause}") + list_sql = text( + f""" + SELECT + pt.id, + pt.template_name, + pt.template_type, + pt.description, + pt.template_content, + pt.variables, + pt.status, + pt.version, + pt.created_by, + pt.created_at, + pt.updated_at, + pt.template_code, + pt.template_abbreviation + FROM prompt_templates pt + WHERE {where_clause} + ORDER BY pt.updated_at DESC, pt.id DESC + LIMIT :limit OFFSET :offset + """ + ) + if normalized_types: + count_sql = count_sql.bindparams(bindparam("template_types", expanding=True)) + list_sql = list_sql.bindparams(bindparam("template_types", expanding=True)) + + async with _LegacySession() as session: + total = int((await session.execute(count_sql, params)).scalar_one()) + rows = ( + await session.execute( + list_sql, + params, + ) + ).mappings().all() + + usernames = await self._load_usernames([int(row["created_by"]) for row in rows if row.get("created_by") is not None]) + items = [self._to_vo(row, usernames.get(int(row["created_by"]))) for row in rows] + return PromptTemplateListVO(total=total, page=Page, page_size=PageSize, items=items) + + async def GetTemplate(self, TemplateId: int) -> PromptTemplateVO: + async with _LegacySession() as session: + row = ( + await session.execute( + text( + """ + SELECT + id, + template_name, + template_type, + description, + template_content, + variables, + status, + version, + created_by, + created_at, + updated_at, + template_code, + template_abbreviation + FROM prompt_templates + WHERE id = :id + """ + ), + {"id": TemplateId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "提示词模板不存在") + usernames = await self._load_usernames([int(row["created_by"])] if row.get("created_by") is not None else []) + return self._to_vo(row, usernames.get(int(row["created_by"])) if row.get("created_by") is not None else None) + + async def CreateTemplate(self, Body: PromptTemplateCreateDTO) -> PromptTemplateVO: + await self._validate_template_payload(Body.template_type, Body.template_code, Body.template_abbreviation) + await self._ensure_template_code_unique(Body.template_code) + now = datetime.utcnow() + payload = self._build_write_payload(Body) + payload.update({"created_at": now, "updated_at": now}) + async with _LegacySession() as session: + async with session.begin(): + new_id = await session.scalar( + text( + """ + INSERT INTO prompt_templates ( + template_name, + template_type, + description, + template_content, + variables, + status, + version, + created_by, + created_at, + updated_at, + template_code, + template_abbreviation + ) VALUES ( + :template_name, + :template_type, + :description, + :template_content, + CAST(:variables AS jsonb), + :status, + :version, + :created_by, + :created_at, + :updated_at, + :template_code, + :template_abbreviation + ) RETURNING id + """ + ), + payload, + ) + await session.commit() + return await self.GetTemplate(int(new_id)) + + async def UpdateTemplate(self, TemplateId: int, Body: PromptTemplateUpdateDTO) -> PromptTemplateVO: + current = await self.GetTemplate(TemplateId) + payload = Body.model_dump(exclude_unset=True) + if not payload: + return current + new_type = payload.get("template_type", current.template_type) + new_code = payload.get("template_code", current.template_code) + new_abbr = payload.get("template_abbreviation", current.template_abbreviation) + await self._validate_template_payload(new_type, new_code, new_abbr) + if "template_code" in payload: + await self._ensure_template_code_unique(payload.get("template_code"), TemplateId) + + updates: list[str] = [] + params: dict[str, Any] = {"id": TemplateId, "updated_at": datetime.utcnow()} + simple_fields = [ + "template_name", + "template_type", + "description", + "template_content", + "status", + "version", + "template_code", + "template_abbreviation", + ] + for field in simple_fields: + if field in payload: + params[field] = payload[field] + updates.append(f"{field} = :{field}") + if "variables" in payload: + params["variables"] = json.dumps(payload.get("variables") or {}, ensure_ascii=False) + updates.append("variables = CAST(:variables AS jsonb)") + updates.append("updated_at = :updated_at") + + async with _LegacySession() as session: + async with session.begin(): + await session.execute(text(f"UPDATE prompt_templates SET {', '.join(updates)} WHERE id = :id"), params) + await session.commit() + return await self.GetTemplate(TemplateId) + + async def DeleteTemplate(self, TemplateId: int) -> None: + await self.GetTemplate(TemplateId) + async with _LegacySession() as session: + async with session.begin(): + await session.execute(text("DELETE FROM prompt_templates WHERE id = :id"), {"id": TemplateId}) + await session.commit() + + async def GetTemplateTypes(self) -> PromptTemplateTypeListVO: + async with _LegacySession() as session: + rows = ( + await session.execute( + text( + """ + SELECT template_type, COUNT(*)::int AS count + FROM prompt_templates + WHERE template_type IS NOT NULL AND TRIM(template_type) <> '' + GROUP BY template_type + ORDER BY template_type ASC + """ + ) + ) + ).mappings().all() + items = [ + PromptTemplateTypeOptionVO( + value=str(row["template_type"]), + label=_TYPE_LABELS.get(str(row["template_type"]), str(row["template_type"])), + count=int(row["count"] or 0), + ) + for row in rows + ] + return PromptTemplateTypeListVO(items=items) + + async def DuplicateTemplate(self, TemplateId: int, NewCode: str | None) -> PromptTemplateVO: + current = await self.GetTemplate(TemplateId) + code = (NewCode or "").strip() or self._generate_copy_code(current.template_code or f"template_{TemplateId}") + await self._ensure_template_code_unique(code) + body = PromptTemplateCreateDTO( + template_name=f"{current.template_name}-副本", + template_type=current.template_type, + description=current.description, + template_content=current.template_content, + variables=current.variables, + status=current.status, + version=current.version, + created_by=current.created_by, + template_code=code, + template_abbreviation=current.template_abbreviation, + ) + return await self.CreateTemplate(body) + + async def _load_usernames(self, user_ids: list[int]) -> dict[int, str]: + ids = sorted({int(item) for item in user_ids if item is not None}) + if not ids: + return {} + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text("SELECT id, username FROM sso_users WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), + {"ids": ids}, + ) + ).mappings().all() + return {int(row["id"]): str(row.get("username") or "") for row in rows} + + async def _validate_template_payload(self, template_type: str | None, template_code: str | None, template_abbreviation: str | None) -> None: + if template_type not in _ALLOWED_TEMPLATE_TYPES: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板类型不合法") + if template_type == "VLM_Extraction": + if not str(template_code or "").strip(): + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "VLM抽取模板必须填写模板code") + if not str(template_abbreviation or "").strip(): + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "VLM抽取模板必须填写模板简称") + + async def _ensure_template_code_unique(self, template_code: str | None, template_id: int | None = None) -> None: + code = str(template_code or "").strip() + if not code: + return + sql = "SELECT id FROM prompt_templates WHERE LOWER(template_code) = LOWER(:template_code)" + params: dict[str, Any] = {"template_code": code} + if template_id is not None: + sql += " AND id <> :id" + params["id"] = template_id + async with _LegacySession() as session: + exists = (await session.execute(text(sql), params)).scalar_one_or_none() + if exists: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板code已存在") + + def _build_write_payload(self, body: PromptTemplateCreateDTO) -> dict[str, Any]: + return { + "template_name": body.template_name.strip(), + "template_type": body.template_type, + "description": (body.description or "").strip() or None, + "template_content": body.template_content, + "variables": json.dumps(body.variables or {}, ensure_ascii=False), + "status": int(body.status), + "version": str(body.version or "v1.0").strip() or "v1.0", + "created_by": int(body.created_by) if body.created_by is not None else None, + "template_code": (body.template_code or "").strip() or None, + "template_abbreviation": (body.template_abbreviation or "").strip() or None, + } + + def _to_vo(self, row: dict[str, Any], username: str | None) -> PromptTemplateVO: + return PromptTemplateVO( + id=int(row["id"]), + template_name=str(row.get("template_name") or ""), + template_code=row.get("template_code"), + template_type=str(row.get("template_type") or ""), + description=row.get("description"), + template_content=str(row.get("template_content") or ""), + template_abbreviation=row.get("template_abbreviation"), + variables=self._parse_variables(row.get("variables")), + status=int(row.get("status") or 0), + version=str(row.get("version") or "v1.0"), + created_by=int(row["created_by"]) if row.get("created_by") is not None else None, + created_by_username=username, + created_at=self._format_datetime(row.get("created_at")), + updated_at=self._format_datetime(row.get("updated_at")), + ) + + def _parse_variables(self, value: Any) -> dict[str, str]: + if value is None: + return {} + if isinstance(value, dict): + return {str(k): str(v) for k, v in value.items()} + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return {str(k): str(v) for k, v in parsed.items()} + except json.JSONDecodeError: + return {} + return {} + + def _format_datetime(self, value: Any) -> str: + if isinstance(value, datetime): + return value.isoformat() + return str(value or "") + + def _generate_copy_code(self, code: str) -> str: + return f"{code}_copy_{int(datetime.utcnow().timestamp())}" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index 31024d0..a9b2df4 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -12,12 +12,14 @@ 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.rbacAdminDto import ( + RoleAccessSaveDTO, RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( + RoleAccessSaveVO, RoleListVO, RolePermissionsVO, RoleRoutesVO, @@ -156,6 +158,11 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "doc_type:create:write", "display_name": "创建文档类型", "module": "doc_type", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/document-types", "route_path": "/document-types"}, {"permission_key": "doc_type:update:write", "display_name": "更新文档类型", "module": "doc_type", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/document-types/{id}", "route_path": "/document-types"}, {"permission_key": "doc_type:delete:delete", "display_name": "删除文档类型", "module": "doc_type", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/document-types/{id}", "route_path": "/document-types"}, + {"permission_key": "evaluation_point:list:read", "display_name": "评查点列表", "module": "evaluation_point", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"}, + {"permission_key": "evaluation_point:detail:read", "display_name": "评查点详情", "module": "evaluation_point", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"}, + {"permission_key": "evaluation_point:create:write", "display_name": "创建评查点", "module": "evaluation_point", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"}, + {"permission_key": "evaluation_point:update:write", "display_name": "更新评查点", "module": "evaluation_point", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"}, + {"permission_key": "evaluation_point:delete:delete", "display_name": "删除评查点", "module": "evaluation_point", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"}, {"permission_key": "rbac:roles:read", "display_name": "角色列表", "module": "rbac", "resource": "roles", "action": "read", "api_method": "GET", "api_path": "/api/v3/rbac/roles", "route_path": "/role-permissions"}, {"permission_key": "rbac:roles:create", "display_name": "创建角色", "module": "rbac", "resource": "roles", "action": "create", "api_method": "POST", "api_path": "/api/v3/rbac/roles", "route_path": "/role-permissions"}, {"permission_key": "rbac:roles:update", "display_name": "更新角色", "module": "rbac", "resource": "roles", "action": "update", "api_method": "PUT", "api_path": "/api/v3/rbac/roles/{role_id}", "route_path": "/role-permissions"}, @@ -495,32 +502,9 @@ class RbacAdminServiceImpl(IRbacAdminService): routeIds = sorted(set(Body.route_ids)) async with GetAsyncSession() as Session: await self._ensureAdminSeeds(Session) - allRouteIds = [row[0] for row in (await Session.execute(text("SELECT id FROM sys_routes WHERE deleted_at IS NULL AND route_path = ANY(:paths)").bindparams(paths=[item["route_path"] for item in self._MANAGEABLE_ROUTE_BLUEPRINTS]))).fetchall()] - existingRows = ( - await Session.execute(text("SELECT route_id, status FROM role_route WHERE role_id = :role_id AND route_id = ANY(:route_ids)").bindparams(route_ids=allRouteIds), {"role_id": RoleId}) - ).fetchall() - existingMap = {int(routeId): int(status) for routeId, status in existingRows} - insertedCount = 0 - for routeId in allRouteIds: - if routeId in routeIds: - if routeId in existingMap: - await Session.execute( - text("UPDATE role_route SET status = 1, permission = :permission, updated_at = NOW() WHERE role_id = :role_id AND route_id = :route_id"), - {"role_id": RoleId, "route_id": routeId, "permission": Body.permission}, - ) - else: - await Session.execute( - text("INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) VALUES (:role_id, :route_id, :permission, 1, NOW(), NOW())"), - {"role_id": RoleId, "route_id": routeId, "permission": Body.permission}, - ) - insertedCount += 1 - elif routeId in existingMap and existingMap[routeId] != 0: - await Session.execute( - text("UPDATE role_route SET status = 0, updated_at = NOW() WHERE role_id = :role_id AND route_id = :route_id"), - {"role_id": RoleId, "route_id": routeId}, - ) + result = await self._updateRoleRoutesInSession(Session, RoleId, routeIds, Body.permission) await Session.commit() - return RoleRouteUpdateResultVO(role_id=RoleId, enabled_count=len(routeIds), disabled_count=max(len(allRouteIds) - len(routeIds), 0), inserted_count=insertedCount, route_ids=routeIds) + return result async def GetRolePermissions(self, CurrentUserId: int, RoleId: int) -> RolePermissionsVO: """查询角色权限授权。""" @@ -550,29 +534,29 @@ class RbacAdminServiceImpl(IRbacAdminService): await self._assertPermission(CurrentUserId, "rbac:role_permissions:write") async with GetAsyncSession() as Session: await self._ensureAdminSeeds(Session) - permissionIds = [item.permission_id for item in Body.permissions] - if Body.replace: - await Session.execute(text("DELETE FROM role_permissions WHERE role_id = :role_id"), {"role_id": Body.role_id}) - for item in Body.permissions: - await Session.execute( - text( - """ - INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) - VALUES (:role_id, :permission_id, :grant_type, :data_scope, NOW(), NOW()) - ON CONFLICT (role_id, permission_id) - DO UPDATE SET grant_type = EXCLUDED.grant_type, data_scope = EXCLUDED.data_scope, updated_at = NOW() - """ - ), - { - "role_id": Body.role_id, - "permission_id": item.permission_id, - "grant_type": item.grant_type, - "data_scope": item.data_scope, - }, - ) + await self._saveRolePermissionsInSession(Session, Body.role_id, Body.permissions, Body.replace, Body.replace_scope_permission_ids) await Session.commit() return await self.GetRolePermissions(CurrentUserId, Body.role_id) + async def SaveRoleAccess(self, CurrentUserId: int, RoleId: int, Body: RoleAccessSaveDTO) -> RoleAccessSaveVO: + """原子保存角色菜单与接口权限。""" + await self._assertManagePermission(CurrentUserId) + await self._assertPermission(CurrentUserId, "rbac:role_routes:write") + await self._assertPermission(CurrentUserId, "rbac:role_permissions:write") + routeIds = sorted(set(int(routeId) for routeId in Body.route_ids)) + permissionIds = sorted(set(int(permissionId) for permissionId in Body.permission_ids)) + permissionConfigs = [ + {"permission_id": permissionId, "grant_type": "GRANT", "data_scope": "ALL"} + for permissionId in permissionIds + ] + async with GetAsyncSession() as Session: + await self._ensureAdminSeeds(Session) + routeResult = await self._updateRoleRoutesInSession(Session, RoleId, routeIds, Body.route_permission) + await self._saveRolePermissionsInSession(Session, RoleId, permissionConfigs, True, Body.replace_scope_permission_ids) + permissionResult = await self._getRolePermissionsInSession(Session, RoleId) + await Session.commit() + return RoleAccessSaveVO(role_id=RoleId, route_result=routeResult, permission_result=permissionResult) + async def GetRoutePermissions(self, CurrentUserId: int, RouteId: int) -> RoutePermissionsVO: """查询路由关联权限定义。""" await self._assertManagePermission(CurrentUserId) @@ -592,6 +576,104 @@ class RbacAdminServiceImpl(IRbacAdminService): permissions=permissionMap.get(RouteId, []), ) + async def _updateRoleRoutesInSession(self, Session, RoleId: int, RouteIds: list[int], Permission: str) -> RoleRouteUpdateResultVO: + """在当前事务中写入角色路由授权。""" + allRouteIds = [ + row[0] + for row in ( + await Session.execute( + text("SELECT id FROM sys_routes WHERE deleted_at IS NULL AND route_path = ANY(:paths)").bindparams( + paths=[item["route_path"] for item in self._MANAGEABLE_ROUTE_BLUEPRINTS] + ) + ) + ).fetchall() + ] + existingRows = ( + await Session.execute( + text("SELECT route_id, status FROM role_route WHERE role_id = :role_id AND route_id = ANY(:route_ids)").bindparams(route_ids=allRouteIds), + {"role_id": RoleId}, + ) + ).fetchall() + existingMap = {int(routeId): int(status) for routeId, status in existingRows} + insertedCount = 0 + for routeId in allRouteIds: + if routeId in RouteIds: + if routeId in existingMap: + await Session.execute( + text("UPDATE role_route SET status = 1, permission = :permission, updated_at = NOW() WHERE role_id = :role_id AND route_id = :route_id"), + {"role_id": RoleId, "route_id": routeId, "permission": Permission}, + ) + else: + await Session.execute( + text("INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) VALUES (:role_id, :route_id, :permission, 1, NOW(), NOW())"), + {"role_id": RoleId, "route_id": routeId, "permission": Permission}, + ) + insertedCount += 1 + elif routeId in existingMap and existingMap[routeId] != 0: + await Session.execute( + text("UPDATE role_route SET status = 0, updated_at = NOW() WHERE role_id = :role_id AND route_id = :route_id"), + {"role_id": RoleId, "route_id": routeId}, + ) + return RoleRouteUpdateResultVO( + role_id=RoleId, + enabled_count=len(RouteIds), + disabled_count=max(len(allRouteIds) - len(RouteIds), 0), + inserted_count=insertedCount, + route_ids=RouteIds, + ) + + async def _saveRolePermissionsInSession(self, Session, RoleId: int, Permissions: list[Any], Replace: bool, ReplaceScopePermissionIds: list[int]) -> None: + """在当前事务中写入角色接口权限。""" + if Replace: + scopeIds = sorted({int(permissionId) for permissionId in ReplaceScopePermissionIds if permissionId}) + if scopeIds: + # 仅清理当前页面负责维护的权限范围,避免局部页面保存时误删其他模块权限。 + await Session.execute( + text("DELETE FROM role_permissions WHERE role_id = :role_id AND permission_id = ANY(:permission_ids)").bindparams(permission_ids=scopeIds), + {"role_id": RoleId}, + ) + else: + # 兼容旧调用方:若未传作用域,保留原有全量替换行为。 + await Session.execute(text("DELETE FROM role_permissions WHERE role_id = :role_id"), {"role_id": RoleId}) + for item in Permissions: + permissionId = int(item.permission_id if hasattr(item, "permission_id") else item["permission_id"]) + grantType = item.grant_type if hasattr(item, "grant_type") else item.get("grant_type") + dataScope = item.data_scope if hasattr(item, "data_scope") else item.get("data_scope") + await Session.execute( + text( + """ + INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) + VALUES (:role_id, :permission_id, :grant_type, :data_scope, NOW(), NOW()) + ON CONFLICT (role_id, permission_id) + DO UPDATE SET grant_type = EXCLUDED.grant_type, data_scope = EXCLUDED.data_scope, updated_at = NOW() + """ + ), + { + "role_id": RoleId, + "permission_id": permissionId, + "grant_type": grantType, + "data_scope": dataScope, + }, + ) + + async def _getRolePermissionsInSession(self, Session, RoleId: int) -> RolePermissionsVO: + """在当前事务中查询角色接口权限。""" + rows = ( + await Session.execute( + text( + """ + SELECT rp.id, rp.permission_id, p.permission_key, p.display_name, rp.grant_type, rp.data_scope + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = :role_id + ORDER BY p.sort_order ASC, p.id ASC + """ + ), + {"role_id": RoleId}, + ) + ).mappings().all() + return RolePermissionsVO(role_id=RoleId, permissions=[self._toRolePermissionVo(row) for row in rows]) + async def _assertManagePermission(self, CurrentUserId: int) -> None: """校验当前用户是否具备管理能力。""" context = await self._getCurrentUserContext(CurrentUserId) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py index 4dc8910..c8dc519 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py @@ -21,8 +21,13 @@ class RbacServiceImpl(IRbacService): _MINIMAL_VISIBLE_ROUTE_PREFIXES: tuple[str, ...] = ( "/home", "/chat-with-llm", + "/contract-template", + "/cross-checking", "/files", "/documents", + "/rules", + "/rule-groups", + "/rules-files", "/settings", "/entry-modules", "/role-permissions", @@ -108,7 +113,7 @@ class RbacServiceImpl(IRbacService): "route_name": "rule-management", "component": "rules", "parent_id": None, - "route_title": "评查规则库", + "route_title": "规则管理", "icon": "ri-book-3-line", "sort_order": 4, "is_hidden": False, @@ -165,7 +170,7 @@ class RbacServiceImpl(IRbacService): "route_name": "contract-template", "component": "contract-template", "parent_id": None, - "route_title": "合同模板", + "route_title": "合同管理", "icon": "ri-file-search-line", "sort_order": 5, "is_hidden": False, @@ -178,7 +183,7 @@ class RbacServiceImpl(IRbacService): "route_name": "contract-search-ai", "component": "contract-template.search", "parent_id": 1010, - "route_title": "智能搜索", + "route_title": "模板搜索", "icon": "ri-search-line", "sort_order": 1, "is_hidden": False, @@ -192,7 +197,7 @@ class RbacServiceImpl(IRbacService): "route_name": "contract-list", "component": "contract-template.list", "parent_id": 1010, - "route_title": "合同列表", + "route_title": "模板列表", "icon": "ri-folder-line", "sort_order": 2, "is_hidden": False, @@ -243,6 +248,20 @@ class RbacServiceImpl(IRbacService): "meta": {"group": "settings"}, "children": None, }, + { + "id": 1016, + "route_path": "/document-types", + "route_name": "document-types", + "component": "document-types", + "parent_id": 1013, + "route_title": "文档类型管理", + "icon": "ri-file-list-3-line", + "sort_order": 3, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": None, + }, ], }, { @@ -353,7 +372,7 @@ class RbacServiceImpl(IRbacService): "route_name": "rule-management", "component": "rules", "parent_id": None, - "route_title": "评查规则库", + "route_title": "规则管理", "icon": "ri-book-3-line", "sort_order": 4, "is_hidden": False, @@ -410,7 +429,7 @@ class RbacServiceImpl(IRbacService): "route_name": "contract-template", "component": "contract-template", "parent_id": None, - "route_title": "合同模板", + "route_title": "合同管理", "icon": "ri-file-search-line", "sort_order": 5, "is_hidden": False, @@ -423,7 +442,7 @@ class RbacServiceImpl(IRbacService): "route_name": "contract-search-ai", "component": "contract-template.search", "parent_id": 2009, - "route_title": "智能搜索", + "route_title": "模板搜索", "icon": "ri-search-line", "sort_order": 1, "is_hidden": False, @@ -437,7 +456,7 @@ class RbacServiceImpl(IRbacService): "route_name": "contract-list", "component": "contract-template.list", "parent_id": 2009, - "route_title": "合同列表", + "route_title": "模板列表", "icon": "ri-folder-line", "sort_order": 2, "is_hidden": False, @@ -544,9 +563,9 @@ class RbacServiceImpl(IRbacService): "/entry-modules": ["entry_module:"], "/role-permissions": ["rbac:"], "/document-types": ["doc_type:"], - "/rules": ["rules:"], - "/rule-groups": ["rules:"], - "/rules/list": ["rules:"], + "/rules": ["rules:", "evaluation_point:"], + "/rule-groups": ["evaluation_group:", "rules:"], + "/rules/list": ["rules:", "evaluation_point:"], "/rules-files": ["rules:"], } diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py new file mode 100644 index 0000000..fdd9b29 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleConfigServiceImpl.py @@ -0,0 +1,220 @@ +"""规则配置页聚合服务实现。""" + +from __future__ import annotations + +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 sqlalchemy import text + +from fastapi_modules.fastapi_leaudit.domian.vo.ruleConfigVo import RuleConfigPackVO +from fastapi_modules.fastapi_leaudit.services import IOssService +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl +from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService + + +class RuleConfigServiceImpl(IRuleConfigService): + """规则配置页聚合服务实现。""" + + def __init__(self, OssService: IOssService | None = None) -> None: + self.OssService = OssService or OssServiceImpl() + self.RuleService = RuleServiceImpl(self.OssService) + + async def ListPacks(self) -> list[RuleConfigPackVO]: + """列出规则配置页所需的全部 pack。""" + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + """ + SELECT + child.id AS group_id, + child.name AS subtype, + COALESCE(child.document_type_id, root.document_type_id) AS document_type_id, + dt.name AS document_type_name, + root.id AS root_group_id, + COALESCE(root.name, child.name) AS main_type, + em.name AS entry_module_name + FROM leaudit_evaluation_point_groups child + LEFT JOIN leaudit_evaluation_point_groups root + ON root.id = child.pid + AND root.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt + ON dt.id = COALESCE(child.document_type_id, root.document_type_id) + LEFT JOIN leaudit_entry_modules em + ON em.id = COALESCE(child.entry_module_id, root.entry_module_id, dt.entry_module_id) + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + ORDER BY COALESCE(root.sort_order, 0) ASC, root.id ASC, child.sort_order ASC, child.id ASC + """ + ) + ) + ).mappings().all() + + rule_set_map = await self._load_rule_set_meta_map() + return [await self._build_pack_vo(row, rule_set_map) for row in rows] + + async def GetPack(self, PackId: int) -> RuleConfigPackVO: + """获取单个规则配置 pack。""" + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT + child.id AS group_id, + child.name AS subtype, + COALESCE(child.document_type_id, root.document_type_id) AS document_type_id, + dt.name AS document_type_name, + root.id AS root_group_id, + COALESCE(root.name, child.name) AS main_type, + em.name AS entry_module_name + FROM leaudit_evaluation_point_groups child + LEFT JOIN leaudit_evaluation_point_groups root + ON root.id = child.pid + AND root.deleted_at IS NULL + LEFT JOIN leaudit_document_types dt + ON dt.id = COALESCE(child.document_type_id, root.document_type_id) + LEFT JOIN leaudit_entry_modules em + ON em.id = COALESCE(child.entry_module_id, root.entry_module_id, dt.entry_module_id) + WHERE child.id = :group_id + AND child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + LIMIT 1 + """ + ), + {"group_id": PackId}, + ) + ).mappings().first() + + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则配置 pack 不存在") + + rule_set_map = await self._load_rule_set_meta_map() + return await self._build_pack_vo(row, rule_set_map) + + async def _build_pack_vo(self, row, rule_set_map: dict[int, dict[str, object]]) -> RuleConfigPackVO: + """构建单个 pack 聚合对象。""" + group_id = int(row["group_id"]) + binding = await self._load_effective_binding(group_id) + + document_type = str(row["document_type_name"] or "").strip() + main_type = str(row["main_type"] or "").strip() + subtype = str(row["subtype"] or "").strip() or "通用" + module_type = str(row["entry_module_name"] or "").strip() or (f"{document_type}评查" if document_type else "规则配置") + + source_status = "empty" + yaml_text = "" + rule_set_id: int | None = None + rule_type: str | None = None + rule_name: str | None = None + current_version_id: int | None = None + fallback_version_id: int | None = None + resolved_version_id: int | None = None + has_usable_version = False + usable_rule_count = 0 + binding_id: int | None = None + + if binding: + binding_id = int(binding["id"]) + rule_set_id = int(binding["rule_set_id"]) + rule_set_meta = rule_set_map.get(rule_set_id, {}) + rule_type = str(rule_set_meta.get("rule_type") or "") or None + rule_name = str(rule_set_meta.get("rule_name") or "") or None + current_version_id = self._to_int(rule_set_meta.get("current_version_id")) + fallback_version_id = self._to_int(rule_set_meta.get("fallback_version_id")) + resolved_version_id = current_version_id or fallback_version_id + has_usable_version = bool(rule_set_meta.get("has_usable_version")) + usable_rule_count = int(rule_set_meta.get("usable_rule_count") or 0) + if resolved_version_id is not None: + yaml_text = await self._load_yaml_text_by_version_id(resolved_version_id) + source_status = "ready" if yaml_text.strip() else "missing" + + return RuleConfigPackVO( + packId=group_id, + groupId=group_id, + rootGroupId=self._to_int(row.get("root_group_id")), + bindingId=binding_id, + ruleSetId=rule_set_id, + ruleType=rule_type, + ruleName=rule_name, + currentVersionId=current_version_id, + fallbackVersionId=fallback_version_id, + resolvedVersionId=resolved_version_id, + hasUsableVersion=has_usable_version, + usableRuleCount=usable_rule_count, + documentTypeId=self._to_int(row.get("document_type_id")), + documentType=document_type, + moduleType=module_type, + mainType=main_type or document_type, + subtype=subtype, + yamlText=yaml_text, + sourceStatus=source_status, + ) + + async def _load_effective_binding(self, group_id: int): + """读取当前二级分组实际生效的规则集绑定。""" + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT id, rule_set_id + FROM leaudit_rule_group_bindings + WHERE group_id = :group_id + AND deleted_at IS NULL + AND is_active = TRUE + ORDER BY priority DESC, id ASC + LIMIT 1 + """ + ), + {"group_id": group_id}, + ) + ).mappings().first() + return row + + async def _load_rule_set_meta_map(self) -> dict[int, dict[str, object]]: + """批量读取规则集元数据。""" + items = await self.RuleService.ListSets() + return { + item.id: { + "rule_type": item.ruleType, + "rule_name": item.ruleName, + "current_version_id": item.currentVersionId, + "fallback_version_id": item.fallbackVersionId, + "has_usable_version": item.hasUsableVersion, + "usable_rule_count": item.usableRuleCount, + } + for item in items + } + + async def _load_yaml_text_by_version_id(self, version_id: int) -> str: + """按版本ID读取 YAML 正文。""" + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT oss_url + FROM leaudit_rule_versions + WHERE id = :version_id + LIMIT 1 + """ + ), + {"version_id": version_id}, + ) + ).mappings().first() + + if not row or not row["oss_url"]: + return "" + + try: + return (await self.OssService.DownloadBytes(row["oss_url"])).decode("utf-8") + except Exception: + return "" + + def _to_int(self, value) -> int | None: + if value is None: + return None + return int(value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py new file mode 100644 index 0000000..de53e17 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleGroupSupport.py @@ -0,0 +1,617 @@ +"""Rule-group schema, bootstrap, and sync helpers.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import bindparam, text + + +async def ensure_rule_group_schema(session) -> None: + """Create the new rule-group tables when they do not exist yet.""" + statements = [ + """ + CREATE TABLE IF NOT EXISTS leaudit_evaluation_point_groups ( + id BIGSERIAL PRIMARY KEY, + pid BIGINT NOT NULL DEFAULT 0, + code VARCHAR(120) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT NULL, + document_type_id BIGINT NULL REFERENCES leaudit_document_types(id), + entry_module_id BIGINT NULL REFERENCES leaudit_entry_modules(id), + sort_order INTEGER NOT NULL DEFAULT 0, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS leaudit_rule_group_bindings ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES leaudit_evaluation_point_groups(id), + rule_set_id BIGINT NOT NULL REFERENCES leaudit_rule_sets(id), + rule_type_binding_id BIGINT NULL REFERENCES leaudit_rule_type_bindings(id), + priority INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + """, + "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_pid ON leaudit_evaluation_point_groups(pid)", + "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_doc_type ON leaudit_evaluation_point_groups(document_type_id)", + "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_entry_module ON leaudit_evaluation_point_groups(entry_module_id)", + "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_group_id ON leaudit_rule_group_bindings(group_id)", + "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_rule_set_id ON leaudit_rule_group_bindings(rule_set_id)", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_ep_groups_code_active ON leaudit_evaluation_point_groups (LOWER(code)) WHERE deleted_at IS NULL", + "DROP INDEX IF EXISTS uq_leaudit_ep_groups_doc_type_active", + "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_rule_group_bindings_active ON leaudit_rule_group_bindings (group_id, rule_set_id) WHERE deleted_at IS NULL", + ] + for statement in statements: + await session.execute(text(statement)) + + +async def bootstrap_rule_groups(session) -> None: + """Seed doc-type roots and default child groups from current doc-type bindings.""" + await ensure_rule_group_schema(session) + + # Once the system has entered "business root" mode, stop recreating the + # historical "doc type as top root" structure on every request. + business_root_exists = bool( + ( + await session.execute( + text( + """ + SELECT 1 + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id IS NULL + LIMIT 1 + """ + ) + ) + ).scalar_one_or_none() + ) + if business_root_exists: + return + + rows = ( + await session.execute( + text( + """ + SELECT + dt.id, + dt.code, + dt.name, + dt.description, + dt.entry_module_id, + dt.sort_order, + dt.is_enabled, + em.name AS entry_module_name, + COALESCE( + json_agg( + json_build_object( + 'id', b.id, + 'rule_set_id', b.rule_set_id, + 'priority', b.priority, + 'is_active', b.is_active, + 'note', b.note + ) + ORDER BY b.priority DESC, b.id ASC + ) FILTER (WHERE b.id IS NOT NULL AND b.deleted_at IS NULL), + '[]'::json + ) AS bindings + FROM leaudit_document_types dt + LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id + LEFT JOIN leaudit_rule_type_bindings b ON b.doc_type_id = dt.id AND b.deleted_at IS NULL + WHERE dt.deleted_at IS NULL + GROUP BY dt.id, dt.code, dt.name, dt.description, dt.entry_module_id, dt.sort_order, dt.is_enabled, em.name + ORDER BY dt.sort_order ASC, dt.id ASC + """ + ) + ) + ).mappings().all() + + for row in rows: + top_group_id = await ensure_top_group(session, row) + child_group_id = await ensure_default_child_group(session, row, top_group_id) + existing_binding_count = int( + ( + await session.execute( + text( + "SELECT COUNT(*) FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND deleted_at IS NULL" + ), + {"group_id": child_group_id}, + ) + ).scalar_one() + ) + bindings = list(row.get("bindings") or []) + if existing_binding_count == 0 and bindings: + await _replace_group_bindings(session, child_group_id, int(row["id"]), bindings) + + +async def ensure_group_for_doc_type(session, doc_type_id: int) -> dict[str, int]: + """Ensure the tree nodes for one doc type exist and return their ids.""" + row = ( + await session.execute( + text( + """ + SELECT dt.id, dt.code, dt.name, dt.description, dt.entry_module_id, dt.sort_order, dt.is_enabled, em.name AS entry_module_name + FROM leaudit_document_types dt + LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id + WHERE dt.deleted_at IS NULL AND dt.id = :doc_type_id + LIMIT 1 + """ + ), + {"doc_type_id": doc_type_id}, + ) + ).mappings().first() + if not row: + raise ValueError("文档类型不存在") + + business_root_exists = bool( + ( + await session.execute( + text( + """ + SELECT 1 + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id IS NULL + LIMIT 1 + """ + ) + ) + ).scalar_one_or_none() + ) + if business_root_exists: + target_root_code = "root.contract" if int(row.get("entry_module_id") or 0) == 1 else "root.casefile" if int(row.get("entry_module_id") or 0) == 2 else None + if target_root_code: + root_row = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND code = :code + LIMIT 1 + """ + ), + {"code": target_root_code}, + ) + ).mappings().first() + if root_row: + child_row = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND pid = :pid + AND document_type_id = :doc_type_id + ORDER BY sort_order ASC, id ASC + LIMIT 1 + """ + ), + {"pid": int(root_row["id"]), "doc_type_id": doc_type_id}, + ) + ).mappings().first() + if child_row: + return {"top_group_id": int(root_row["id"]), "child_group_id": int(child_row["id"])} + + top_group_id = await ensure_top_group(session, row) + child_group_id = await ensure_default_child_group(session, row, top_group_id) + return {"top_group_id": top_group_id, "child_group_id": child_group_id} + + +async def sync_group_bindings_from_doc_type(session, doc_type_id: int, rule_set_ids: list[int]) -> int: + """Mirror doc-type bindings into the child rule-group bindings.""" + await ensure_rule_group_schema(session) + ids = [int(item) for item in rule_set_ids if item] + ids = list(dict.fromkeys(ids)) + group_ids = await ensure_group_for_doc_type(session, doc_type_id) + child_group_id = group_ids["child_group_id"] + + legacy_bindings = ( + await session.execute( + text( + """ + SELECT id, rule_set_id, priority, is_active, note + FROM leaudit_rule_type_bindings + WHERE doc_type_id = :doc_type_id AND deleted_at IS NULL + ORDER BY priority DESC, id ASC + """ + ), + {"doc_type_id": doc_type_id}, + ) + ).mappings().all() + binding_map = {int(row["rule_set_id"]): row for row in legacy_bindings} + payload: list[dict[str, Any]] = [] + for index, rule_set_id in enumerate(ids): + current = binding_map.get(rule_set_id) + payload.append( + { + "id": current.get("id") if current else None, + "rule_set_id": rule_set_id, + "priority": int(current.get("priority") if current else 100 - index), + "is_active": bool(current.get("is_active") if current else True), + "note": current.get("note") if current else None, + } + ) + await _replace_group_bindings(session, child_group_id, doc_type_id, payload) + return child_group_id + + +async def sync_doc_type_bindings_from_group(session, group_id: int) -> int | None: + """Mirror one doc type's active child-group bindings into the runtime binding table.""" + await ensure_rule_group_schema(session) + group_row = ( + await session.execute( + text( + """ + SELECT g.id, g.document_type_id, dt.code AS document_type_code + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id + WHERE g.id = :group_id AND g.deleted_at IS NULL + LIMIT 1 + """ + ), + {"group_id": group_id}, + ) + ).mappings().first() + if not group_row or group_row.get("document_type_id") is None: + return None + + doc_type_id = int(group_row["document_type_id"]) + doc_type_code = str(group_row.get("document_type_code") or "") or None + + binding_rows = ( + await session.execute( + text( + """ + SELECT + rgb.id, + rgb.group_id, + rgb.rule_set_id, + rgb.priority, + rgb.is_active, + rgb.note, + g.sort_order AS group_sort_order + FROM leaudit_rule_group_bindings rgb + JOIN leaudit_evaluation_point_groups g ON g.id = rgb.group_id + WHERE g.document_type_id = :doc_type_id + AND g.deleted_at IS NULL + AND COALESCE(g.pid, 0) <> 0 + AND rgb.deleted_at IS NULL + AND rgb.is_active = TRUE + ORDER BY + COALESCE(g.sort_order, 0) ASC, + COALESCE(rgb.priority, 0) DESC, + rgb.id ASC + """ + ), + {"doc_type_id": doc_type_id}, + ) + ).mappings().all() + + deduped_rows: list[dict[str, Any]] = [] + seen_rule_set_ids: set[int] = set() + for row in binding_rows: + rule_set_id = int(row["rule_set_id"]) + if rule_set_id in seen_rule_set_ids: + continue + seen_rule_set_ids.add(rule_set_id) + deduped_rows.append(dict(row)) + + await session.execute( + text( + "UPDATE leaudit_rule_type_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE doc_type_id = :doc_type_id AND deleted_at IS NULL" + ), + {"doc_type_id": doc_type_id}, + ) + + for index, row in enumerate(deduped_rows): + await session.execute( + text( + """ + INSERT INTO leaudit_rule_type_bindings ( + doc_type_id, + doc_type_code, + rule_set_id, + binding_mode, + priority, + is_active, + note, + created_at, + updated_at, + region + ) VALUES ( + :doc_type_id, + :doc_type_code, + :rule_set_id, + 'explicit', + :priority, + TRUE, + :note, + NOW(), + NOW(), + 'default' + ) + RETURNING id + """ + ), + { + "doc_type_id": doc_type_id, + "doc_type_code": doc_type_code, + "rule_set_id": int(row["rule_set_id"]), + "priority": max(0, 1000 - index), + "note": row.get("note"), + }, + ) + return doc_type_id + + +async def ensure_top_group(session, doc_type_row) -> int: + """Create or reuse the top-level root group for one doc type.""" + doc_type_id = int(doc_type_row["id"]) + top_code = str(doc_type_row["code"]) + top_name = str(doc_type_row["name"]) + top_sort = int(doc_type_row.get("sort_order") or 0) + top = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id = :doc_type_id + LIMIT 1 + """ + ), + {"doc_type_id": doc_type_id}, + ) + ).mappings().first() + if top: + await session.execute( + text( + """ + UPDATE leaudit_evaluation_point_groups + SET code = :code, + name = :name, + description = :description, + sort_order = :sort_order, + is_enabled = :is_enabled, + updated_at = NOW() + WHERE id = :group_id + """ + ), + { + "group_id": int(top["id"]), + "code": top_code, + "name": top_name, + "description": doc_type_row.get("description"), + "sort_order": top_sort, + "is_enabled": bool(doc_type_row.get("is_enabled", True)), + }, + ) + return int(top["id"]) + + legacy = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND document_type_id = :doc_type_id + AND LOWER(code) = LOWER(:code) + ORDER BY CASE WHEN COALESCE(pid, 0) = 0 THEN 0 ELSE 1 END, id ASC + LIMIT 1 + """ + ), + {"doc_type_id": doc_type_id, "code": top_code}, + ) + ).mappings().first() + if legacy: + await session.execute( + text( + """ + UPDATE leaudit_evaluation_point_groups + SET pid = 0, + code = :code, + name = :name, + description = :description, + document_type_id = :doc_type_id, + sort_order = :sort_order, + is_enabled = :is_enabled, + updated_at = NOW() + WHERE id = :group_id + """ + ), + { + "group_id": int(legacy["id"]), + "doc_type_id": doc_type_id, + "code": top_code, + "name": top_name, + "description": doc_type_row.get("description"), + "sort_order": top_sort, + "is_enabled": bool(doc_type_row.get("is_enabled", True)), + }, + ) + return int(legacy["id"]) + + inserted = ( + await session.execute( + text( + """ + INSERT INTO leaudit_evaluation_point_groups ( + pid, code, name, description, document_type_id, sort_order, is_enabled, created_at, updated_at + ) VALUES ( + 0, :code, :name, :description, :doc_type_id, :sort_order, :is_enabled, NOW(), NOW() + ) + RETURNING id + """ + ), + { + "doc_type_id": doc_type_id, + "code": top_code, + "name": top_name, + "description": doc_type_row.get("description"), + "sort_order": top_sort, + "is_enabled": bool(doc_type_row.get("is_enabled", True)), + }, + ) + ).mappings().one() + return int(inserted["id"]) + + +async def ensure_default_child_group(session, doc_type_row, top_group_id: int) -> int: + """Ensure there is at least one default second-level group under a doc-type root.""" + doc_type_id = int(doc_type_row["id"]) + child = ( + await session.execute( + text( + """ + SELECT id, pid + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND pid = :pid + AND document_type_id = :doc_type_id + AND LOWER(code) = LOWER(:code) + LIMIT 1 + """ + ), + {"pid": top_group_id, "doc_type_id": doc_type_id, "code": f"{doc_type_row['code']}.default"}, + ) + ).mappings().first() + any_children = int( + ( + await session.execute( + text( + """ + SELECT COUNT(*) + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND pid = :pid + """ + ), + {"pid": top_group_id}, + ) + ).scalar_one() + ) + if child: + return int(child["id"]) + if any_children > 0: + adopted = ( + await session.execute( + text( + """ + SELECT id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND pid = :pid + AND document_type_id = :doc_type_id + ORDER BY sort_order ASC, id ASC + LIMIT 1 + """ + ), + {"pid": top_group_id, "doc_type_id": doc_type_id}, + ) + ).mappings().first() + if adopted: + return int(adopted["id"]) + + payload = { + "pid": top_group_id, + "code": f"{doc_type_row['code']}.default", + "name": "通用", + "description": f"{doc_type_row['name']}默认子类型", + "document_type_id": doc_type_id, + "sort_order": int(doc_type_row.get("sort_order") or 0), + "is_enabled": bool(doc_type_row.get("is_enabled", True)), + } + inserted = ( + await session.execute( + text( + """ + INSERT INTO leaudit_evaluation_point_groups ( + pid, code, name, description, document_type_id, sort_order, is_enabled, created_at, updated_at + ) VALUES ( + :pid, :code, :name, :description, :document_type_id, :sort_order, :is_enabled, NOW(), NOW() + ) + RETURNING id + """ + ), + payload, + ) + ).mappings().one() + default_child_id = int(inserted["id"]) + await _move_root_bindings_to_default_child(session, top_group_id, default_child_id) + return int(inserted["id"]) + + +async def _move_root_bindings_to_default_child(session, root_group_id: int, child_group_id: int) -> None: + await session.execute( + text( + """ + UPDATE leaudit_rule_group_bindings + SET group_id = :child_group_id, + updated_at = NOW() + WHERE group_id = :root_group_id + AND deleted_at IS NULL + """ + ), + {"root_group_id": root_group_id, "child_group_id": child_group_id}, + ) + + +async def _replace_group_bindings(session, child_group_id: int, doc_type_id: int, bindings: list[dict[str, Any]]) -> None: + await session.execute( + text( + "UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE group_id = :group_id AND deleted_at IS NULL" + ), + {"group_id": child_group_id}, + ) + for item in bindings: + await session.execute( + text( + """ + INSERT INTO leaudit_rule_group_bindings ( + group_id, + rule_set_id, + rule_type_binding_id, + priority, + is_active, + note, + created_at, + updated_at + ) VALUES ( + :group_id, + :rule_set_id, + :rule_type_binding_id, + :priority, + :is_active, + :note, + NOW(), + NOW() + ) + """ + ), + { + "group_id": child_group_id, + "rule_set_id": int(item["rule_set_id"]), + "rule_type_binding_id": int(item["id"]) if item.get("id") else None, + "priority": int(item.get("priority") or 0), + "is_active": bool(item.get("is_active", True)), + "note": item.get("note"), + }, + ) + await sync_doc_type_bindings_from_group(session, child_group_id) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py index 9e865bc..96881a8 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/ruleServiceImpl.py @@ -39,29 +39,85 @@ class RuleServiceImpl(IRuleService): text( """ SELECT - id, - rule_type, - rule_name, - domain_type, - current_version_id, - status - FROM leaudit_rule_sets - WHERE deleted_at IS NULL - ORDER BY id DESC + rs.id, + rs.rule_type, + rs.rule_name, + rs.domain_type, + rs.current_version_id, + current_rv.id AS usable_current_version_id, + fallback_rv.id AS fallback_version_id, + CASE + WHEN current_rv.id IS NOT NULL OR fallback_rv.id IS NOT NULL THEN TRUE + ELSE FALSE + END AS has_usable_version, + rs.status + FROM leaudit_rule_sets rs + LEFT JOIN leaudit_rule_versions current_rv + ON current_rv.id = rs.current_version_id + AND current_rv.status IN ('published', 'rollback') + LEFT JOIN LATERAL ( + SELECT rv.id + FROM leaudit_rule_versions rv + WHERE rv.rule_set_id = rs.id + AND rv.status IN ('published', 'rollback') + ORDER BY rv.version_seq DESC, rv.id DESC + LIMIT 1 + ) fallback_rv ON TRUE + WHERE rs.deleted_at IS NULL + ORDER BY rs.id DESC """ ) ) - return [ - RuleSetVO( - id=int(Row["id"]), - ruleType=Row["rule_type"], - ruleName=Row["rule_name"], - domainType=Row["domain_type"], - currentVersionId=Row["current_version_id"], - status=Row["status"], - ) - for Row in Result.mappings().all() - ] + rows = Result.mappings().all() + + usable_counts: dict[int, int] = {} + for row in rows: + usable_version_id = row["usable_current_version_id"] or row["fallback_version_id"] + if usable_version_id is not None and int(usable_version_id) not in usable_counts: + usable_counts[int(usable_version_id)] = await self._GetRuleCountByVersionId(int(usable_version_id)) + + return [ + RuleSetVO( + id=int(Row["id"]), + ruleType=Row["rule_type"], + ruleName=Row["rule_name"], + domainType=Row["domain_type"], + currentVersionId=Row["current_version_id"], + fallbackVersionId=Row["fallback_version_id"], + hasUsableVersion=bool(Row["has_usable_version"]), + usableRuleCount=usable_counts.get(int(Row["usable_current_version_id"] or Row["fallback_version_id"]), 0) + if (Row["usable_current_version_id"] or Row["fallback_version_id"]) is not None + else 0, + status=Row["status"], + ) + for Row in rows + ] + + async def _GetRuleCountByVersionId(self, VersionId: int) -> int: + """读取指定可用规则版本的规则数。""" + async with GetAsyncSession() as Session: + Result = await Session.execute( + text( + """ + SELECT oss_url + FROM leaudit_rule_versions + WHERE id = :version_id + LIMIT 1 + """ + ), + {"version_id": VersionId}, + ) + Row = Result.mappings().first() + + if not Row or not Row["oss_url"]: + return 0 + + try: + yaml_text = (await self.OssService.DownloadBytes(Row["oss_url"])).decode("utf-8") + validation = self.Validator.ValidateYaml(yaml_text) + return int(validation.ruleCount or 0) + except Exception: + return 0 async def GetVersions(self, RuleType: str) -> list[RuleVersionVO]: """获取规则集的所有版本。""" diff --git a/fastapi_modules/fastapi_leaudit/services/promptTemplateService.py b/fastapi_modules/fastapi_leaudit/services/promptTemplateService.py new file mode 100644 index 0000000..2625d3e --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/promptTemplateService.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.promptTemplateDto import PromptTemplateCreateDTO, PromptTemplateUpdateDTO +from fastapi_modules.fastapi_leaudit.domian.vo.promptTemplateVo import PromptTemplateListVO, PromptTemplateTypeListVO, PromptTemplateVO + + +class IPromptTemplateService(ABC): + @abstractmethod + async def ListTemplates(self, Search: str | None, TemplateTypes: list[str] | None, Status: int | None, Page: int, PageSize: int) -> PromptTemplateListVO: + ... + + @abstractmethod + async def GetTemplate(self, TemplateId: int) -> PromptTemplateVO: + ... + + @abstractmethod + async def CreateTemplate(self, Body: PromptTemplateCreateDTO) -> PromptTemplateVO: + ... + + @abstractmethod + async def UpdateTemplate(self, TemplateId: int, Body: PromptTemplateUpdateDTO) -> PromptTemplateVO: + ... + + @abstractmethod + async def DeleteTemplate(self, TemplateId: int) -> None: + ... + + @abstractmethod + async def GetTemplateTypes(self) -> PromptTemplateTypeListVO: + ... + + @abstractmethod + async def DuplicateTemplate(self, TemplateId: int, NewCode: str | None) -> PromptTemplateVO: + ... diff --git a/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py b/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py index 5c394aa..66419a2 100644 --- a/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py +++ b/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py @@ -3,12 +3,14 @@ from abc import ABC, abstractmethod from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import ( + RoleAccessSaveDTO, RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( + RoleAccessSaveVO, RoleListVO, RolePermissionsVO, RoleRoutesVO, @@ -94,6 +96,11 @@ class IRbacAdminService(ABC): """保存角色权限授权。""" ... + @abstractmethod + async def SaveRoleAccess(self, CurrentUserId: int, RoleId: int, Body: RoleAccessSaveDTO) -> RoleAccessSaveVO: + """原子保存角色菜单与接口权限。""" + ... + @abstractmethod async def GetRoutePermissions(self, CurrentUserId: int, RouteId: int) -> RoutePermissionsVO: """查询路由关联权限定义。""" diff --git a/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py b/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py new file mode 100644 index 0000000..ab5ac1b --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/ruleConfigService.py @@ -0,0 +1,19 @@ +"""规则配置页聚合服务接口。""" + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.vo.ruleConfigVo import RuleConfigPackVO + + +class IRuleConfigService(ABC): + """规则配置页聚合服务接口。""" + + @abstractmethod + async def ListPacks(self) -> list[RuleConfigPackVO]: + """列出规则配置页所需的全部 pack。""" + ... + + @abstractmethod + async def GetPack(self, PackId: int) -> RuleConfigPackVO: + """获取单个规则配置 pack。""" + ... diff --git a/rules/contract_loan/rules.yaml b/rules/contract_loan/rules.yaml index 0eacc65..296496e 100644 --- a/rules/contract_loan/rules.yaml +++ b/rules/contract_loan/rules.yaml @@ -236,8 +236,7 @@ rules: name: 利率不超过法定上限(LPR × 4 倍) risk: high score: 20 - depends_on: - - when: derived.LPR_4x != null + activate_if: derived.LPR_4x != null stages: - id: '1' check: required diff --git a/scripts/migrate_legacy_users.py b/scripts/migrate_legacy_users.py new file mode 100644 index 0000000..e5481a4 --- /dev/null +++ b/scripts/migrate_legacy_users.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Migrate legacy users from docauditai into leaudit_platform. + +Default mode is dry-run. Use --apply to write data. + +What gets migrated: +- sso_users +- user_role + +What gets reused: +- target roles already seeded in leaudit_platform + +Rules: +- preserve legacy user id when inserting into the new database +- map missing legacy roles to `common` +- normalize area with trim and alias mapping +- never overwrite target area with empty value +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import asyncpg + + +ROOT = Path(__file__).resolve().parents[1] +APP_TOML = ROOT / "app.toml" + +ALLOWED_ROLE_KEYS = {"provincial_admin", "admin", "common", "super_admin"} +DEFAULT_ROLE_KEY = "common" +AREA_ALIASES = { + "省": "省局", + "省厅": "省局", + "省公司": "省局", +} + + +@dataclass +class LegacyUser: + id: int + sub: str + username: str + nick_name: str + phone_number: str | None + email: str | None + ou_id: str + ou_name: str + status: int + is_leader: bool + created_at: Any + updated_at: Any + deleted_at: Any + password: str | None + try_count: int | None + try_login_time: Any + area: str | None + mq_person_uuid: str | None + mq_account_uuid: str | None + mq_synced_at: Any + tenant_name: str | None + dep_short_name: str | None + dep_name: str | None + + +def load_target_dsn() -> str: + try: + import tomllib + except ImportError: # pragma: no cover + import tomli as tomllib + + with APP_TOML.open("rb") as fh: + config = tomllib.load(fh) + db = config["DB"] + return ( + f"postgresql://{db['USER']}:{db['PASSWORD']}" + f"@{db['HOST']}:{db['PORT']}/{db['NAME']}" + ) + + +def build_legacy_dsn(args: argparse.Namespace) -> str: + return ( + f"postgresql://{args.legacy_user}:{args.legacy_password}" + f"@{args.legacy_host}:{args.legacy_port}/{args.legacy_db}" + ) + + +def normalize_area(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + return None + return AREA_ALIASES.get(normalized, normalized) + + +def choose_roles(role_keys: list[str]) -> list[str]: + cleaned: list[str] = [] + for role_key in role_keys: + if role_key in ALLOWED_ROLE_KEYS: + cleaned.append(role_key) + if not cleaned: + cleaned = [DEFAULT_ROLE_KEY] + return sorted(set(cleaned)) + + +async def fetch_legacy_users(conn: asyncpg.Connection) -> dict[int, LegacyUser]: + rows = await conn.fetch( + """ + SELECT id, sub, username, nick_name, phone_number, email, + ou_id, ou_name, status, is_leader, created_at, updated_at, + deleted_at, password, try_count, try_login_time, area, + mq_person_uuid, mq_account_uuid, mq_synced_at, + tenant_name, dep_short_name, dep_name + FROM sso_users + ORDER BY id + """ + ) + return { + row["id"]: LegacyUser(**dict(row)) + for row in rows + } + + +async def fetch_legacy_user_roles(conn: asyncpg.Connection) -> dict[int, list[str]]: + rows = await conn.fetch( + """ + SELECT ur.user_id, r.role_key + FROM user_role ur + JOIN roles r ON r.id = ur.role_id + ORDER BY ur.user_id, r.id + """ + ) + result: dict[int, list[str]] = defaultdict(list) + for row in rows: + result[row["user_id"]].append(row["role_key"]) + return result + + +async def fetch_target_roles(conn: asyncpg.Connection) -> dict[str, int]: + rows = await conn.fetch("SELECT id, role_key FROM roles ORDER BY id") + return {row["role_key"]: row["id"] for row in rows} + + +async def fetch_target_users(conn: asyncpg.Connection) -> tuple[dict[str, dict[str, Any]], dict[int, dict[str, Any]]]: + rows = await conn.fetch("SELECT id, sub, area FROM sso_users") + normalized = [dict(row) for row in rows] + by_sub = {row["sub"]: row for row in normalized} + by_id = {row["id"]: row for row in normalized} + return by_sub, by_id + + +async def ensure_target_ready(conn: asyncpg.Connection) -> None: + missing = [] + for table_name in ( + "sso_users", + "roles", + "user_role", + "permissions", + "role_permissions", + "sys_routes", + "role_route", + ): + exists = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1 + ) + """, + table_name, + ) + if not exists: + missing.append(table_name) + if missing: + raise RuntimeError(f"target database is missing required tables: {', '.join(missing)}") + + +async def upsert_user(conn: asyncpg.Connection, user: LegacyUser, existing_sub_row: dict[str, Any] | None) -> int: + area = normalize_area(user.area) + if existing_sub_row: + await conn.execute( + """ + UPDATE sso_users + SET username = $2, + nick_name = $3, + phone_number = $4, + email = $5, + ou_id = $6, + ou_name = $7, + status = $8, + is_leader = $9, + updated_at = $10, + deleted_at = $11, + password = $12, + try_count = $13, + try_login_time = $14, + area = COALESCE($15, area), + mq_person_uuid = $16, + mq_account_uuid = $17, + mq_synced_at = $18, + tenant_name = $19, + dep_short_name = $20, + dep_name = $21 + WHERE sub = $1 + """, + user.sub, + user.username, + user.nick_name, + user.phone_number, + user.email, + user.ou_id, + user.ou_name, + user.status, + user.is_leader, + user.updated_at, + user.deleted_at, + user.password, + user.try_count, + user.try_login_time, + area, + user.mq_person_uuid, + user.mq_account_uuid, + user.mq_synced_at, + user.tenant_name, + user.dep_short_name, + user.dep_name, + ) + return int(existing_sub_row["id"]) + + await conn.execute( + """ + INSERT INTO sso_users ( + id, sub, username, nick_name, phone_number, email, ou_id, ou_name, + status, is_leader, created_at, updated_at, deleted_at, password, + try_count, try_login_time, area, mq_person_uuid, mq_account_uuid, + mq_synced_at, tenant_name, dep_short_name, dep_name + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14, + $15, $16, $17, $18, $19, + $20, $21, $22, $23 + ) + """, + user.id, + user.sub, + user.username, + user.nick_name, + user.phone_number, + user.email, + user.ou_id, + user.ou_name, + user.status, + user.is_leader, + user.created_at, + user.updated_at, + user.deleted_at, + user.password, + user.try_count, + user.try_login_time, + area, + user.mq_person_uuid, + user.mq_account_uuid, + user.mq_synced_at, + user.tenant_name, + user.dep_short_name, + user.dep_name, + ) + return user.id + + +async def assign_roles( + conn: asyncpg.Connection, + target_user_id: int, + role_keys: list[str], + role_key_to_id: dict[str, int], +) -> None: + for role_key in role_keys: + role_id = role_key_to_id.get(role_key) + if role_id is None: + raise RuntimeError(f"target role not found: {role_key}") + await conn.execute( + """ + INSERT INTO user_role (user_id, role_id, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (user_id, role_id) DO NOTHING + """, + target_user_id, + role_id, + ) + + +async def sync_user_sequence(conn: asyncpg.Connection) -> None: + await conn.execute( + """ + SELECT setval( + pg_get_serial_sequence('sso_users', 'id'), + COALESCE((SELECT MAX(id) FROM sso_users), 1), + true + ) + """ + ) + + +async def main_async(args: argparse.Namespace) -> int: + legacy_conn = await asyncpg.connect(build_legacy_dsn(args)) + target_conn = await asyncpg.connect(load_target_dsn()) + try: + await ensure_target_ready(target_conn) + + legacy_users = await fetch_legacy_users(legacy_conn) + legacy_user_roles = await fetch_legacy_user_roles(legacy_conn) + target_role_map = await fetch_target_roles(target_conn) + target_by_sub, target_by_id = await fetch_target_users(target_conn) + + missing_target_roles = sorted(set(ALLOWED_ROLE_KEYS) - set(target_role_map)) + if missing_target_roles: + raise RuntimeError( + "target database is missing seeded roles: " + ", ".join(missing_target_roles) + ) + + summary = Counter() + role_summary = Counter() + id_conflicts: list[tuple[int, str, int, str]] = [] + + async def process_all() -> None: + for user_id, user in legacy_users.items(): + source_roles = legacy_user_roles.get(user_id, []) + desired_roles = choose_roles(source_roles) + role_summary.update(desired_roles) + if not source_roles: + summary["default_common_role"] += 1 + + existing_sub_row = target_by_sub.get(user.sub) + existing_id_row = target_by_id.get(user.id) + if existing_id_row and (existing_sub_row is None or existing_sub_row["sub"] != user.sub): + id_conflicts.append((user.id, user.sub, int(existing_id_row["id"]), existing_id_row["sub"])) + summary["id_conflict"] += 1 + continue + + if existing_sub_row: + summary["update_user"] += 1 + else: + summary["insert_user"] += 1 + + if args.apply: + target_user_id = await upsert_user(target_conn, user, existing_sub_row) + await assign_roles(target_conn, target_user_id, desired_roles, target_role_map) + target_by_sub[user.sub] = {"id": target_user_id, "sub": user.sub, "area": normalize_area(user.area)} + target_by_id[user.id] = {"id": target_user_id, "sub": user.sub, "area": normalize_area(user.area)} + + if args.apply: + async with target_conn.transaction(): + await process_all() + await sync_user_sequence(target_conn) + else: + await process_all() + + print("=== Migration Summary ===") + print(f"mode: {'APPLY' if args.apply else 'DRY_RUN'}") + print(f"legacy_users_total: {len(legacy_users)}") + for key in sorted(summary): + print(f"{key}: {summary[key]}") + print("role_assignment_plan:") + for role_key, count in sorted(role_summary.items()): + print(f" {role_key}: {count}") + print(f"id_conflicts: {len(id_conflicts)}") + if id_conflicts: + print("sample_id_conflicts:") + for conflict in id_conflicts[:20]: + print(f" legacy_id={conflict[0]} legacy_sub={conflict[1]} target_id={conflict[2]} target_sub={conflict[3]}") + + if not args.apply: + print("dry-run complete; rerun with --apply to write data") + return 0 + finally: + await legacy_conn.close() + await target_conn.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Migrate legacy users into leaudit_platform") + parser.add_argument("--legacy-host", default=os.getenv("LEGACY_DB_HOST", "172.16.0.81")) + parser.add_argument("--legacy-port", type=int, default=int(os.getenv("LEGACY_DB_PORT", "54302"))) + parser.add_argument("--legacy-db", default=os.getenv("LEGACY_DB_NAME", "docauditai")) + parser.add_argument("--legacy-user", default=os.getenv("LEGACY_DB_USER", "docauditai_admin")) + parser.add_argument("--legacy-password", default=os.getenv("LEGACY_DB_PASSWORD", "zhfw*123*")) + parser.add_argument("--apply", action="store_true", help="write data into target database") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + return asyncio.run(main_async(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/migrate_rule_groups_to_business_roots.sql b/scripts/migrate_rule_groups_to_business_roots.sql new file mode 100644 index 0000000..66c7d67 --- /dev/null +++ b/scripts/migrate_rule_groups_to_business_roots.sql @@ -0,0 +1,462 @@ +-- 评查点分组正式迁移脚本(业务大类根版本) +-- 目标: +-- 一级分组 = 业务大类(合同 / 行政卷宗 / 后续新增业务) +-- 二级分组 = 具体业务类型 +-- 规则集 = 挂在二级分组下 +-- 入口模块 = 绑定一级分组 +-- +-- 重要说明: +-- 1. 本脚本面向当前真实库结构: +-- - 已存在旧一级根(一级=具体文档类型) +-- - 已存在新一级业务根 root.contract / root.casefile +-- - 已存在默认二级分组 *.default / 通用 +-- 2. 执行前必须先跑: +-- scripts/precheck_rule_group_migration.sql +-- 3. 必须先备份: +-- leaudit_evaluation_point_groups +-- leaudit_rule_group_bindings +-- leaudit_rule_type_bindings +-- leaudit_document_types +-- 4. 本脚本不会立刻删除旧一级根,只做结构补齐 + 绑定迁移 + 汇总重建。 + +BEGIN; + +-- ========================================================= +-- 0. 补齐结构:一级分组入口模块字段 +-- ========================================================= +ALTER TABLE leaudit_evaluation_point_groups +ADD COLUMN IF NOT EXISTS entry_module_id BIGINT NULL REFERENCES leaudit_entry_modules(id); + +CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_entry_module +ON leaudit_evaluation_point_groups(entry_module_id); + +-- ========================================================= +-- 1. 保证一级业务大类根存在,并统一编码 +-- 当前统一使用: +-- root.contract -> 合同 +-- root.casefile -> 行政卷宗 +-- ========================================================= +WITH root_candidates AS ( + SELECT + 'root.contract'::varchar AS code, + '合同'::varchar AS name, + '合同业务大类'::text AS description, + 1::bigint AS entry_module_id, + 10::int AS sort_order + UNION ALL + SELECT + 'root.casefile'::varchar, + '行政卷宗'::varchar, + '行政卷宗业务大类'::text, + 2::bigint, + 20::int +), +upsert_roots AS ( + INSERT INTO leaudit_evaluation_point_groups ( + pid, + code, + name, + description, + document_type_id, + entry_module_id, + sort_order, + is_enabled, + created_at, + updated_at + ) + SELECT + 0, + rc.code, + rc.name, + rc.description, + NULL, + rc.entry_module_id, + rc.sort_order, + TRUE, + NOW(), + NOW() + FROM root_candidates rc + WHERE NOT EXISTS ( + SELECT 1 + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) = 0 + AND LOWER(g.code) = LOWER(rc.code) + ) + RETURNING id, code +) +SELECT COUNT(*) AS inserted_root_count FROM upsert_roots; + +UPDATE leaudit_evaluation_point_groups +SET + name = '合同', + description = '合同业务大类', + entry_module_id = 1, + sort_order = 10, + updated_at = NOW() +WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND LOWER(code) = LOWER('root.contract'); + +UPDATE leaudit_evaluation_point_groups +SET + name = '行政卷宗', + description = '行政卷宗业务大类', + entry_module_id = 2, + sort_order = 20, + updated_at = NOW() +WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND LOWER(code) = LOWER('root.casefile'); + +-- ========================================================= +-- 2. 优先把旧一级根直接下沉为目标二级分组 +-- 这样可复用现有 code,避免唯一索引冲突 +-- ========================================================= +WITH target_root_map AS ( + SELECT + dt.id AS document_type_id, + CASE + WHEN dt.entry_module_id = 1 THEN ( + SELECT id FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = 'root.contract' + LIMIT 1 + ) + WHEN dt.entry_module_id = 2 THEN ( + SELECT id FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = 'root.casefile' + LIMIT 1 + ) + ELSE NULL + END AS new_root_id + FROM leaudit_document_types dt + WHERE dt.deleted_at IS NULL +), +reparented_old_roots AS ( + UPDATE leaudit_evaluation_point_groups g + SET + pid = trm.new_root_id, + entry_module_id = NULL, + updated_at = NOW() + FROM target_root_map trm + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) = 0 + AND g.document_type_id = trm.document_type_id + AND trm.new_root_id IS NOT NULL + RETURNING g.id, g.document_type_id +) +SELECT COUNT(*) AS reparented_old_root_count FROM reparented_old_roots; + +-- ========================================================= +-- 3. 如果某个文档类型没有旧一级根,再补建新的目标二级分组 +-- ========================================================= +WITH mapped_doc_types AS ( + SELECT + dt.id AS document_type_id, + dt.code AS document_type_code, + dt.name AS document_type_name, + dt.description, + dt.entry_module_id, + dt.sort_order, + dt.is_enabled, + CASE + WHEN dt.entry_module_id = 1 THEN ( + SELECT id FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = 'root.contract' + LIMIT 1 + ) + WHEN dt.entry_module_id = 2 THEN ( + SELECT id FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = 'root.casefile' + LIMIT 1 + ) + ELSE NULL + END AS root_group_id + FROM leaudit_document_types dt + WHERE dt.deleted_at IS NULL +), +insert_children AS ( + INSERT INTO leaudit_evaluation_point_groups ( + pid, + code, + name, + description, + document_type_id, + entry_module_id, + sort_order, + is_enabled, + created_at, + updated_at + ) + SELECT + m.root_group_id, + m.document_type_code, + m.document_type_name, + COALESCE(m.description, m.document_type_name || '二级分组'), + m.document_type_id, + NULL, + COALESCE(m.sort_order, 0), + COALESCE(m.is_enabled, TRUE), + NOW(), + NOW() + FROM mapped_doc_types m + WHERE m.root_group_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_evaluation_point_groups g + JOIN leaudit_evaluation_point_groups root + ON root.id = g.pid + AND root.deleted_at IS NULL + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) <> 0 + AND g.document_type_id = m.document_type_id + AND root.document_type_id IS NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND LOWER(g.code) = LOWER(m.document_type_code) + ) + RETURNING id, document_type_id +) +SELECT COUNT(*) AS inserted_child_count FROM insert_children; + +-- ========================================================= +-- 4. 将旧一级根上的规则绑定迁到目标二级分组 +-- ========================================================= +WITH old_roots AS ( + SELECT id AS old_group_id, document_type_id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id IS NOT NULL +), +target_children AS ( + SELECT child.id AS new_group_id, child.document_type_id + FROM leaudit_evaluation_point_groups child + JOIN leaudit_evaluation_point_groups root + ON root.id = child.pid + AND root.deleted_at IS NULL + AND root.document_type_id IS NULL + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND child.document_type_id IS NOT NULL +), +move_pairs AS ( + SELECT old_roots.old_group_id, target_children.new_group_id + FROM old_roots + JOIN target_children + ON target_children.document_type_id = old_roots.document_type_id +), +updated_bindings AS ( + UPDATE leaudit_rule_group_bindings rgb + SET group_id = mp.new_group_id, + updated_at = NOW() + FROM move_pairs mp + WHERE rgb.group_id = mp.old_group_id + AND rgb.deleted_at IS NULL + RETURNING rgb.id +) +SELECT COUNT(*) AS moved_old_root_binding_count FROM updated_bindings; + +-- ========================================================= +-- 5. 将旧默认子级上的规则绑定迁到目标二级分组 +-- 注意:只迁“父级仍是旧一级根”的默认子级 +-- ========================================================= +WITH old_default_children AS ( + SELECT + child.id AS old_group_id, + child.document_type_id + FROM leaudit_evaluation_point_groups child + JOIN leaudit_evaluation_point_groups parent + ON parent.id = child.pid + AND parent.deleted_at IS NULL + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND parent.document_type_id IS NOT NULL + AND ( + child.name = '通用' + OR child.code LIKE '%.default' + ) +), +target_children AS ( + SELECT child.id AS new_group_id, child.document_type_id + FROM leaudit_evaluation_point_groups child + JOIN leaudit_evaluation_point_groups root + ON root.id = child.pid + AND root.deleted_at IS NULL + AND root.document_type_id IS NULL + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND child.document_type_id IS NOT NULL +), +move_pairs AS ( + SELECT old_default_children.old_group_id, target_children.new_group_id + FROM old_default_children + JOIN target_children + ON target_children.document_type_id = old_default_children.document_type_id +), +updated_bindings AS ( + UPDATE leaudit_rule_group_bindings rgb + SET group_id = mp.new_group_id, + updated_at = NOW() + FROM move_pairs mp + WHERE rgb.group_id = mp.old_group_id + AND rgb.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_rule_group_bindings existing + WHERE existing.deleted_at IS NULL + AND existing.group_id = mp.new_group_id + AND existing.rule_set_id = rgb.rule_set_id + ) + RETURNING rgb.id +), +soft_deleted_duplicates AS ( + UPDATE leaudit_rule_group_bindings rgb + SET deleted_at = NOW(), + updated_at = NOW(), + note = COALESCE(rgb.note, '') || ' [soft-deleted after business-root migration: duplicate with target child binding]' + FROM move_pairs mp + WHERE rgb.group_id = mp.old_group_id + AND rgb.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM leaudit_rule_group_bindings existing + WHERE existing.deleted_at IS NULL + AND existing.group_id = mp.new_group_id + AND existing.rule_set_id = rgb.rule_set_id + ) + RETURNING rgb.id +) +SELECT + (SELECT COUNT(*) FROM updated_bindings) AS moved_default_child_binding_count, + (SELECT COUNT(*) FROM soft_deleted_duplicates) AS soft_deleted_duplicate_default_binding_count; + +-- ========================================================= +-- 6. 去重修复:避免迁移后同一二级分组重复绑定同一规则集 +-- 保留 priority 更高、id 更小的一条 +-- ========================================================= +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY group_id, rule_set_id + ORDER BY priority DESC, id ASC + ) AS rn + FROM leaudit_rule_group_bindings + WHERE deleted_at IS NULL +), +soft_deleted AS ( + UPDATE leaudit_rule_group_bindings rgb + SET deleted_at = NOW(), + updated_at = NOW() + FROM ranked r + WHERE rgb.id = r.id + AND r.rn > 1 + RETURNING rgb.id +) +SELECT COUNT(*) AS deduped_binding_count FROM soft_deleted; + +-- ========================================================= +-- 7. 清理迁移后已空置的旧默认子级 +-- 条件: +-- - 自身无有效规则绑定 +-- - 自身是“通用 / *.default” +-- - 父级已是具体业务类型节点 +-- ========================================================= +WITH empty_default_children AS ( + SELECT child.id + FROM leaudit_evaluation_point_groups child + JOIN leaudit_evaluation_point_groups parent + ON parent.id = child.pid + AND parent.deleted_at IS NULL + LEFT JOIN leaudit_rule_group_bindings rgb + ON rgb.group_id = child.id + AND rgb.deleted_at IS NULL + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND parent.document_type_id IS NOT NULL + AND ( + child.name = '通用' + OR child.code LIKE '%.default' + ) + GROUP BY child.id + HAVING COUNT(rgb.id) = 0 +), +soft_deleted_groups AS ( + UPDATE leaudit_evaluation_point_groups g + SET deleted_at = NOW(), + updated_at = NOW() + FROM empty_default_children edc + WHERE g.id = edc.id + RETURNING g.id +) +SELECT COUNT(*) AS soft_deleted_empty_default_group_count FROM soft_deleted_groups; + +-- ========================================================= +-- 8. 重建文档类型汇总绑定表 +-- ========================================================= +UPDATE leaudit_rule_type_bindings +SET deleted_at = NOW(), + updated_at = NOW() +WHERE deleted_at IS NULL; + +INSERT INTO leaudit_rule_type_bindings ( + doc_type_id, + doc_type_code, + rule_set_id, + binding_mode, + priority, + is_active, + note, + created_at, + updated_at, + region +) +SELECT DISTINCT ON (child.document_type_id, rgb.rule_set_id) + child.document_type_id, + dt.code, + rgb.rule_set_id, + 'explicit', + 100, + TRUE, + 're-built from second-level group bindings after business-root migration', + NOW(), + NOW(), + 'default' +FROM leaudit_rule_group_bindings rgb +JOIN leaudit_evaluation_point_groups child + ON child.id = rgb.group_id + AND child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 +JOIN leaudit_evaluation_point_groups root + ON root.id = child.pid + AND root.deleted_at IS NULL + AND root.document_type_id IS NULL +JOIN leaudit_document_types dt + ON dt.id = child.document_type_id +WHERE rgb.deleted_at IS NULL + AND rgb.is_active = TRUE +ORDER BY child.document_type_id, rgb.rule_set_id, rgb.priority DESC, rgb.id ASC; + +-- ========================================================= +-- 9. 迁移后输出检查摘要 +-- ========================================================= +SELECT + root.code AS root_code, + root.name AS root_name, + root.entry_module_id, + COUNT(child.id) AS child_count +FROM leaudit_evaluation_point_groups root +LEFT JOIN leaudit_evaluation_point_groups child + ON child.pid = root.id + AND child.deleted_at IS NULL +WHERE root.deleted_at IS NULL + AND COALESCE(root.pid, 0) = 0 + AND root.document_type_id IS NULL +GROUP BY root.id, root.code, root.name, root.entry_module_id +ORDER BY root.sort_order, root.id; + +COMMIT; diff --git a/scripts/migrate_rule_groups_to_doc_type_roots.sql b/scripts/migrate_rule_groups_to_doc_type_roots.sql new file mode 100644 index 0000000..fb3d819 --- /dev/null +++ b/scripts/migrate_rule_groups_to_doc_type_roots.sql @@ -0,0 +1,303 @@ +-- 已废弃:文件名保留仅为兼容历史引用,请改用: +-- scripts/migrate_rule_groups_to_business_roots.sql +-- +-- 评查点分组迁移草案(产品新口径) +-- 目标结构: +-- 一级分组 = 业务大类(如:合同、卷宗、后续新增业务) +-- 二级分组 = 具体业务类型(如:建设工程合同、处罚-一般程序) +-- 规则集 = 挂在二级分组下 +-- 一级分组 = 可绑定入口模块 +-- +-- 注意: +-- 1. 本脚本当前仅作为迁移草案保存,尚未执行。 +-- 2. 正式执行前必须先在测试库完成全量演练。 +-- 3. 其中“合同 / 卷宗”的划分需要先由业务确认哪些 document_type 归属哪个一级分组。 + +BEGIN; + +-- 0) 预留一级分组的入口模块字段。 +ALTER TABLE leaudit_evaluation_point_groups +ADD COLUMN IF NOT EXISTS entry_module_id BIGINT NULL REFERENCES leaudit_entry_modules(id); + +CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_entry_module +ON leaudit_evaluation_point_groups(entry_module_id); + +-- 1) 建立目标一级分组(当前先按已知口径建立“合同 / 卷宗”两个大类)。 +-- 后续若有新业务,只需继续插入新的一级分组即可。 +WITH root_candidates AS ( + SELECT + 'biz.contract'::varchar AS code, + '合同'::varchar AS name, + '合同业务大类'::text AS description, + 1::bigint AS entry_module_id, + 10::int AS sort_order + UNION ALL + SELECT + 'biz.case'::varchar, + '卷宗'::varchar, + '卷宗业务大类'::text, + 2::bigint, + 20::int +), +inserted_roots AS ( + INSERT INTO leaudit_evaluation_point_groups ( + pid, + code, + name, + description, + document_type_id, + entry_module_id, + sort_order, + is_enabled, + created_at, + updated_at + ) + SELECT + 0, + rc.code, + rc.name, + rc.description, + NULL, + rc.entry_module_id, + rc.sort_order, + TRUE, + NOW(), + NOW() + FROM root_candidates rc + WHERE NOT EXISTS ( + SELECT 1 + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) = 0 + AND LOWER(g.code) = LOWER(rc.code) + ) + RETURNING id, code, name +) +SELECT COUNT(*) AS inserted_root_count FROM inserted_roots; + +-- 2) 建立 document_type -> 一级分组 的映射。 +-- 这里先按入口模块粗分: +-- 合同管理 -> 合同 +-- 案卷智能评查 -> 卷宗 +-- 后续若出现其他入口,需在这里继续补 mapping。 +WITH root_map AS ( + SELECT id, code, name, entry_module_id + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND code IN ('biz.contract', 'biz.case') +), +doc_type_root_mapping AS ( + SELECT + dt.id AS document_type_id, + dt.code AS document_type_code, + dt.name AS document_type_name, + dt.entry_module_id, + CASE + WHEN dt.entry_module_id = 1 THEN (SELECT id FROM root_map WHERE code = 'biz.contract' LIMIT 1) + WHEN dt.entry_module_id = 2 THEN (SELECT id FROM root_map WHERE code = 'biz.case' LIMIT 1) + ELSE NULL + END AS root_group_id + FROM leaudit_document_types dt + WHERE dt.deleted_at IS NULL +) +SELECT * +FROM doc_type_root_mapping +ORDER BY document_type_id; + +-- 3) 为每个 document_type 建立新的二级分组(挂到一级分组下)。 +WITH doc_type_root_mapping AS ( + SELECT + dt.id AS document_type_id, + dt.code AS document_type_code, + dt.name AS document_type_name, + dt.description, + dt.entry_module_id, + dt.sort_order, + dt.is_enabled, + CASE + WHEN dt.entry_module_id = 1 THEN ( + SELECT id FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = 'biz.contract' + LIMIT 1 + ) + WHEN dt.entry_module_id = 2 THEN ( + SELECT id FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = 'biz.case' + LIMIT 1 + ) + ELSE NULL + END AS root_group_id + FROM leaudit_document_types dt + WHERE dt.deleted_at IS NULL +), +inserted_children AS ( + INSERT INTO leaudit_evaluation_point_groups ( + pid, + code, + name, + description, + document_type_id, + entry_module_id, + sort_order, + is_enabled, + created_at, + updated_at + ) + SELECT + m.root_group_id, + m.document_type_code, + m.document_type_name, + COALESCE(m.description, m.document_type_name || '二级分组'), + m.document_type_id, + NULL, + COALESCE(m.sort_order, 0), + COALESCE(m.is_enabled, TRUE), + NOW(), + NOW() + FROM doc_type_root_mapping m + WHERE m.root_group_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) <> 0 + AND g.document_type_id = m.document_type_id + ) + RETURNING id, pid, document_type_id, code, name +) +SELECT COUNT(*) AS inserted_child_count FROM inserted_children; + +-- 4) 将旧“一级=具体文档类型”的规则绑定迁到新二级分组。 +-- 迁移策略: +-- - 若旧一级或其唯一默认子级已绑定规则集,则全部搬到新的二级分组上 +-- - 旧数据本身不立刻硬删除,先留待人工复核 +WITH old_doc_type_roots AS ( + SELECT + g.id AS old_group_id, + g.document_type_id + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) = 0 + AND g.document_type_id IS NOT NULL +), +new_children AS ( + SELECT + g.id AS new_group_id, + g.document_type_id + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) <> 0 + AND g.document_type_id IS NOT NULL +), +move_pairs AS ( + SELECT + old.old_group_id, + child.new_group_id, + old.document_type_id + FROM old_doc_type_roots old + JOIN new_children child + ON child.document_type_id = old.document_type_id +), +root_binding_updates AS ( + UPDATE leaudit_rule_group_bindings rgb + SET group_id = mp.new_group_id, + updated_at = NOW() + FROM move_pairs mp + WHERE rgb.group_id = mp.old_group_id + AND rgb.deleted_at IS NULL + RETURNING rgb.id +) +SELECT COUNT(*) AS moved_root_binding_count FROM root_binding_updates; + +-- 5) 将旧默认子级(如 *.default / 通用)上的绑定也迁到新二级分组。 +WITH old_default_children AS ( + SELECT + child.id AS old_group_id, + child.document_type_id + FROM leaudit_evaluation_point_groups child + JOIN leaudit_evaluation_point_groups parent + ON parent.id = child.pid + AND parent.deleted_at IS NULL + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND parent.document_type_id IS NOT NULL + AND child.document_type_id = parent.document_type_id + AND ( + child.name = '通用' + OR child.code LIKE '%.default' + ) +), +new_children AS ( + SELECT + g.id AS new_group_id, + g.document_type_id + FROM leaudit_evaluation_point_groups g + JOIN leaudit_evaluation_point_groups root + ON root.id = g.pid + AND root.deleted_at IS NULL + AND root.document_type_id IS NULL + WHERE g.deleted_at IS NULL + AND COALESCE(g.pid, 0) <> 0 + AND g.document_type_id IS NOT NULL +), +move_pairs AS ( + SELECT + old.old_group_id, + child.new_group_id + FROM old_default_children old + JOIN new_children child + ON child.document_type_id = old.document_type_id +), +default_binding_updates AS ( + UPDATE leaudit_rule_group_bindings rgb + SET group_id = mp.new_group_id, + updated_at = NOW() + FROM move_pairs mp + WHERE rgb.group_id = mp.old_group_id + AND rgb.deleted_at IS NULL + RETURNING rgb.id +) +SELECT COUNT(*) AS moved_default_binding_count FROM default_binding_updates; + +-- 6) 重建文档类型汇总绑定,确保文档类型页仍可看到汇总规则集。 +UPDATE leaudit_rule_type_bindings +SET deleted_at = NOW(), + updated_at = NOW() +WHERE deleted_at IS NULL; + +INSERT INTO leaudit_rule_type_bindings ( + doc_type_id, + doc_type_code, + rule_set_id, + binding_mode, + priority, + is_active, + note, + created_at, + updated_at, + region +) +SELECT DISTINCT ON (c.document_type_id, rgb.rule_set_id) + c.document_type_id, + dt.code, + rgb.rule_set_id, + 'explicit', + 100, + TRUE, + 're-built from second-level group bindings after root/category migration', + NOW(), + NOW(), + 'default' +FROM leaudit_rule_group_bindings rgb +JOIN leaudit_evaluation_point_groups c + ON c.id = rgb.group_id + AND c.deleted_at IS NULL + AND COALESCE(c.pid, 0) <> 0 +JOIN leaudit_document_types dt + ON dt.id = c.document_type_id +WHERE rgb.deleted_at IS NULL + AND rgb.is_active = TRUE +ORDER BY c.document_type_id, rgb.rule_set_id, rgb.priority DESC, rgb.id ASC; + +COMMIT; diff --git a/scripts/precheck_rule_group_migration.sql b/scripts/precheck_rule_group_migration.sql new file mode 100644 index 0000000..57cfca9 --- /dev/null +++ b/scripts/precheck_rule_group_migration.sql @@ -0,0 +1,270 @@ +-- 评查点分组正式迁移前巡检脚本 +-- 用途: +-- 1. 在测试库 / 正式库执行前,先盘点当前分组树、规则集绑定和入口映射风险 +-- 2. 本脚本只做查询,不修改任何数据 +-- +-- 推荐执行方式: +-- psql "$DATABASE_URL" -f scripts/precheck_rule_group_migration.sql + +-- ========================================================= +-- 0. 文档类型与入口模块映射总览 +-- ========================================================= +SELECT + dt.id, + dt.code, + dt.name, + dt.entry_module_id, + em.name AS entry_module_name, + dt.sort_order, + dt.is_enabled +FROM leaudit_document_types dt +LEFT JOIN leaudit_entry_modules em + ON em.id = dt.entry_module_id +WHERE dt.deleted_at IS NULL +ORDER BY dt.sort_order, dt.id; + +-- ========================================================= +-- 1. 旧一级根检查:一级直接挂具体文档类型 +-- ========================================================= +SELECT + id, + code, + name, + document_type_id, + entry_module_id, + sort_order, + is_enabled +FROM leaudit_evaluation_point_groups +WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id IS NOT NULL +ORDER BY id; + +-- ========================================================= +-- 2. 新一级业务大类根检查:一级不直接挂文档类型 +-- ========================================================= +SELECT + id, + code, + name, + entry_module_id, + sort_order, + is_enabled +FROM leaudit_evaluation_point_groups +WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id IS NULL +ORDER BY sort_order, id; + +-- ========================================================= +-- 3. 二级分组全量清单 +-- ========================================================= +SELECT + child.id, + child.pid, + parent.name AS parent_name, + child.code, + child.name, + child.document_type_id, + dt.name AS document_type_name, + COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS resolved_entry_module_id, + em.name AS resolved_entry_module_name, + child.sort_order, + child.is_enabled +FROM leaudit_evaluation_point_groups child +LEFT JOIN leaudit_evaluation_point_groups parent + ON parent.id = child.pid + AND parent.deleted_at IS NULL +LEFT JOIN leaudit_document_types dt + ON dt.id = child.document_type_id +LEFT JOIN leaudit_entry_modules em + ON em.id = COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id) +WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 +ORDER BY child.pid, child.sort_order, child.id; + +-- ========================================================= +-- 4. 二级分组唯一性检查:同一文档类型是否被多个二级分组承接 +-- ========================================================= +SELECT + document_type_id, + COUNT(*) AS child_count, + STRING_AGG(name, ' / ' ORDER BY id) AS child_names +FROM leaudit_evaluation_point_groups +WHERE deleted_at IS NULL + AND COALESCE(pid, 0) <> 0 + AND document_type_id IS NOT NULL +GROUP BY document_type_id +HAVING COUNT(*) > 1 +ORDER BY document_type_id; + +-- ========================================================= +-- 5. 旧默认子级检查:通用 / *.default +-- ========================================================= +SELECT + child.id, + child.pid, + parent.name AS parent_name, + child.name, + child.code, + child.document_type_id +FROM leaudit_evaluation_point_groups child +JOIN leaudit_evaluation_point_groups parent + ON parent.id = child.pid + AND parent.deleted_at IS NULL +WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + AND ( + child.name = '通用' + OR child.code LIKE '%.default' + ) +ORDER BY child.id; + +-- ========================================================= +-- 6. 规则集绑定全量清单:按分组树展开 +-- ========================================================= +SELECT + rgb.id AS binding_id, + rgb.group_id, + root.id AS root_id, + root.name AS root_name, + child.id AS child_id, + child.name AS child_name, + child.document_type_id, + dt.name AS document_type_name, + rgb.rule_set_id, + rs.rule_name, + rs.rule_type, + rgb.priority, + rgb.is_active, + rgb.note +FROM leaudit_rule_group_bindings rgb +JOIN leaudit_evaluation_point_groups child + ON child.id = rgb.group_id + AND child.deleted_at IS NULL +LEFT JOIN leaudit_evaluation_point_groups root + ON root.id = child.pid + AND root.deleted_at IS NULL +LEFT JOIN leaudit_document_types dt + ON dt.id = child.document_type_id +LEFT JOIN leaudit_rule_sets rs + ON rs.id = rgb.rule_set_id +WHERE rgb.deleted_at IS NULL +ORDER BY root.id, child.id, rgb.priority DESC, rgb.id; + +-- ========================================================= +-- 7. 规则集重复挂载检查:旧一级根与默认子级是否同时挂了同一规则集 +-- ========================================================= +WITH target_groups AS ( + SELECT + g.id, + g.document_type_id, + g.name, + g.code, + CASE + WHEN COALESCE(g.pid, 0) = 0 THEN 'old_root' + WHEN g.name = '通用' OR g.code LIKE '%.default' THEN 'default_child' + ELSE 'other_child' + END AS group_kind + FROM leaudit_evaluation_point_groups g + WHERE g.deleted_at IS NULL + AND g.document_type_id IS NOT NULL +) +SELECT + tg.document_type_id, + rgb.rule_set_id, + COUNT(*) AS binding_count, + STRING_AGG(tg.group_kind || ':' || tg.name, ' / ' ORDER BY tg.id) AS group_sources +FROM leaudit_rule_group_bindings rgb +JOIN target_groups tg + ON tg.id = rgb.group_id +WHERE rgb.deleted_at IS NULL +GROUP BY tg.document_type_id, rgb.rule_set_id +HAVING COUNT(*) > 1 +ORDER BY tg.document_type_id, rgb.rule_set_id; + +-- ========================================================= +-- 8. 文档类型汇总绑定表现状:leaudit_rule_type_bindings +-- ========================================================= +SELECT + rtb.id, + rtb.doc_type_id, + dt.name AS document_type_name, + rtb.doc_type_code, + rtb.rule_set_id, + rs.rule_name, + rs.rule_type, + rtb.binding_mode, + rtb.priority, + rtb.is_active, + rtb.note +FROM leaudit_rule_type_bindings rtb +LEFT JOIN leaudit_document_types dt + ON dt.id = rtb.doc_type_id +LEFT JOIN leaudit_rule_sets rs + ON rs.id = rtb.rule_set_id +WHERE rtb.deleted_at IS NULL +ORDER BY rtb.doc_type_id, rtb.priority DESC, rtb.id; + +-- ========================================================= +-- 9. 规则集可运行性检查:版本占位 +-- ========================================================= +SELECT + rs.id, + rs.rule_name, + rs.rule_type, + rs.current_version_id, + NULL::bigint AS fallback_version_id, + rs.status +FROM leaudit_rule_sets rs +WHERE rs.deleted_at IS NULL +ORDER BY rs.id; + +-- ========================================================= +-- 10. 文档类型缺失入口模块检查 +-- ========================================================= +SELECT + dt.id, + dt.code, + dt.name +FROM leaudit_document_types dt +WHERE dt.deleted_at IS NULL + AND dt.entry_module_id IS NULL +ORDER BY dt.id; + +-- ========================================================= +-- 11. 一级根缺失入口模块检查 +-- ========================================================= +SELECT + id, + code, + name +FROM leaudit_evaluation_point_groups +WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND document_type_id IS NULL + AND entry_module_id IS NULL +ORDER BY id; + +-- ========================================================= +-- 12. 新链路下无法命中规则集的二级分组检查 +-- ========================================================= +SELECT + child.id, + child.name, + child.code, + child.document_type_id, + dt.name AS document_type_name, + COUNT(rgb.id) AS binding_count +FROM leaudit_evaluation_point_groups child +LEFT JOIN leaudit_rule_group_bindings rgb + ON rgb.group_id = child.id + AND rgb.deleted_at IS NULL + AND rgb.is_active = TRUE +LEFT JOIN leaudit_document_types dt + ON dt.id = child.document_type_id +WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 +GROUP BY child.id, child.name, child.code, child.document_type_id, dt.name +HAVING COUNT(rgb.id) = 0 +ORDER BY child.id; diff --git a/scripts/seed_evaluation_points_rbac.sql b/scripts/seed_evaluation_points_rbac.sql new file mode 100644 index 0000000..999c23a --- /dev/null +++ b/scripts/seed_evaluation_points_rbac.sql @@ -0,0 +1,91 @@ +BEGIN; + +WITH rules_route AS ( + SELECT id + FROM sys_routes + WHERE route_path = '/rules' + AND deleted_at IS NULL + LIMIT 1 +) +INSERT INTO permissions ( + permission_key, + module, + resource, + action, + description, + display_name, + permission_type, + is_system, + metadata, + created_at, + updated_at, + sort_order, + route_id, + api_path, + api_method +) +SELECT * +FROM ( + SELECT 'evaluation_point:list:read', 'evaluation_point', 'list', 'read', '查看评查点列表', '评查点列表', 'API', TRUE, '{}'::jsonb, NOW(), NOW(), 51, (SELECT id FROM rules_route), '/api/v3/evaluation-points', 'GET' + UNION ALL + SELECT 'evaluation_point:detail:read', 'evaluation_point', 'detail', 'read', '查看评查点详情', '评查点详情', 'API', TRUE, '{}'::jsonb, NOW(), NOW(), 52, (SELECT id FROM rules_route), '/api/v3/evaluation-points/{id}', 'GET' + UNION ALL + SELECT 'evaluation_point:create:write', 'evaluation_point', 'create', 'write', '创建评查点', '创建评查点', 'API', TRUE, '{}'::jsonb, NOW(), NOW(), 53, (SELECT id FROM rules_route), '/api/v3/evaluation-points', 'POST' + UNION ALL + SELECT 'evaluation_point:update:write', 'evaluation_point', 'update', 'write', '更新评查点', '更新评查点', 'API', TRUE, '{}'::jsonb, NOW(), NOW(), 54, (SELECT id FROM rules_route), '/api/v3/evaluation-points/{id}', 'PUT' + UNION ALL + SELECT 'evaluation_point:delete:delete', 'evaluation_point', 'delete', 'delete', '删除评查点', '删除评查点', 'API', TRUE, '{}'::jsonb, NOW(), NOW(), 55, (SELECT id FROM rules_route), '/api/v3/evaluation-points/{id}', 'DELETE' +) AS seed +ON CONFLICT (permission_key) +DO UPDATE SET + module = EXCLUDED.module, + resource = EXCLUDED.resource, + action = EXCLUDED.action, + description = EXCLUDED.description, + display_name = EXCLUDED.display_name, + permission_type = EXCLUDED.permission_type, + is_system = EXCLUDED.is_system, + metadata = EXCLUDED.metadata, + updated_at = NOW(), + sort_order = EXCLUDED.sort_order, + route_id = EXCLUDED.route_id, + api_path = EXCLUDED.api_path, + api_method = EXCLUDED.api_method; + +WITH permission_ids AS ( + SELECT id + FROM permissions + WHERE permission_key IN ( + 'evaluation_point:list:read', + 'evaluation_point:detail:read', + 'evaluation_point:create:write', + 'evaluation_point:update:write', + 'evaluation_point:delete:delete' + ) +), admin_roles AS ( + SELECT id, + CASE + WHEN role_key = 'admin' THEN 'DEPT' + ELSE 'ALL' + END AS data_scope + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +) +INSERT INTO role_permissions ( + role_id, + permission_id, + grant_type, + data_scope, + created_at, + updated_at +) +SELECT admin_roles.id, permission_ids.id, 'GRANT', admin_roles.data_scope, NOW(), NOW() +FROM admin_roles +CROSS JOIN permission_ids +ON CONFLICT (role_id, permission_id) +DO UPDATE SET + grant_type = EXCLUDED.grant_type, + data_scope = EXCLUDED.data_scope, + updated_at = NOW(); + +COMMIT; diff --git a/scripts/seed_frontend_route_scope.sql b/scripts/seed_frontend_route_scope.sql new file mode 100644 index 0000000..4348c84 --- /dev/null +++ b/scripts/seed_frontend_route_scope.sql @@ -0,0 +1,177 @@ +BEGIN; + +-- 补齐当前前端真实菜单树里仍需要暴露的模块路由: +-- 1. AI 对话 +-- 2. 合同管理(搜索/列表) +-- 3. 交叉评查 +WITH upsert_routes AS ( + INSERT INTO sys_routes ( + route_path, + route_name, + component, + parent_id, + route_title, + icon, + sort_order, + is_hidden, + is_cache, + meta, + status, + created_at, + updated_at, + deleted_at + ) + VALUES + ('/chat-with-llm', 'chat-with-llm', 'chat-with-llm', NULL, 'AI对话', 'ri-chat-smile-2-line', 20, FALSE, TRUE, '{"group":"assistant"}'::jsonb, 0, NOW(), NOW(), NULL), + ('/contract-template', 'contract-template', 'contract-template', NULL, '合同管理', 'ri-file-search-line', 50, FALSE, TRUE, '{"group":"contract"}'::jsonb, 0, NOW(), NOW(), NULL), + ('/cross-checking', 'cross-checking', 'cross-checking', NULL, '交叉评查', 'ri-color-filter-line', 70, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, NOW(), NOW(), NULL) + ON CONFLICT (route_path) WHERE deleted_at IS NULL + DO UPDATE SET + route_name = EXCLUDED.route_name, + component = EXCLUDED.component, + route_title = EXCLUDED.route_title, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_hidden = EXCLUDED.is_hidden, + is_cache = EXCLUDED.is_cache, + meta = EXCLUDED.meta, + status = 0, + updated_at = NOW(), + deleted_at = NULL + RETURNING id, route_path +), +all_root_routes AS ( + SELECT id, route_path + FROM sys_routes + WHERE deleted_at IS NULL + AND route_path IN ('/chat-with-llm', '/contract-template', '/cross-checking') +), +template_root AS ( + SELECT id FROM all_root_routes WHERE route_path = '/contract-template' +), +cross_root AS ( + SELECT id FROM all_root_routes WHERE route_path = '/cross-checking' +) +INSERT INTO sys_routes ( + route_path, + route_name, + component, + parent_id, + route_title, + icon, + sort_order, + is_hidden, + is_cache, + meta, + status, + created_at, + updated_at, + deleted_at +) +VALUES + ('/contract-template/search', 'contract-template.search', 'contract-template.search', (SELECT id FROM template_root), '模板搜索', 'ri-search-line', 1, FALSE, TRUE, '{"group":"contract"}'::jsonb, 0, NOW(), NOW(), NULL), + ('/contract-template/list', 'contract-template.list', 'contract-template.list', (SELECT id FROM template_root), '模板列表', 'ri-folder-line', 2, FALSE, TRUE, '{"group":"contract"}'::jsonb, 0, NOW(), NOW(), NULL), + ('/cross-checking/upload', 'cross-checking.upload', 'cross-checking.upload', (SELECT id FROM cross_root), '创建任务', 'ri-upload-cloud-line', 1, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, NOW(), NOW(), NULL), + ('/cross-checking/result', 'cross-checking.result', 'cross-checking.result', (SELECT id FROM cross_root), '评查结果', 'ri-file-list-3-line', 2, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, NOW(), NOW(), NULL) +ON CONFLICT (route_path) WHERE deleted_at IS NULL +DO UPDATE SET + route_name = EXCLUDED.route_name, + component = EXCLUDED.component, + parent_id = EXCLUDED.parent_id, + route_title = EXCLUDED.route_title, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_hidden = EXCLUDED.is_hidden, + is_cache = EXCLUDED.is_cache, + meta = EXCLUDED.meta, + status = 0, + updated_at = NOW(), + deleted_at = NULL; + +-- 修正旧环境已存在记录的父子关系与显示名,避免菜单被拆成多个平级项 +UPDATE sys_routes AS child +SET + parent_id = root.id, + route_title = CASE + WHEN child.route_path = '/contract-template/search' THEN '模板搜索' + WHEN child.route_path = '/contract-template/list' THEN '模板列表' + WHEN child.route_path = '/cross-checking/upload' THEN '创建任务' + WHEN child.route_path = '/cross-checking/result' THEN '评查结果' + ELSE child.route_title + END, + updated_at = NOW() +FROM sys_routes root +WHERE child.deleted_at IS NULL + AND root.deleted_at IS NULL + AND ( + (child.route_path IN ('/contract-template/search', '/contract-template/list') AND root.route_path = '/contract-template') + OR (child.route_path IN ('/cross-checking/upload', '/cross-checking/result') AND root.route_path = '/cross-checking') + ); + +UPDATE sys_routes +SET route_title = CASE + WHEN route_path = '/contract-template' THEN '合同管理' + WHEN route_path = '/contract-template/search' THEN '模板搜索' + WHEN route_path = '/contract-template/list' THEN '模板列表' + ELSE route_title + END, + updated_at = NOW() +WHERE deleted_at IS NULL + AND route_path IN ('/contract-template', '/contract-template/search', '/contract-template/list'); + +WITH role_map AS ( + SELECT id, role_key + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +), +route_map AS ( + SELECT id, route_path + FROM sys_routes + WHERE deleted_at IS NULL + AND route_path IN ( + '/chat-with-llm', + '/contract-template', + '/contract-template/search', + '/contract-template/list', + '/cross-checking', + '/cross-checking/upload', + '/cross-checking/result' + ) +), +seed(role_key, route_path, permission, status) AS ( + VALUES + ('super_admin', '/chat-with-llm', 'RW', 1), + ('super_admin', '/contract-template', 'RW', 1), + ('super_admin', '/contract-template/search', 'RW', 1), + ('super_admin', '/contract-template/list', 'RW', 1), + ('super_admin', '/cross-checking', 'RW', 1), + ('super_admin', '/cross-checking/upload', 'RW', 1), + ('super_admin', '/cross-checking/result', 'RW', 1), + + ('provincial_admin', '/chat-with-llm', 'RW', 1), + ('provincial_admin', '/contract-template', 'RW', 1), + ('provincial_admin', '/contract-template/search', 'RW', 1), + ('provincial_admin', '/contract-template/list', 'RW', 1), + ('provincial_admin', '/cross-checking', 'RW', 1), + ('provincial_admin', '/cross-checking/upload', 'RW', 1), + ('provincial_admin', '/cross-checking/result', 'RW', 1), + + ('admin', '/chat-with-llm', 'RW', 1), + ('admin', '/contract-template', 'RW', 1), + ('admin', '/contract-template/search', 'RW', 1), + ('admin', '/contract-template/list', 'RW', 1), + ('admin', '/cross-checking', 'RW', 1), + ('admin', '/cross-checking/upload', 'RW', 1), + ('admin', '/cross-checking/result', 'RW', 1) +) +INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) +SELECT rm.id, tm.id, s.permission, s.status, NOW(), NOW() +FROM seed s +JOIN role_map rm ON rm.role_key = s.role_key +JOIN route_map tm ON tm.route_path = s.route_path +ON CONFLICT (role_id, route_id) DO UPDATE SET + permission = EXCLUDED.permission, + status = EXCLUDED.status, + updated_at = NOW(); + +COMMIT; diff --git a/scripts/seed_home_entry_modules.sql b/scripts/seed_home_entry_modules.sql new file mode 100644 index 0000000..3ff18f2 --- /dev/null +++ b/scripts/seed_home_entry_modules.sql @@ -0,0 +1,161 @@ +-- ============================================================================ +-- 首页入口模块初始化脚本(按老系统真实语义迁移) +-- 用途: +-- 1. 初始化首页入口模块 +-- 2. 绑定现有 leaudit_document_types.entry_module_id +-- 3. 对齐老系统“入口模块表只存业务入口 + 图片路径,跳转由前端硬编码”的实际情况 +-- +-- 当前新系统落地策略: +-- - leaudit_entry_modules.path = 前端跳转路由 +-- - leaudit_entry_modules.icon_path = 入口图标路径 +-- +-- 老系统真实数据来源(docauditai): +-- 1. 合同管理 +-- - 图片:documents/mz/static/img/entry_module_1.png +-- - 文档类型:HT +-- - 实际跳转:/contract-template/search +-- 2. 案卷智能评查 +-- - 图片:documents/mz/static/img/entry_module_2.png +-- - 文档类型:XZXK, XZCF +-- - 实际跳转:/home +-- 3. 内部公文 +-- - 图片:documents/mz/static/img/entry_module_15.png +-- - 文档类型:NBGW +-- - 实际跳转:/home +-- +-- 另外补齐老前端硬编码入口: +-- 4. 智慧法务助手 -> /chat-with-llm/chat +-- 5. 交叉评查 -> /cross-checking +-- ============================================================================ + +BEGIN; + +INSERT INTO leaudit_entry_modules ( + name, + description, + path, + icon_path, + areas, + sort_order, + is_enabled, + created_at, + updated_at, + deleted_at +) +VALUES + ( + '合同管理', + '合同管理入口模块', + '/contract-template/search', + 'documents/mz/static/img/entry_module_1.png', + '[ + {"area":"梅州","enabled":true,"sort_order":1}, + {"area":"云浮","enabled":true,"sort_order":2}, + {"area":"揭阳","enabled":true,"sort_order":3}, + {"area":"潮州","enabled":true,"sort_order":4}, + {"area":"省局","enabled":true,"sort_order":5} + ]'::jsonb, + 10, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL + ), + ( + '案卷智能评查', + '案卷类型的入口模块', + '/home', + 'documents/mz/static/img/entry_module_2.png', + '[ + {"area":"梅州","enabled":true,"sort_order":1}, + {"area":"揭阳","enabled":true,"sort_order":2}, + {"area":"云浮","enabled":true,"sort_order":3}, + {"area":"潮州","enabled":true,"sort_order":4}, + {"area":"省局","enabled":true,"sort_order":5} + ]'::jsonb, + 20, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL + ), + ( + '内部公文', + '内部公文', + '/home', + 'documents/mz/static/img/entry_module_15.png', + '[ + {"area":"潮州","enabled":true,"sort_order":1}, + {"area":"省局","enabled":true,"sort_order":2}, + {"area":"揭阳","enabled":true,"sort_order":3}, + {"area":"梅州","enabled":true,"sort_order":4}, + {"area":"云浮","enabled":true,"sort_order":5} + ]'::jsonb, + 30, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL + ), + ( + '智慧法务助手', + '大模型法务助手入口', + '/chat-with-llm/chat', + '/images/icon_assistant.png', + NULL, + 40, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL + ), + ( + '交叉评查', + '交叉评查任务入口', + '/cross-checking', + '/images/icon_cross@2x.png', + NULL, + 50, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL + ) +ON CONFLICT (name) DO UPDATE +SET + description = EXCLUDED.description, + path = EXCLUDED.path, + icon_path = EXCLUDED.icon_path, + areas = EXCLUDED.areas, + sort_order = EXCLUDED.sort_order, + is_enabled = EXCLUDED.is_enabled, + updated_at = CURRENT_TIMESTAMP, + deleted_at = NULL; + +WITH module_map AS ( + SELECT id, name + FROM leaudit_entry_modules + WHERE deleted_at IS NULL +) +UPDATE leaudit_document_types dt +SET + entry_module_id = mm.id, + updated_at = CURRENT_TIMESTAMP +FROM module_map mm +WHERE dt.deleted_at IS NULL + AND ( + ( + mm.name = '合同管理' + AND dt.code LIKE 'contract.%' + ) + OR ( + mm.name = '案卷智能评查' + AND dt.code LIKE '行政卷宗.%' + ) + OR ( + mm.name = '内部公文' + AND dt.code IN ('NBGW', 'internal.document') + ) + ); + +COMMIT; diff --git a/scripts/seed_rule_groups_rbac.sql b/scripts/seed_rule_groups_rbac.sql new file mode 100644 index 0000000..6ba5487 --- /dev/null +++ b/scripts/seed_rule_groups_rbac.sql @@ -0,0 +1,163 @@ +BEGIN; + +WITH settings_route AS ( + SELECT id FROM sys_routes WHERE route_path = '/settings' AND deleted_at IS NULL LIMIT 1 +), upsert_route AS ( + INSERT INTO sys_routes ( + route_path, + route_name, + component, + parent_id, + route_title, + icon, + sort_order, + is_hidden, + is_cache, + meta, + status, + created_at, + updated_at, + deleted_at + ) + SELECT + '/rule-groups', + 'rule-groups', + 'rule-groups', + settings_route.id, + '评查点分组', + 'ri-folder-open-line', + 4, + FALSE, + TRUE, + '{"group": "settings"}'::jsonb, + 0, + NOW(), + NOW(), + NULL + FROM settings_route + ON CONFLICT (route_path) WHERE deleted_at IS NULL + DO UPDATE SET + route_name = EXCLUDED.route_name, + component = EXCLUDED.component, + parent_id = EXCLUDED.parent_id, + route_title = EXCLUDED.route_title, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + is_hidden = EXCLUDED.is_hidden, + is_cache = EXCLUDED.is_cache, + meta = EXCLUDED.meta, + status = 0, + updated_at = NOW(), + deleted_at = NULL + RETURNING id +), target_route AS ( + SELECT id FROM upsert_route + UNION ALL + SELECT id FROM sys_routes WHERE route_path = '/rule-groups' AND deleted_at IS NULL LIMIT 1 +) +INSERT INTO permissions ( + permission_key, + module, + resource, + action, + description, + display_name, + permission_type, + is_system, + metadata, + created_at, + updated_at, + sort_order, + route_id, + api_path, + api_method +) +SELECT * +FROM ( + SELECT 'evaluation_group:list:read', 'evaluation_group', 'list', 'read', '查看评查点分组列表', '查看评查点分组列表', 'API', FALSE, '{}'::jsonb, NOW(), NOW(), 1, (SELECT id FROM target_route LIMIT 1), '/api/v3/evaluation-point-groups', 'GET' + UNION ALL + SELECT 'evaluation_group:detail:read', 'evaluation_group', 'detail', 'read', '查看评查点分组详情', '查看评查点分组详情', 'API', FALSE, '{}'::jsonb, NOW(), NOW(), 2, (SELECT id FROM target_route LIMIT 1), '/api/v3/evaluation-point-groups/{id}', 'GET' + UNION ALL + SELECT 'evaluation_group:create:write','evaluation_group', 'create', 'write', '创建评查点分组', '创建评查点分组', 'API', FALSE, '{}'::jsonb, NOW(), NOW(), 3, (SELECT id FROM target_route LIMIT 1), '/api/v3/evaluation-point-groups', 'POST' + UNION ALL + SELECT 'evaluation_group:update:write','evaluation_group', 'update', 'write', '更新评查点分组', '更新评查点分组', 'API', FALSE, '{}'::jsonb, NOW(), NOW(), 4, (SELECT id FROM target_route LIMIT 1), '/api/v3/evaluation-point-groups/{id}', 'PUT' + UNION ALL + SELECT 'evaluation_group:delete:delete','evaluation_group','delete', 'delete', '删除评查点分组', '删除评查点分组', 'API', FALSE, '{}'::jsonb, NOW(), NOW(), 5, (SELECT id FROM target_route LIMIT 1), '/api/v3/evaluation-point-groups/{id}', 'DELETE' + UNION ALL + SELECT 'evaluation_group:batch:write', 'evaluation_group', 'batch', 'write', '批量操作评查点分组', '批量操作评查点分组', 'API', FALSE, '{}'::jsonb, NOW(), NOW(), 6, (SELECT id FROM target_route LIMIT 1), '/api/v3/evaluation-point-groups/batch/status', 'PATCH' +) AS seed +ON CONFLICT (permission_key) +DO UPDATE SET + module = EXCLUDED.module, + resource = EXCLUDED.resource, + action = EXCLUDED.action, + description = EXCLUDED.description, + display_name = EXCLUDED.display_name, + permission_type = EXCLUDED.permission_type, + metadata = EXCLUDED.metadata, + updated_at = NOW(), + sort_order = EXCLUDED.sort_order, + route_id = EXCLUDED.route_id, + api_path = EXCLUDED.api_path, + api_method = EXCLUDED.api_method; + +WITH permission_ids AS ( + SELECT id, permission_key + FROM permissions + WHERE permission_key IN ( + 'evaluation_group:list:read', + 'evaluation_group:detail:read', + 'evaluation_group:create:write', + 'evaluation_group:update:write', + 'evaluation_group:delete:delete', + 'evaluation_group:batch:write' + ) +), admin_roles AS ( + SELECT id + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +), route_id AS ( + SELECT id FROM sys_routes WHERE route_path = '/rule-groups' AND deleted_at IS NULL LIMIT 1 +) +INSERT INTO role_permissions ( + role_id, + permission_id, + grant_type, + data_scope, + created_at, + updated_at +) +SELECT admin_roles.id, permission_ids.id, 'GRANT', 'ALL', NOW(), NOW() +FROM admin_roles +CROSS JOIN permission_ids +ON CONFLICT (role_id, permission_id) +DO UPDATE SET + grant_type = EXCLUDED.grant_type, + data_scope = EXCLUDED.data_scope, + updated_at = NOW(); + +WITH admin_roles AS ( + SELECT id + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +), route_id AS ( + SELECT id FROM sys_routes WHERE route_path = '/rule-groups' AND deleted_at IS NULL LIMIT 1 +) +INSERT INTO role_route ( + role_id, + route_id, + permission, + created_at, + updated_at, + status +) +SELECT admin_roles.id, route_id.id, 'RW', NOW(), NOW(), 1 +FROM admin_roles +CROSS JOIN route_id +ON CONFLICT (role_id, route_id) +DO UPDATE SET + permission = EXCLUDED.permission, + updated_at = NOW(), + status = 1; + +COMMIT; diff --git a/scripts/user_rbac_seed.sql b/scripts/user_rbac_seed.sql index ff4d507..97f578d 100644 --- a/scripts/user_rbac_seed.sql +++ b/scripts/user_rbac_seed.sql @@ -38,7 +38,14 @@ VALUES ('/rules/sets', 'rules.sets', 'rules/sets', NULL, '规则集管理', 'yaml', 31, FALSE, TRUE, '{"group":"rules"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/system', 'system', 'Layout', NULL, '系统管理', 'setting', 90, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/system/users', 'system.users', 'system/users', NULL, '用户管理', 'user', 91, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), - ('/system/roles', 'system.roles', 'system/roles', NULL, '角色权限', 'shield', 92, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL) + ('/system/roles', 'system.roles', 'system/roles', NULL, '角色权限', 'shield', 92, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/chat-with-llm', 'chat-with-llm', 'chat-with-llm', NULL, 'AI对话', 'chat', 15, FALSE, TRUE, '{"group":"assistant"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/contract-template', 'contract-template', 'contract-template', NULL, '合同管理', 'file-search', 40, FALSE, TRUE, '{"group":"contract"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/contract-template/search', 'contract-template.search', 'contract-template/search', NULL, '模板搜索', 'search', 41, FALSE, TRUE, '{"group":"contract"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/contract-template/list', 'contract-template.list', 'contract-template/list', NULL, '模板列表', 'folder', 42, FALSE, TRUE, '{"group":"contract"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/cross-checking', 'cross-checking', 'cross-checking', NULL, '交叉评查', 'flow', 60, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/cross-checking/upload', 'cross-checking.upload', 'cross-checking/upload', NULL, '创建任务', 'upload', 61, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + ('/cross-checking/result', 'cross-checking.result', 'cross-checking/result', NULL, '评查结果', 'table', 62, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL) ON CONFLICT DO NOTHING; -- -------------------------------------------------------------------------- @@ -73,6 +80,11 @@ VALUES ('rules:binding_create:write', 'rules', 'binding_create', 'write', '创建规则绑定', '创建规则绑定', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 48, NULL, '/api/rule-sets/{rule_type}/bindings', 'POST', NULL), ('rules:binding_update:write', 'rules', 'binding_update', 'write', '更新规则绑定', '更新规则绑定', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 49, NULL, '/api/rule-sets/bindings/{binding_id}', 'PUT', NULL), ('rules:binding_delete:delete', 'rules', 'binding_delete', 'delete', '删除规则绑定', '删除规则绑定', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 50, NULL, '/api/rule-sets/bindings/{binding_id}', 'DELETE', NULL), + ('evaluation_point:list:read', 'evaluation_point', 'list', 'read', '查看评查点列表', '评查点列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 51, NULL, '/api/v3/evaluation-points', 'GET', NULL), + ('evaluation_point:detail:read', 'evaluation_point', 'detail', 'read', '查看评查点详情', '评查点详情', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 52, NULL, '/api/v3/evaluation-points/{id}', 'GET', NULL), + ('evaluation_point:create:write', 'evaluation_point', 'create', 'write', '创建评查点', '创建评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 53, NULL, '/api/v3/evaluation-points', 'POST', NULL), + ('evaluation_point:update:write', 'evaluation_point', 'update', 'write', '更新评查点', '更新评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 54, NULL, '/api/v3/evaluation-points/{id}', 'PUT', NULL), + ('evaluation_point:delete:delete', 'evaluation_point', 'delete', 'delete', '删除评查点', '删除评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 55, NULL, '/api/v3/evaluation-points/{id}', 'DELETE', NULL), ('users:list:read', 'users', 'list', 'read', '查看用户列表', '用户列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 60, NULL, '/api/users/list', 'GET', NULL), ('users:create:write', 'users', 'create', 'write', '创建用户', '创建用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 61, NULL, '/api/users', 'POST', NULL), @@ -115,6 +127,13 @@ seed(role_key, route_path, permission, status) AS ( ('super_admin', '/audit/runs', 'RW', 1), ('super_admin', '/rules', 'RW', 1), ('super_admin', '/rules/sets', 'RW', 1), + ('super_admin', '/chat-with-llm', 'RW', 1), + ('super_admin', '/contract-template', 'RW', 1), + ('super_admin', '/contract-template/search', 'RW', 1), + ('super_admin', '/contract-template/list', 'RW', 1), + ('super_admin', '/cross-checking', 'RW', 1), + ('super_admin', '/cross-checking/upload', 'RW', 1), + ('super_admin', '/cross-checking/result', 'RW', 1), ('super_admin', '/system', 'RW', 1), ('super_admin', '/system/users', 'RW', 1), ('super_admin', '/system/roles', 'RW', 1), @@ -125,6 +144,13 @@ seed(role_key, route_path, permission, status) AS ( ('provincial_admin', '/audit/runs', 'RW', 1), ('provincial_admin', '/rules', 'RW', 1), ('provincial_admin', '/rules/sets', 'RW', 1), + ('provincial_admin', '/chat-with-llm', 'RW', 1), + ('provincial_admin', '/contract-template', 'RW', 1), + ('provincial_admin', '/contract-template/search', 'RW', 1), + ('provincial_admin', '/contract-template/list', 'RW', 1), + ('provincial_admin', '/cross-checking', 'RW', 1), + ('provincial_admin', '/cross-checking/upload', 'RW', 1), + ('provincial_admin', '/cross-checking/result', 'RW', 1), ('provincial_admin', '/system', 'RW', 1), ('provincial_admin', '/system/users', 'RW', 1), ('provincial_admin', '/system/roles', 'RW', 1), @@ -135,6 +161,13 @@ seed(role_key, route_path, permission, status) AS ( ('admin', '/audit/runs', 'RW', 1), ('admin', '/rules', 'RW', 1), ('admin', '/rules/sets', 'RW', 1), + ('admin', '/chat-with-llm', 'RW', 1), + ('admin', '/contract-template', 'RW', 1), + ('admin', '/contract-template/search', 'RW', 1), + ('admin', '/contract-template/list', 'RW', 1), + ('admin', '/cross-checking', 'RW', 1), + ('admin', '/cross-checking/upload', 'RW', 1), + ('admin', '/cross-checking/result', 'RW', 1), ('admin', '/system', 'RW', 1), ('admin', '/system/users', 'RW', 1), @@ -184,6 +217,11 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('super_admin', 'rules:binding_create:write', 'GRANT', 'ALL'), ('super_admin', 'rules:binding_update:write', 'GRANT', 'ALL'), ('super_admin', 'rules:binding_delete:delete', 'GRANT', 'ALL'), + ('super_admin', 'evaluation_point:list:read', 'GRANT', 'ALL'), + ('super_admin', 'evaluation_point:detail:read', 'GRANT', 'ALL'), + ('super_admin', 'evaluation_point:create:write', 'GRANT', 'ALL'), + ('super_admin', 'evaluation_point:update:write', 'GRANT', 'ALL'), + ('super_admin', 'evaluation_point:delete:delete', 'GRANT', 'ALL'), ('super_admin', 'users:list:read', 'GRANT', 'ALL'), ('super_admin', 'users:create:write', 'GRANT', 'ALL'), ('super_admin', 'users:update:write', 'GRANT', 'ALL'), @@ -215,6 +253,11 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('provincial_admin', 'rules:binding_create:write', 'GRANT', 'ALL'), ('provincial_admin', 'rules:binding_update:write', 'GRANT', 'ALL'), ('provincial_admin', 'rules:binding_delete:delete', 'GRANT', 'ALL'), + ('provincial_admin', 'evaluation_point:list:read', 'GRANT', 'ALL'), + ('provincial_admin', 'evaluation_point:detail:read', 'GRANT', 'ALL'), + ('provincial_admin', 'evaluation_point:create:write', 'GRANT', 'ALL'), + ('provincial_admin', 'evaluation_point:update:write', 'GRANT', 'ALL'), + ('provincial_admin', 'evaluation_point:delete:delete', 'GRANT', 'ALL'), ('provincial_admin', 'users:list:read', 'GRANT', 'ALL'), ('provincial_admin', 'users:create:write', 'GRANT', 'ALL'), ('provincial_admin', 'users:update:write', 'GRANT', 'ALL'), @@ -242,6 +285,11 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('admin', 'rules:binding_list:read', 'GRANT', 'DEPT'), ('admin', 'rules:binding_create:write', 'GRANT', 'DEPT'), ('admin', 'rules:binding_update:write', 'GRANT', 'DEPT'), + ('admin', 'evaluation_point:list:read', 'GRANT', 'DEPT'), + ('admin', 'evaluation_point:detail:read', 'GRANT', 'DEPT'), + ('admin', 'evaluation_point:create:write', 'GRANT', 'DEPT'), + ('admin', 'evaluation_point:update:write', 'GRANT', 'DEPT'), + ('admin', 'evaluation_point:delete:delete', 'GRANT', 'DEPT'), ('admin', 'users:list:read', 'GRANT', 'DEPT'), ('admin', 'users:update:write', 'GRANT', 'DEPT'),