From ec14e4a4544c4bdfde8b47eda145fb917ffdc586 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Fri, 22 May 2026 17:01:56 +0800 Subject: [PATCH 1/4] chore: update frontend permission fixes --- legal-platform-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-platform-frontend b/legal-platform-frontend index 20c9d48..22a277e 160000 --- a/legal-platform-frontend +++ b/legal-platform-frontend @@ -1 +1 @@ -Subproject commit 20c9d4872ac09eb3cb86ed9f1e999007ba1c944e +Subproject commit 22a277eb3d39326f39f851656bbf3c0a5d2ae6b7 -- 2.52.0 From 993f1525dddff46a68931629d44b45019a449cac Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Fri, 22 May 2026 17:14:28 +0800 Subject: [PATCH 2/4] chore: update frontend entry visibility fix --- legal-platform-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/legal-platform-frontend b/legal-platform-frontend index 22a277e..28f1054 160000 --- a/legal-platform-frontend +++ b/legal-platform-frontend @@ -1 +1 @@ -Subproject commit 22a277eb3d39326f39f851656bbf3c0a5d2ae6b7 +Subproject commit 28f1054238b891c5ecfdde71ecdbe6eb2cf543e7 -- 2.52.0 From 0309df1cf7fc2ef1d12180caa268bd38efe3d605 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Fri, 22 May 2026 17:29:05 +0800 Subject: [PATCH 3/4] fix: keep tenant document entries visible --- .../services/impl/homeServiceImpl.py | 8 +++++++ tests/test_home_entry_visibility.py | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/test_home_entry_visibility.py diff --git a/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py index 630c9e0..89d9f01 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py @@ -30,6 +30,11 @@ class HomeServiceImpl(IHomeService): "/chat-with-llm/chat", "/cross-checking", ) + _DOCUMENT_ENTRY_TARGETS: tuple[str, ...] = ( + "/files/upload", + "/documents", + "/documents/list", + ) def __init__(self) -> None: self.RbacService = RbacServiceImpl() @@ -404,6 +409,9 @@ class HomeServiceImpl(IHomeService): def _isAllowedTargetPath(self, TargetPath: str, AllowedPaths: set[str]) -> bool: """判断首页目标路径是否被当前用户路由树覆盖。""" + if TargetPath in self._DOCUMENT_ENTRY_TARGETS: + return True + if TargetPath in AllowedPaths: return True diff --git a/tests/test_home_entry_visibility.py b/tests/test_home_entry_visibility.py new file mode 100644 index 0000000..a9e3980 --- /dev/null +++ b/tests/test_home_entry_visibility.py @@ -0,0 +1,21 @@ +"""首页入口可见性测试。""" + +from fastapi_modules.fastapi_leaudit.services.impl.homeServiceImpl import HomeServiceImpl + + +def test_document_entry_targets_are_visible_without_file_management_routes(): + """文档类首页入口只受租户配置控制,不因缺少文件管理路由消失。""" + service = HomeServiceImpl() + + assert service._isAllowedTargetPath("/documents", set()) is True + assert service._isAllowedTargetPath("/documents/list", set()) is True + assert service._isAllowedTargetPath("/files/upload", set()) is True + + +def test_non_document_entry_targets_still_require_route_grant(): + """非文档入口仍需要当前用户路由树覆盖。""" + service = HomeServiceImpl() + + assert service._isAllowedTargetPath("/tenants", set()) is False + assert service._isAllowedTargetPath("/cross-checking", set()) is False + assert service._isAllowedTargetPath("/cross-checking", {"/cross-checking"}) is True -- 2.52.0 From 14d11996757003b9e14a8376130c8ab4a34bf9ba Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Fri, 22 May 2026 18:14:44 +0800 Subject: [PATCH 4/4] feat: support contract template replace and delete --- .../controllers/contractTemplateController.py | 33 ++- .../domian/Dto/contractTemplateDto.py | 4 + .../services/contractTemplateService.py | 12 + .../impl/contractTemplateServiceImpl.py | 221 ++++++++++++++++-- tests/test_contract_template_search.py | 98 ++++++++ 5 files changed, 351 insertions(+), 17 deletions(-) diff --git a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py index bf6b062..55af70b 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py @@ -9,6 +9,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, + ContractTemplateUpdateDTO, ) from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService from fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl import ContractTemplateServiceImpl @@ -82,7 +83,7 @@ class ContractTemplateController(BaseController): payload: dict = Depends(verify_access_token), ): if not await self._check_permission(int(payload["user_id"]), ["contract_template:create:write"]): - return JSONResponse(status_code=403, content={"code": 403, "msg": "当前仅允许租户管理员上传合同模板", "data": None}) + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前没有上传合同模板权限", "data": None}) body = ContractTemplateCreateDTO( title=title, template_code=template_code, @@ -129,13 +130,41 @@ class ContractTemplateController(BaseController): TemplateId: int, payload: dict = Depends(verify_access_token), ): - if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read", "contract_template:list:read"]): + if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None}) data = await self.ContractTemplateService.GetTemplateDetail(TemplateId, int(payload["user_id"])) if not data: return JSONResponse(status_code=404, content={"code": 404, "msg": "合同模板不存在", "data": None}) return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) + @self.router.put("/{TemplateId}") + async def UpdateContractTemplate( + TemplateId: int, + title: str = Form(...), + template_code: str = Form(...), + category_id: int = Form(...), + region: str | None = Form(default=None), + tenant_code: str | None = Form(default=None), + description: str | None = Form(default=None), + is_featured: bool = Form(default=False), + file: UploadFile = File(...), + pdf_file: UploadFile | None = File(default=None), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["contract_template:update:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前没有更新合同模板权限", "data": None}) + body = ContractTemplateUpdateDTO( + title=title, + template_code=template_code, + category_id=category_id, + region=region, + tenant_code=tenant_code, + description=description, + is_featured=is_featured, + ) + data = await self.ContractTemplateService.UpdateTemplate(TemplateId, body, file, pdf_file, int(payload["user_id"])) + return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) + @self.router.delete("/{TemplateId}") async def DeleteContractTemplate( TemplateId: int, diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py index 0b77230..864a4c5 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py @@ -41,3 +41,7 @@ class ContractTemplateCreateDTO(BaseModel): tenant_code: str | None = Field(None, description="所属租户编码") description: str | None = Field(None, description="模板简介") is_featured: bool = Field(False, description="是否推荐") + + +class ContractTemplateUpdateDTO(ContractTemplateCreateDTO): + """合同模板替换上传参数。""" diff --git a/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py b/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py index fbc3c89..0b4b526 100644 --- a/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py +++ b/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py @@ -5,6 +5,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, + ContractTemplateUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateCategoryVO, @@ -44,6 +45,17 @@ class IContractTemplateService(ABC): ) -> ContractTemplateCreateVO: ... + @abstractmethod + async def UpdateTemplate( + self, + TemplateId: int, + Body: ContractTemplateUpdateDTO, + File: UploadFile, + PdfFile: UploadFile | None, + CurrentUserId: int, + ) -> ContractTemplateCreateVO: + ... + @abstractmethod async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None: ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py index fe02138..d76c0e7 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py @@ -15,6 +15,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, + ContractTemplateUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateCategoryVO, @@ -225,6 +226,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): LIMIT 1 """ ) + (sql,) = self._bind_expanding(sql, params) row = (await session.execute(sql, params)).mappings().first() if not row: @@ -423,26 +425,215 @@ class ContractTemplateServiceImpl(IContractTemplateService): raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建成功但详情读取失败") return ContractTemplateCreateVO(**detail.model_dump()) + async def UpdateTemplate( + self, + TemplateId: int, + Body: ContractTemplateUpdateDTO, + File: UploadFile, + PdfFile: UploadFile | None, + CurrentUserId: int, + ) -> ContractTemplateCreateVO: + if File is None or not File.filename: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "替换模板主文件不能为空") + + fileContent = await File.read() + if not fileContent: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "替换模板主文件内容不能为空") + + normalizedCode = (Body.template_code or "").strip() + normalizedTitle = (Body.title or "").strip() + if not normalizedCode: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板编码不能为空") + if not normalizedTitle: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板标题不能为空") + + fileExt = Path(File.filename).suffix.lstrip(".").lower() + if fileExt not in {"doc", "docx", "pdf"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前仅支持上传 DOC、DOCX、PDF 模板") + mimeType = File.content_type or mimetypes.guess_type(File.filename)[0] or "application/octet-stream" + + pdfContent: bytes | None = None + pdfMimeType: str | None = None + pdfFileName: str | None = None + if PdfFile and PdfFile.filename: + pdfContent = await PdfFile.read() + if not pdfContent: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览 PDF 文件内容不能为空") + pdfExt = Path(PdfFile.filename).suffix.lstrip(".").lower() + if pdfExt != "pdf": + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览文件仅支持 PDF") + pdfMimeType = PdfFile.content_type or "application/pdf" + pdfFileName = PdfFile.filename + + async with GetAsyncSession() as session: + await self._ensureContractTemplateSchema(session) + currentUser = await self._getCurrentUserContext(CurrentUserId, session) + resolvedTenantCode, resolvedTenantName, resolvedRegion = self._resolve_upload_scope(currentUser, Body.region, Body.tenant_code) + + params: dict[str, Any] = {"template_id": TemplateId} + scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None, writable=True) + existing_sql = text( + f""" + SELECT id + FROM contract_templates t + WHERE t.id = :template_id + AND t.deleted_at IS NULL + AND {' AND '.join(scope_filters)} + LIMIT 1 + """ + ) + (existing_sql,) = self._bind_expanding(existing_sql, params) + existingRow = (await session.execute(existing_sql, params)).mappings().first() + if not existingRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权更新") + + categoryRow = ( + await session.execute( + text( + """ + SELECT id, name + FROM contract_categories + WHERE id = :category_id + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"category_id": Body.category_id}, + ) + ).mappings().first() + if not categoryRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板分类不存在") + + duplicateRow = ( + await session.execute( + text( + """ + SELECT id + FROM contract_templates + WHERE ( + tenant_code = :tenant_code + OR ( + (tenant_code IS NULL OR BTRIM(tenant_code) = '') + AND region = :region + ) + ) + AND template_code = :template_code + AND id <> :template_id + AND deleted_at IS NULL + LIMIT 1 + """ + ), + { + "tenant_code": resolvedTenantCode, + "region": resolvedRegion, + "template_code": normalizedCode, + "template_id": TemplateId, + }, + ) + ).mappings().first() + if duplicateRow: + raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前租户已存在模板编码 {normalizedCode}") + + categoryName = str(categoryRow["name"] or "未分类") + objectKey = OssPathUtils.BuildContractTemplateKey( + Region=resolvedRegion, + CategoryName=categoryName, + TemplateCode=normalizedCode, + FileRole="source", + FileName=File.filename, + ) + filePath = await self.OssService.UploadBytes( + ObjectKey=objectKey, + Content=fileContent, + ContentType=mimeType, + ) + + pdfPath: str | None = None + if pdfContent is not None and pdfFileName: + pdfObjectKey = OssPathUtils.BuildContractTemplateKey( + Region=resolvedRegion, + CategoryName=categoryName, + TemplateCode=normalizedCode, + FileRole="preview", + FileName=pdfFileName, + ) + pdfPath = await self.OssService.UploadBytes( + ObjectKey=pdfObjectKey, + Content=pdfContent, + ContentType=pdfMimeType or "application/pdf", + ) + + await session.execute( + text( + """ + UPDATE contract_templates + SET template_code = :template_code, + title = :title, + category_id = :category_id, + tenant_code = :tenant_code, + tenant_name = :tenant_name, + region = :region, + description = :description, + file_path = :file_path, + pdf_file_path = :pdf_file_path, + file_format = :file_format, + original_file_name = :original_file_name, + mime_type = :mime_type, + file_size = :file_size, + pdf_file_size = :pdf_file_size, + is_featured = :is_featured, + updated_by = :updated_by, + updated_at = NOW() + WHERE id = :template_id + AND deleted_at IS NULL + """ + ), + { + "template_id": TemplateId, + "template_code": normalizedCode, + "title": normalizedTitle, + "category_id": Body.category_id, + "tenant_code": resolvedTenantCode, + "tenant_name": resolvedTenantName, + "region": resolvedRegion, + "description": (Body.description or "").strip() or None, + "file_path": filePath, + "pdf_file_path": pdfPath, + "file_format": fileExt, + "original_file_name": File.filename, + "mime_type": mimeType, + "file_size": len(fileContent), + "pdf_file_size": len(pdfContent) if pdfContent is not None else None, + "is_featured": Body.is_featured, + "updated_by": CurrentUserId, + }, + ) + await session.commit() + + detail = await self.GetTemplateDetail(TemplateId, CurrentUserId) + if not detail: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板更新成功但详情读取失败") + return ContractTemplateCreateVO(**detail.model_dump()) + async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None: async with GetAsyncSession() as session: await self._ensureContractTemplateSchema(session) currentUser = await self._getCurrentUserContext(CurrentUserId, session) params: dict[str, Any] = {"template_id": TemplateId} scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None, writable=True) + sql = text( + f""" + SELECT id + FROM contract_templates t + WHERE t.id = :template_id + AND t.deleted_at IS NULL + AND {' AND '.join(scope_filters)} + LIMIT 1 + """ + ) + (sql,) = self._bind_expanding(sql, params) row = ( - await session.execute( - text( - f""" - SELECT id - FROM contract_templates t - WHERE t.id = :template_id - AND t.deleted_at IS NULL - AND {' AND '.join(scope_filters)} - LIMIT 1 - """ - ), - params, - ) + await session.execute(sql, params) ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权删除") @@ -859,8 +1050,8 @@ class ContractTemplateServiceImpl(IContractTemplateService): current_tenant_code = str(currentUser.get("tenant_code") or "").strip() or None current_tenant_name = str(currentUser.get("tenant_name") or "").strip() or None current_region = str(currentUser["tenant_scope_value"] or currentUser["area"] or "").strip() - if not currentUser.get("is_area_admin"): - raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持租户管理员上传合同模板") + if not currentUser.get("can_manage"): + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许有权限的管理员上传合同模板") if not current_region: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户管理员账号未配置所属租户,无法上传合同模板") if requested_tenant_code and not current_tenant_code: diff --git a/tests/test_contract_template_search.py b/tests/test_contract_template_search.py index 484f0c6..95d9087 100644 --- a/tests/test_contract_template_search.py +++ b/tests/test_contract_template_search.py @@ -1,6 +1,9 @@ import asyncio from unittest.mock import patch +import pytest + +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl import ContractTemplateServiceImpl @@ -11,6 +14,9 @@ class _EmptyMappingResult: def all(self): return [] + def first(self): + return None + class _FakeSession: def __init__(self): @@ -66,3 +72,95 @@ def test_contract_template_search_category_stats_binds_expanding_scope_params(): assert fake_session.executed_sql._bindparams["visible_regions"].expanding is True assert fake_session.executed_params["visible_tenant_codes"] == ["PROVINCIAL", "PUBLIC", "MZ"] assert fake_session.executed_params["visible_regions"] == ["省级", "公共", "梅州"] + + +def test_contract_template_upload_scope_allows_global_manager_with_tenant(): + service = ContractTemplateServiceImpl() + + tenant_code, tenant_name, region = service._resolve_upload_scope( + { + "id": 1, + "area": "梅州", + "tenant_code": "MZ", + "tenant_name": "梅州", + "tenant_scope_value": "梅州", + "is_global": True, + "can_manage": True, + "is_area_admin": False, + }, + "梅州", + "MZ", + ) + + assert tenant_code == "MZ" + assert tenant_name == "梅州" + assert region == "梅州" + + +def test_contract_template_upload_scope_rejects_non_manager(): + service = ContractTemplateServiceImpl() + + with pytest.raises(LeauditException): + service._resolve_upload_scope( + { + "id": 2, + "area": "梅州", + "tenant_code": "MZ", + "tenant_name": "梅州", + "tenant_scope_value": "梅州", + "is_global": False, + "can_manage": False, + "is_area_admin": False, + }, + "梅州", + "MZ", + ) + + +def test_contract_template_detail_binds_expanding_scope_params(): + service = ContractTemplateServiceImpl() + fake_session = _FakeSession() + + async def noop_ensure_schema(session): + return None + + async def fake_user_context(current_user_id, session): + return { + "id": current_user_id, + "area": "梅州", + "tenant_code": "MZ", + "tenant_name": "梅州", + "tenant_scope_value": "梅州", + "is_global": False, + "can_manage": True, + "is_area_admin": True, + } + + service._ensureContractTemplateSchema = noop_ensure_schema + service._getCurrentUserContext = fake_user_context + + with patch( + "fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl.GetAsyncSession", + return_value=_FakeSessionContext(fake_session), + ): + result = asyncio.run(service.GetTemplateDetail(32, 5)) + + assert result is None + assert fake_session.executed_sql._bindparams["visible_tenant_codes"].expanding is True + assert fake_session.executed_sql._bindparams["visible_regions"].expanding is True + assert fake_session.executed_params["visible_tenant_codes"] == ["PROVINCIAL", "PUBLIC", "MZ"] + assert fake_session.executed_params["visible_regions"] == ["省级", "公共", "梅州"] + + +def test_contract_template_detail_permission_does_not_fallback_to_list_permission(): + from fastapi_modules.fastapi_leaudit.controllers.contractTemplateController import ContractTemplateController + + controller = ContractTemplateController() + + class FakePermissionService: + async def CheckPermission(self, user_id, permission_key): + return permission_key == "contract_template:list:read" + + controller.PermissionService = FakePermissionService() + + assert asyncio.run(controller._check_permission(5, ["contract_template:detail:read"])) is False -- 2.52.0