From 7c6f1348080fad1bd9c874d0d1647ae9b0eec55e Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Tue, 19 May 2026 22:59:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=90=88=E5=90=8C?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=9C=B0=E5=8C=BA?= =?UTF-8?q?=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fastapi_common_storage/oss_path_utils.py | 4 +- .../controllers/contractTemplateController.py | 48 +- .../domian/Dto/contractTemplateDto.py | 13 + .../domian/vo/contractTemplateVo.py | 11 + .../services/contractTemplateService.py | 23 +- .../impl/contractTemplateServiceImpl.py | 662 +++++++++++++++--- .../services/impl/rbacAdminServiceImpl.py | 3 + scripts/migrate_legacy_contract_templates.py | 2 + scripts/创建sql/schema_contract_templates.sql | 148 +++- .../创建sql/seed_contract_templates_rbac.sql | 20 +- 10 files changed, 803 insertions(+), 131 deletions(-) diff --git a/fastapi_common/fastapi_common_storage/oss_path_utils.py b/fastapi_common/fastapi_common_storage/oss_path_utils.py index 4873a32..5685c0e 100644 --- a/fastapi_common/fastapi_common_storage/oss_path_utils.py +++ b/fastapi_common/fastapi_common_storage/oss_path_utils.py @@ -54,6 +54,7 @@ class OssPathUtils: @staticmethod def BuildContractTemplateKey( + Region: str, CategoryName: str, TemplateCode: str, FileRole: str, @@ -61,12 +62,13 @@ class OssPathUtils: ) -> str: """生成合同模板 object key。""" ext = Path(FileName).suffix or "" + safe_region = OssPathUtils.BuildSafeFileStem(Region or "shared") safe_category = OssPathUtils.BuildSafeFileStem(CategoryName or "uncategorized") safe_template_code = OssPathUtils.BuildSafeFileStem(TemplateCode or "template") safe_stem = OssPathUtils.BuildSafeFileStem(FileName) safe_role = OssPathUtils.BuildSafeFileStem(FileRole or "file") return ( - f"contract-templates/{safe_category}/{safe_template_code}/" + f"contract-templates/{safe_region}/{safe_category}/{safe_template_code}/" f"{safe_role}__{safe_stem}{ext}" ) diff --git a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py index 8db98b5..8895bd3 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py @@ -1,11 +1,12 @@ """合同模板控制器。""" -from fastapi import Depends, Query +from fastapi import Depends, File, Form, Query, UploadFile 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.contractTemplateDto import ( + ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, ) @@ -39,6 +40,7 @@ class ContractTemplateController(BaseController): keyword: str | None = Query(None, description="关键词"), category_id: int | None = Query(None, description="分类ID"), category_name: str | None = Query(None, description="分类名称"), + region: str | None = Query(None, description="地区"), file_format: str | None = Query(None, description="文件格式"), is_featured: bool | None = Query(None, description="是否推荐"), page: int = Query(1, ge=1, description="页码"), @@ -53,6 +55,7 @@ class ContractTemplateController(BaseController): keyword=keyword, category_id=category_id, category_name=category_name, + region=region, file_format=file_format, is_featured=is_featured, page=page, @@ -60,7 +63,32 @@ class ContractTemplateController(BaseController): sort_by=sort_by, sort_order=sort_order, ) - data = await self.ContractTemplateService.ListTemplates(query) + data = await self.ContractTemplateService.ListTemplates(query, int(payload["user_id"])) + return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) + + @self.router.post("") + async def CreateContractTemplate( + title: str = Form(...), + template_code: str = Form(...), + category_id: int = Form(...), + region: 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:create:write"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有上传合同模板权限", "data": None}) + body = ContractTemplateCreateDTO( + title=title, + template_code=template_code, + category_id=category_id, + region=region, + description=description, + is_featured=is_featured, + ) + data = await self.ContractTemplateService.CreateTemplate(body, file, pdf_file, int(payload["user_id"])) return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) @self.router.get("/search") @@ -68,6 +96,7 @@ class ContractTemplateController(BaseController): q: str = Query(..., min_length=1, description="搜索关键词"), category_id: int | None = Query(None, description="分类ID"), category_name: str | None = Query(None, description="分类名称"), + region: str | None = Query(None, description="地区"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(12, ge=1, le=200, description="分页大小"), sort_by: str = Query("updated_at", description="排序字段"), @@ -80,12 +109,13 @@ class ContractTemplateController(BaseController): q=q, category_id=category_id, category_name=category_name, + region=region, page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order, ) - data = await self.ContractTemplateService.SearchTemplates(query) + data = await self.ContractTemplateService.SearchTemplates(query, int(payload["user_id"])) return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) @self.router.get("/{TemplateId}") @@ -95,11 +125,21 @@ class ContractTemplateController(BaseController): ): if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read", "contract_template:list:read"]): return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None}) - data = await self.ContractTemplateService.GetTemplateDetail(TemplateId) + 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.delete("/{TemplateId}") + async def DeleteContractTemplate( + TemplateId: int, + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["contract_template:delete:delete"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除合同模板权限", "data": None}) + await self.ContractTemplateService.DeleteTemplate(TemplateId, int(payload["user_id"])) + return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": 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): diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py index 1ebc40d..795ac4c 100644 --- a/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py @@ -7,6 +7,7 @@ class ContractTemplateListQueryDTO(BaseModel): keyword: str | None = Field(None, description="关键词") category_id: int | None = Field(None, description="分类ID") category_name: str | None = Field(None, description="分类名称") + region: str | None = Field(None, description="地区") file_format: str | None = Field(None, description="文件格式") is_featured: bool | None = Field(None, description="是否推荐") page: int = Field(1, ge=1, description="页码") @@ -21,7 +22,19 @@ class ContractTemplateSearchQueryDTO(BaseModel): q: str = Field(..., min_length=1, description="搜索关键词") category_id: int | None = Field(None, description="分类ID") category_name: str | None = Field(None, description="分类名称") + region: str | None = Field(None, description="地区") page: int = Field(1, ge=1, description="页码") page_size: int = Field(12, ge=1, le=200, description="分页大小") sort_by: str = Field("updated_at", description="排序字段") sort_order: str = Field("desc", description="排序方向") + + +class ContractTemplateCreateDTO(BaseModel): + """合同模板上传参数。""" + + title: str = Field(..., min_length=1, max_length=200, description="模板标题") + template_code: str = Field(..., min_length=1, max_length=50, description="模板编码") + category_id: int = Field(..., description="分类ID") + region: str | None = Field(None, description="所属地区") + description: str | None = Field(None, description="模板简介") + is_featured: bool = Field(False, description="是否推荐") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py index d19641f..6984e28 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py @@ -23,10 +23,17 @@ class ContractTemplateListItemVO(BaseModel): category_name: str | None = Field(None, description="分类名称") category_icon: str | None = Field(None, description="分类图标") description: str | None = Field(None, description="模板简介") + region: str = Field(..., description="所属地区") file_path: str | None = Field(None, description="原始模板文件路径") pdf_file_path: str | None = Field(None, description="PDF 预览文件路径") file_format: str = Field(..., description="文件格式") + original_file_name: str | None = Field(None, description="原始上传文件名") + mime_type: str | None = Field(None, description="MIME 类型") + file_size: int = Field(0, description="文件大小") + pdf_file_size: int | None = Field(None, description="预览 PDF 文件大小") is_featured: bool = Field(False, description="是否推荐") + created_by: int | None = Field(None, description="创建人") + updated_by: int | None = Field(None, description="更新人") created_at: str | None = Field(None, description="创建时间") updated_at: str | None = Field(None, description="更新时间") @@ -48,6 +55,10 @@ class ContractTemplateDetailVO(ContractTemplateListItemVO): placeholder_schema: dict | None = Field(None, description="模板占位符结构") +class ContractTemplateCreateVO(ContractTemplateDetailVO): + """合同模板上传结果。""" + + class ContractTemplateSearchCategoryVO(BaseModel): """搜索结果分类统计。""" diff --git a/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py b/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py index b864b82..fbc3c89 100644 --- a/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py +++ b/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py @@ -1,11 +1,14 @@ from abc import ABC, abstractmethod +from fastapi import UploadFile from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( + ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateCategoryVO, + ContractTemplateCreateVO, ContractTemplateDetailVO, ContractTemplatePageVO, ContractTemplateSearchResultVO, @@ -20,13 +23,27 @@ class IContractTemplateService(ABC): ... @abstractmethod - async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO: + async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO: ... @abstractmethod - async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: + async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO: ... @abstractmethod - async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None: + async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | None: + ... + + @abstractmethod + async def CreateTemplate( + self, + Body: ContractTemplateCreateDTO, + 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 f7c2dfe..dfd12e1 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py @@ -1,16 +1,24 @@ from __future__ import annotations +import mimetypes +from pathlib import Path from typing import Any +from fastapi import UploadFile from sqlalchemy import bindparam, text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils +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.contractTemplateDto import ( + ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateCategoryVO, + ContractTemplateCreateVO, ContractTemplateDetailVO, ContractTemplateListItemVO, ContractTemplatePageVO, @@ -18,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateSearchResultVO, ) from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService +from fastapi_modules.fastapi_leaudit.services.ossService import IOssService +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl _ALLOWED_SORT_FIELDS = { "id": "t.id", @@ -30,8 +40,14 @@ _ALLOWED_SORT_FIELDS = { class ContractTemplateServiceImpl(IContractTemplateService): """合同模板服务实现。""" + def __init__(self, OssService: IOssService | None = None) -> None: + self.OssService = OssService or OssServiceImpl() + async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]: count_select = "COUNT(t.id)::int AS template_count" if WithTemplateCount else "0::int AS template_count" + filters = ["c.deleted_at IS NULL"] + if not IncludeDisabled: + filters.append("1=1") sql = text( f""" SELECT @@ -45,64 +61,76 @@ class ContractTemplateServiceImpl(IContractTemplateService): FROM contract_categories c LEFT JOIN contract_templates t ON t.category_id = c.id - WHERE 1=1 + AND t.deleted_at IS NULL + WHERE {' AND '.join(filters)} GROUP BY c.id, c.name, c.icon, c.description, c.sort_order ORDER BY COALESCE(c.sort_order, 0) ASC, c.name ASC """ ) async with GetAsyncSession() as session: + await self._ensureContractTemplateSchema(session) rows = (await session.execute(sql)).mappings().all() return [self._to_category_vo(row) for row in rows] - async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO: - where_clause, params, needs_category_name_filter = self._build_template_filters( - keyword=Query.keyword, - category_id=Query.category_id, - category_name=Query.category_name, - file_format=Query.file_format, - is_featured=Query.is_featured, - ) - order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc") - offset = max(Query.page - 1, 0) * Query.page_size - params.update({"limit": Query.page_size, "offset": offset}) - - from_sql = self._build_template_from_sql(needs_category_name_filter) - - count_sql = text( - f""" - SELECT COUNT(*) - {from_sql} - WHERE {where_clause} - """ - ) - list_sql = text( - f""" - SELECT - t.id, - t.template_code, - t.title, - t.category_id, - c.name AS category_name, - c.icon AS category_icon, - c.description AS category_description, - t.description, - t.file_path, - t.pdf_file_path, - t.file_format, - COALESCE(t.is_featured, FALSE) AS is_featured, - t.created_at, - t.updated_at - {from_sql} - WHERE {where_clause} - ORDER BY {order_sql} - LIMIT :limit OFFSET :offset - """ - ) - count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params) - + async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO: async with GetAsyncSession() as session: + await self._ensureContractTemplateSchema(session) + currentUser = await self._getCurrentUserContext(CurrentUserId, session) + where_clause, params, needs_category_name_filter = self._build_template_filters( + keyword=Query.keyword, + category_id=Query.category_id, + category_name=Query.category_name, + region=Query.region, + file_format=Query.file_format, + is_featured=Query.is_featured, + currentUser=currentUser, + ) + order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc") + offset = max(Query.page - 1, 0) * Query.page_size + params.update({"limit": Query.page_size, "offset": offset}) + + from_sql = self._build_template_from_sql(needs_category_name_filter) + + count_sql = text( + f""" + SELECT COUNT(*) + {from_sql} + WHERE {where_clause} + """ + ) + list_sql = text( + f""" + SELECT + t.id, + t.template_code, + t.title, + t.category_id, + c.name AS category_name, + c.icon AS category_icon, + c.description AS category_description, + t.region, + t.description, + t.file_path, + t.pdf_file_path, + t.file_format, + t.original_file_name, + t.mime_type, + COALESCE(t.file_size, 0) AS file_size, + t.pdf_file_size, + COALESCE(t.is_featured, FALSE) AS is_featured, + t.created_by, + t.updated_by, + t.created_at, + t.updated_at + {from_sql} + WHERE {where_clause} + ORDER BY {order_sql} + LIMIT :limit OFFSET :offset + """ + ) + count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params) total = int((await session.execute(count_sql, params)).scalar_one()) rows = (await session.execute(list_sql, params)).mappings().all() @@ -114,18 +142,19 @@ class ContractTemplateServiceImpl(IContractTemplateService): templates=[self._to_list_item_vo(row) for row in rows], ) - async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: + async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO: list_query = ContractTemplateListQueryDTO( keyword=Query.q, category_id=Query.category_id, category_name=Query.category_name, + region=Query.region, page=Query.page, page_size=Query.page_size, sort_by=Query.sort_by, sort_order=Query.sort_order, ) - page_result = await self.ListTemplates(list_query) - category_stats = await self._load_search_category_stats(Query.q) + page_result = await self.ListTemplates(list_query, CurrentUserId) + category_stats = await self._load_search_category_stats(Query.q, Query.region, CurrentUserId) return ContractTemplateSearchResultVO( total=page_result.total, @@ -136,71 +165,310 @@ class ContractTemplateServiceImpl(IContractTemplateService): category_stats=category_stats, ) - async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None: - sql = text( - """ - SELECT - t.id, - t.template_code, - t.title, - t.category_id, - c.name AS category_name, - c.icon AS category_icon, - c.description AS category_description, - t.description, - t.file_path, - t.pdf_file_path, - t.file_format, - COALESCE(t.is_featured, FALSE) AS is_featured, - t.created_at, - t.updated_at - FROM contract_templates t - LEFT JOIN contract_categories c ON c.id = t.category_id - WHERE t.id = :template_id - LIMIT 1 - """ - ) - + async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | None: async with GetAsyncSession() as session: - row = (await session.execute(sql, {"template_id": TemplateId})).mappings().first() + 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) + sql = text( + f""" + SELECT + t.id, + t.template_code, + t.title, + t.category_id, + c.name AS category_name, + c.icon AS category_icon, + c.description AS category_description, + t.region, + t.description, + t.file_path, + t.pdf_file_path, + t.file_format, + t.original_file_name, + t.mime_type, + COALESCE(t.file_size, 0) AS file_size, + t.pdf_file_size, + COALESCE(t.is_featured, FALSE) AS is_featured, + t.created_by, + t.updated_by, + t.created_at, + t.updated_at + FROM contract_templates t + LEFT JOIN contract_categories c ON c.id = t.category_id + WHERE t.id = :template_id + AND t.deleted_at IS NULL + AND c.deleted_at IS NULL + AND {' AND '.join(scope_filters)} + LIMIT 1 + """ + ) + row = (await session.execute(sql, params)).mappings().first() if not row: return None return self._to_detail_vo(row) + async def CreateTemplate( + self, + Body: ContractTemplateCreateDTO, + 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) + resolvedRegion = self._resolve_upload_region(currentUser, Body.region) + 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 region = :region + AND template_code = :template_code + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"region": resolvedRegion, "template_code": normalizedCode}, + ) + ).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", + ) + + createdRow = ( + await session.execute( + text( + """ + INSERT INTO contract_templates ( + template_code, + title, + category_id, + region, + description, + file_path, + pdf_file_path, + file_format, + original_file_name, + mime_type, + file_size, + pdf_file_size, + is_featured, + created_by, + updated_by, + created_at, + updated_at + ) VALUES ( + :template_code, + :title, + :category_id, + :region, + :description, + :file_path, + :pdf_file_path, + :file_format, + :original_file_name, + :mime_type, + :file_size, + :pdf_file_size, + :is_featured, + :created_by, + :updated_by, + NOW(), + NOW() + ) + RETURNING id + """ + ), + { + "template_code": normalizedCode, + "title": normalizedTitle, + "category_id": Body.category_id, + "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, + "created_by": CurrentUserId, + "updated_by": CurrentUserId, + }, + ) + ).mappings().first() + await session.commit() + + if not createdRow: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建失败") + + detail = await self.GetTemplateDetail(int(createdRow["id"]), 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) + 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, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权删除") + + await session.execute( + text( + """ + UPDATE contract_templates + SET deleted_at = NOW(), + updated_at = NOW(), + updated_by = :updated_by + WHERE id = :template_id + AND deleted_at IS NULL + """ + ), + {"template_id": TemplateId, "updated_by": CurrentUserId}, + ) + await session.commit() + async def _load_search_category_stats( self, keyword: str, + requestedRegion: str | None, + CurrentUserId: int, ) -> list[ContractTemplateSearchCategoryVO]: clean_keyword = (keyword or "").strip() if not clean_keyword: return [] - filters = [ - "(" - "t.title ILIKE :keyword " - "OR COALESCE(t.description, '') ILIKE :keyword " - "OR COALESCE(t.template_code, '') ILIKE :keyword " - "OR COALESCE(c.name, '') ILIKE :keyword" - ")" - ] - params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"} - - sql = text( - f""" - SELECT - c.id, - c.name, - COUNT(t.id)::int AS search_count - FROM contract_categories c - LEFT JOIN contract_templates t ON t.category_id = c.id - WHERE {' AND '.join(filters)} - GROUP BY c.id, c.name - ORDER BY c.name ASC - """ - ) - async with GetAsyncSession() as session: + await self._ensureContractTemplateSchema(session) + currentUser = await self._getCurrentUserContext(CurrentUserId, session) + params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"} + scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=requestedRegion) + filters = [ + "c.deleted_at IS NULL", + "t.deleted_at IS NULL", + "(" + "t.title ILIKE :keyword " + "OR COALESCE(t.description, '') ILIKE :keyword " + "OR COALESCE(t.template_code, '') ILIKE :keyword " + "OR COALESCE(c.name, '') ILIKE :keyword" + ")", + *scope_filters, + ] + + sql = text( + f""" + SELECT + c.id, + c.name, + COUNT(t.id)::int AS search_count + FROM contract_categories c + LEFT JOIN contract_templates t ON t.category_id = c.id + WHERE {' AND '.join(filters)} + GROUP BY c.id, c.name + ORDER BY c.name ASC + """ + ) rows = (await session.execute(sql, params)).mappings().all() return [ @@ -218,13 +486,17 @@ class ContractTemplateServiceImpl(IContractTemplateService): keyword: str | None, category_id: int | None, category_name: str | None, + region: str | None, file_format: str | None, is_featured: bool | None, + currentUser: dict[str, Any], ) -> tuple[str, dict[str, Any], bool]: - filters = ["1=1"] + filters = ["t.deleted_at IS NULL", "c.deleted_at IS NULL"] params: dict[str, Any] = {} needs_category_name_filter = False + filters.extend(self._build_template_scope_filters(currentUser, params, region)) + if category_id is not None: filters.append("t.category_id = :category_id") params["category_id"] = category_id @@ -234,7 +506,7 @@ class ContractTemplateServiceImpl(IContractTemplateService): needs_category_name_filter = True if file_format: - filters.append("t.file_format = :file_format") + filters.append("LOWER(t.file_format) = :file_format") params["file_format"] = file_format.strip().lower() if is_featured is not None: @@ -271,8 +543,8 @@ class ContractTemplateServiceImpl(IContractTemplateService): def _bind_expanding(self, *sql_objects_and_params: Any): sql_objects = list(sql_objects_and_params[:-1]) params = sql_objects_and_params[-1] - if "category_ids" in params: - sql_objects = [sql.bindparams(bindparam("category_ids", expanding=True)) for sql in sql_objects] + if "visible_regions" in params: + sql_objects = [sql.bindparams(bindparam("visible_regions", expanding=True)) for sql in sql_objects] return tuple(sql_objects) def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO: @@ -295,10 +567,17 @@ class ContractTemplateServiceImpl(IContractTemplateService): category_name=row.get("category_name"), category_icon=row.get("category_icon"), description=row.get("description"), + region=str(row.get("region") or "省级"), file_path=row.get("file_path"), pdf_file_path=row.get("pdf_file_path"), file_format=str(row.get("file_format") or ""), + original_file_name=row.get("original_file_name"), + mime_type=row.get("mime_type"), + file_size=int(row.get("file_size") or 0), + pdf_file_size=int(row["pdf_file_size"]) if row.get("pdf_file_size") is not None else None, is_featured=bool(row.get("is_featured", False)), + created_by=int(row["created_by"]) if row.get("created_by") is not None else None, + updated_by=int(row["updated_by"]) if row.get("updated_by") is not None else None, created_at=self._stringify_time(row.get("created_at")), updated_at=self._stringify_time(row.get("updated_at")), ) @@ -315,3 +594,178 @@ class ContractTemplateServiceImpl(IContractTemplateService): if value is None: return None return str(value) + + def _build_template_scope_filters( + self, + currentUser: dict[str, Any], + params: dict[str, Any], + requestedRegion: str | None, + writable: bool = False, + ) -> list[str]: + requested = (requestedRegion or "").strip() + area = str(currentUser["area"] or "").strip() + + if currentUser["is_global"]: + if requested: + params["requested_region"] = requested + return ["t.region = :requested_region"] + return ["1=1"] + + if writable: + if not area: + return ["1=0"] + if requested and requested != area: + return ["1=0"] + params["scope_region"] = area + return ["t.region = :scope_region"] + + if currentUser["can_manage"]: + if not area: + return ["1=0"] + if requested: + if requested == "省级": + params["requested_region"] = requested + return ["t.region = :requested_region"] + if requested != area: + return ["1=0"] + params["requested_region"] = requested + return ["t.region = :requested_region"] + params["visible_regions"] = ["省级", area] + return ["t.region IN :visible_regions"] + + if requested: + if requested == "省级": + params["requested_region"] = requested + return ["t.region = :requested_region"] + if area and requested == area: + params["requested_region"] = requested + return ["t.region = :requested_region"] + return ["1=0"] + + if area: + params["visible_regions"] = ["省级", area] + return ["t.region IN :visible_regions"] + params["requested_region"] = "省级" + return ["t.region = :requested_region"] + + async def _getCurrentUserContext(self, CurrentUserId: int, session=None) -> dict[str, Any]: + own_session = False + if session is None: + own_session = True + session_cm = GetAsyncSession() + session = await session_cm.__aenter__() + try: + row = ( + await session.execute( + text( + """ + SELECT + u.id, + COALESCE(u.area, '') AS area, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + GROUP BY u.id, u.area + """ + ), + {"user_id": CurrentUserId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") + return { + "id": int(row["id"]), + "area": str(row["area"] or ""), + "is_global": bool(row["is_global"]), + "can_manage": bool(row["can_manage"]), + } + finally: + if own_session: + await session_cm.__aexit__(None, None, None) + + def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None) -> str: + area = str(currentUser["area"] or "").strip() + if currentUser["can_manage"]: + if not area: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前管理员账号未配置地区,无法上传合同模板") + return area + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有上传合同模板权限") + + async def _ensureContractTemplateSchema(self, session) -> None: + statements = [ + """ + ALTER TABLE contract_categories + ADD COLUMN IF NOT EXISTS created_by BIGINT + """, + """ + ALTER TABLE contract_categories + ADD COLUMN IF NOT EXISTS updated_by BIGINT + """, + """ + ALTER TABLE contract_categories + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS region VARCHAR(50) NOT NULL DEFAULT '省级' + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500) + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS original_file_name VARCHAR(500) NOT NULL DEFAULT '' + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS mime_type VARCHAR(200) + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS file_size BIGINT NOT NULL DEFAULT 0 + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS pdf_file_size BIGINT + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS created_by BIGINT + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS updated_by BIGINT + """, + """ + ALTER TABLE contract_templates + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ + """, + ] + for statement in statements: + await session.execute(text(statement)) + await session.execute( + text( + """ + UPDATE contract_templates + SET region = '省级' + WHERE region IS NULL OR BTRIM(region) = '' + """ + ) + ) + await session.execute( + text( + """ + UPDATE contract_templates + SET original_file_name = COALESCE(NULLIF(original_file_name, ''), title || CASE + WHEN file_format IS NOT NULL AND BTRIM(file_format) <> '' THEN '.' || LOWER(file_format) + ELSE '' + END) + WHERE original_file_name IS NULL OR BTRIM(original_file_name) = '' + """ + ) + ) + await session.commit() diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index eac192c..8326788 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -225,6 +225,9 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "contract_template:list:read", "display_name": "查看合同模板列表", "module": "contract_template", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates", "route_path": "/contract-template/list"}, {"permission_key": "contract_template:search:read", "display_name": "搜索合同模板", "module": "contract_template", "resource": "search", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates/search", "route_path": "/contract-template/search"}, {"permission_key": "contract_template:detail:read", "display_name": "查看合同模板详情", "module": "contract_template", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"}, + {"permission_key": "contract_template:create:write", "display_name": "上传合同模板", "module": "contract_template", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/contract-templates", "route_path": "/contract-template/list"}, + {"permission_key": "contract_template:update:write", "display_name": "更新合同模板", "module": "contract_template", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"}, + {"permission_key": "contract_template:delete:delete", "display_name": "删除合同模板", "module": "contract_template", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"}, {"permission_key": "evaluation_group:list:read", "display_name": "评查点分组列表", "module": "evaluation_group", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"}, {"permission_key": "evaluation_group:create:write", "display_name": "创建评查点分组", "module": "evaluation_group", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"}, {"permission_key": "evaluation_group:update:write", "display_name": "更新评查点分组与绑定", "module": "evaluation_group", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"}, diff --git a/scripts/migrate_legacy_contract_templates.py b/scripts/migrate_legacy_contract_templates.py index 4d92ee9..9be4108 100644 --- a/scripts/migrate_legacy_contract_templates.py +++ b/scripts/migrate_legacy_contract_templates.py @@ -164,12 +164,14 @@ def resolve_pdf_path(template: LegacyTemplate, object_keys: set[str]) -> str: def build_new_object_keys(template: LegacyTemplate, docx_path: str, pdf_path: str) -> tuple[str, str]: docx_key = OssPathUtils.BuildContractTemplateKey( + Region="省级", CategoryName=template.category_name, TemplateCode=template.template_code, FileRole="source", FileName=Path(docx_path).name, ) pdf_key = OssPathUtils.BuildContractTemplateKey( + Region="省级", CategoryName=template.category_name, TemplateCode=template.template_code, FileRole="preview", diff --git a/scripts/创建sql/schema_contract_templates.sql b/scripts/创建sql/schema_contract_templates.sql index 0955d65..8a635b8 100644 --- a/scripts/创建sql/schema_contract_templates.sql +++ b/scripts/创建sql/schema_contract_templates.sql @@ -3,9 +3,9 @@ BEGIN; -- ============================================================================ -- LeAudit Platform Contract Template Schema -- 目标: --- 1. 在主库 leaudit_platform 创建合同模板分类表 --- 2. 在主库 leaudit_platform 创建合同模板表 --- 3. 补齐索引与基础约束 +-- 1. 在主库 leaudit_platform 创建 / 升级合同模板分类表 +-- 2. 在主库 leaudit_platform 创建 / 升级合同模板主表 +-- 3. 补齐地区字段、审计字段、软删除字段、索引与 updated_at 触发器 -- 说明: -- - 本脚本不依赖旧库 docauditai -- - 幂等脚本,可重复执行 @@ -17,50 +17,168 @@ CREATE TABLE IF NOT EXISTS public.contract_categories ( icon VARCHAR(100) NULL, description TEXT NULL, sort_order INTEGER NOT NULL DEFAULT 0, + created_by BIGINT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_by BIGINT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_categories_name - ON public.contract_categories(name); +ALTER TABLE public.contract_categories + ADD COLUMN IF NOT EXISTS icon VARCHAR(100), + ADD COLUMN IF NOT EXISTS description TEXT, + ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS created_by BIGINT, + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS updated_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS public.contract_templates ( id BIGSERIAL PRIMARY KEY, template_code VARCHAR(50) NOT NULL, title VARCHAR(200) NOT NULL, category_id INTEGER NOT NULL REFERENCES public.contract_categories(id) ON DELETE RESTRICT, + region VARCHAR(50) NOT NULL DEFAULT '省级', description TEXT NULL, file_path VARCHAR(500) NULL, + pdf_file_path VARCHAR(500) NULL, file_format VARCHAR(10) NOT NULL, + original_file_name VARCHAR(500) NOT NULL DEFAULT '', + mime_type VARCHAR(200) NULL, + file_size BIGINT NOT NULL DEFAULT 0, + pdf_file_size BIGINT NULL, is_featured BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by BIGINT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - pdf_file_path VARCHAR(500) NULL + deleted_at TIMESTAMPTZ NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_templates_code - ON public.contract_templates(template_code); +ALTER TABLE public.contract_templates + ADD COLUMN IF NOT EXISTS region VARCHAR(50) NOT NULL DEFAULT '省级', + ADD COLUMN IF NOT EXISTS description TEXT, + ADD COLUMN IF NOT EXISTS file_path VARCHAR(500), + ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500), + ADD COLUMN IF NOT EXISTS file_format VARCHAR(10), + ADD COLUMN IF NOT EXISTS original_file_name VARCHAR(500) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS mime_type VARCHAR(200), + ADD COLUMN IF NOT EXISTS file_size BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS pdf_file_size BIGINT, + ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS created_by BIGINT, + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS updated_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -CREATE INDEX IF NOT EXISTS idx_contract_templates_category_id - ON public.contract_templates(category_id); +UPDATE public.contract_templates +SET region = '省级' +WHERE region IS NULL OR BTRIM(region) = ''; -CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_at - ON public.contract_templates(updated_at DESC); +UPDATE public.contract_templates +SET original_file_name = COALESCE(NULLIF(original_file_name, ''), title || CASE + WHEN file_format IS NOT NULL AND BTRIM(file_format) <> '' THEN '.' || LOWER(file_format) + ELSE '' +END) +WHERE original_file_name IS NULL OR BTRIM(original_file_name) = ''; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = 'idx_contract_categories_name' + ) THEN + EXECUTE 'DROP INDEX IF EXISTS public.idx_contract_categories_name'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = 'idx_contract_templates_code' + ) THEN + EXECUTE 'DROP INDEX IF EXISTS public.idx_contract_templates_code'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_categories_name_active + ON public.contract_categories(name) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_contract_categories_sort_active + ON public.contract_categories(sort_order) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_region_code_active + ON public.contract_templates(region, template_code) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_contract_templates_region_active + ON public.contract_templates(region) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_contract_templates_category_active + ON public.contract_templates(category_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_active + ON public.contract_templates(updated_at DESC) + WHERE deleted_at IS NULL; + +CREATE OR REPLACE FUNCTION update_contract_templates_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY['contract_categories', 'contract_templates'] + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I', t, t); + EXECUTE format( + 'CREATE TRIGGER trg_%s_updated_at + BEFORE UPDATE ON %I + FOR EACH ROW EXECUTE FUNCTION update_contract_templates_updated_at()', + t, t + ); + END LOOP; +END; +$$; COMMENT ON TABLE public.contract_categories IS '合同模板分类表'; COMMENT ON COLUMN public.contract_categories.name IS '分类名称'; COMMENT ON COLUMN public.contract_categories.icon IS '分类图标'; COMMENT ON COLUMN public.contract_categories.description IS '分类描述'; COMMENT ON COLUMN public.contract_categories.sort_order IS '排序值'; +COMMENT ON COLUMN public.contract_categories.created_by IS '创建人'; +COMMENT ON COLUMN public.contract_categories.updated_by IS '更新人'; +COMMENT ON COLUMN public.contract_categories.deleted_at IS '软删除时间'; COMMENT ON TABLE public.contract_templates IS '合同模板主表'; COMMENT ON COLUMN public.contract_templates.template_code IS '模板编码'; COMMENT ON COLUMN public.contract_templates.title IS '模板标题'; COMMENT ON COLUMN public.contract_templates.category_id IS '所属分类ID'; +COMMENT ON COLUMN public.contract_templates.region IS '所属地区,省级模板使用“省级”'; COMMENT ON COLUMN public.contract_templates.description IS '模板描述'; COMMENT ON COLUMN public.contract_templates.file_path IS '源模板文件路径'; -COMMENT ON COLUMN public.contract_templates.file_format IS '文件格式'; -COMMENT ON COLUMN public.contract_templates.is_featured IS '是否推荐模板'; COMMENT ON COLUMN public.contract_templates.pdf_file_path IS 'PDF预览文件路径'; +COMMENT ON COLUMN public.contract_templates.file_format IS '文件格式'; +COMMENT ON COLUMN public.contract_templates.original_file_name IS '原始上传文件名'; +COMMENT ON COLUMN public.contract_templates.mime_type IS '文件 MIME 类型'; +COMMENT ON COLUMN public.contract_templates.file_size IS '主文件大小(字节)'; +COMMENT ON COLUMN public.contract_templates.pdf_file_size IS '预览 PDF 文件大小(字节)'; +COMMENT ON COLUMN public.contract_templates.is_featured IS '是否推荐模板'; +COMMENT ON COLUMN public.contract_templates.created_by IS '创建人'; +COMMENT ON COLUMN public.contract_templates.updated_by IS '更新人'; +COMMENT ON COLUMN public.contract_templates.deleted_at IS '软删除时间'; COMMIT; diff --git a/scripts/创建sql/seed_contract_templates_rbac.sql b/scripts/创建sql/seed_contract_templates_rbac.sql index 07f24cb..bc22c4b 100644 --- a/scripts/创建sql/seed_contract_templates_rbac.sql +++ b/scripts/创建sql/seed_contract_templates_rbac.sql @@ -3,8 +3,8 @@ BEGIN; -- ============================================================================ -- LeAudit Platform Contract Template RBAC Seed -- 目标: --- 1. 补齐合同模板读权限 --- 2. 给 super_admin / provincial_admin / admin 分配模板读权限 +-- 1. 补齐合同模板读写删权限 +-- 2. 给 super_admin / provincial_admin / admin 分配模板权限 -- 说明: -- - 依赖 user_rbac_schema_patch.sql -- - 依赖合同模板前端路由已存在于 sys_routes @@ -62,7 +62,10 @@ FROM ( VALUES ('contract_template:list:read', 'contract_template', 'list', 'read', '查看合同模板列表', '查看合同模板列表', '/contract-template/list', 310, '/api/v3/contract-templates', 'GET'), ('contract_template:search:read', 'contract_template', 'search', 'read', '搜索合同模板', '搜索合同模板', '/contract-template/search', 311, '/api/v3/contract-templates/search','GET'), - ('contract_template:detail:read', 'contract_template', 'detail', 'read', '查看合同模板详情', '查看合同模板详情', '/contract-template/list', 312, '/api/v3/contract-templates/{id}', 'GET') + ('contract_template:detail:read', 'contract_template', 'detail', 'read', '查看合同模板详情', '查看合同模板详情', '/contract-template/list', 312, '/api/v3/contract-templates/{id}', 'GET'), + ('contract_template:create:write', 'contract_template', 'create', 'write', '上传合同模板', '上传合同模板', '/contract-template/list', 313, '/api/v3/contract-templates', 'POST'), + ('contract_template:update:write', 'contract_template', 'update', 'write', '更新合同模板', '更新合同模板', '/contract-template/list', 314, '/api/v3/contract-templates/{id}', 'PUT'), + ('contract_template:delete:delete', 'contract_template', 'delete', 'delete', '删除合同模板', '删除合同模板', '/contract-template/list', 315, '/api/v3/contract-templates/{id}', 'DELETE') ) AS seed( permission_key, module, @@ -105,14 +108,23 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('super_admin', 'contract_template:list:read', 'GRANT', 'ALL'), ('super_admin', 'contract_template:search:read', 'GRANT', 'ALL'), ('super_admin', 'contract_template:detail:read', 'GRANT', 'ALL'), + ('super_admin', 'contract_template:create:write', 'GRANT', 'ALL'), + ('super_admin', 'contract_template:update:write', 'GRANT', 'ALL'), + ('super_admin', 'contract_template:delete:delete', 'GRANT', 'ALL'), ('provincial_admin', 'contract_template:list:read', 'GRANT', 'ALL'), ('provincial_admin', 'contract_template:search:read', 'GRANT', 'ALL'), ('provincial_admin', 'contract_template:detail:read', 'GRANT', 'ALL'), + ('provincial_admin', 'contract_template:create:write', 'GRANT', 'ALL'), + ('provincial_admin', 'contract_template:update:write', 'GRANT', 'ALL'), + ('provincial_admin', 'contract_template:delete:delete', 'GRANT', 'ALL'), ('admin', 'contract_template:list:read', 'GRANT', 'DEPT'), ('admin', 'contract_template:search:read', 'GRANT', 'DEPT'), - ('admin', 'contract_template:detail:read', 'GRANT', 'DEPT') + ('admin', 'contract_template:detail:read', 'GRANT', 'DEPT'), + ('admin', 'contract_template:create:write', 'GRANT', 'DEPT'), + ('admin', 'contract_template:update:write', 'GRANT', 'DEPT'), + ('admin', 'contract_template:delete:delete', 'GRANT', 'DEPT') ) INSERT INTO role_permissions ( role_id,