diff --git a/fastapi_common/fastapi_common_storage/oss_path_utils.py b/fastapi_common/fastapi_common_storage/oss_path_utils.py index c542198..4873a32 100644 --- a/fastapi_common/fastapi_common_storage/oss_path_utils.py +++ b/fastapi_common/fastapi_common_storage/oss_path_utils.py @@ -52,6 +52,24 @@ class OssPathUtils: prefix = f"{Region}/" if Region else "" return f"{prefix}rules/{RuleType}/{VersionNo}/validation_report.json" + @staticmethod + def BuildContractTemplateKey( + CategoryName: str, + TemplateCode: str, + FileRole: str, + FileName: str, + ) -> str: + """生成合同模板 object key。""" + ext = Path(FileName).suffix or "" + 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"{safe_role}__{safe_stem}{ext}" + ) + @staticmethod def BuildSafeFileStem(FileName: str) -> str: """生成适合放进 object key 的可读文件名主体。""" diff --git a/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py new file mode 100644 index 0000000..8db98b5 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py @@ -0,0 +1,107 @@ +"""合同模板控制器。""" + +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.contractTemplateDto import ( + ContractTemplateListQueryDTO, + ContractTemplateSearchQueryDTO, +) +from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService +from fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl import ContractTemplateServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService + + +class ContractTemplateController(BaseController): + """合同模板控制器。""" + + def __init__(self): + super().__init__(prefix="/v3/contract-templates", tags=["合同模板"]) + self.ContractTemplateService: IContractTemplateService = ContractTemplateServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.get("/categories") + async def ListContractTemplateCategories( + include_disabled: bool = Query(False, description="是否包含禁用分类"), + with_template_count: bool = Query(True, description="是否附带模板数量"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["contract_template:list:read", "contract_template:search:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板分类权限", "data": None}) + data = await self.ContractTemplateService.ListCategories(include_disabled, with_template_count) + return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": [item.model_dump() for item in data]}) + + @self.router.get("") + async def ListContractTemplates( + keyword: str | None = Query(None, description="关键词"), + category_id: int | None = Query(None, description="分类ID"), + category_name: 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="页码"), + page_size: int = Query(12, ge=1, le=200, description="分页大小"), + sort_by: str = Query("updated_at", description="排序字段"), + sort_order: str = Query("desc", description="排序方向"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["contract_template:list:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板列表权限", "data": None}) + query = ContractTemplateListQueryDTO( + keyword=keyword, + category_id=category_id, + category_name=category_name, + file_format=file_format, + is_featured=is_featured, + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) + data = await self.ContractTemplateService.ListTemplates(query) + return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) + + @self.router.get("/search") + async def SearchContractTemplates( + q: str = Query(..., min_length=1, description="搜索关键词"), + category_id: int | None = Query(None, description="分类ID"), + category_name: 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="排序字段"), + sort_order: str = Query("desc", description="排序方向"), + payload: dict = Depends(verify_access_token), + ): + if not await self._check_permission(int(payload["user_id"]), ["contract_template:search:read"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有搜索合同模板权限", "data": None}) + query = ContractTemplateSearchQueryDTO( + q=q, + category_id=category_id, + category_name=category_name, + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) + data = await self.ContractTemplateService.SearchTemplates(query) + return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()}) + + @self.router.get("/{TemplateId}") + async def GetContractTemplateDetail( + 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"]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None}) + data = await self.ContractTemplateService.GetTemplateDetail(TemplateId) + 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()}) + + 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/domian/Dto/contractTemplateDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py new file mode 100644 index 0000000..1ebc40d --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, Field + + +class ContractTemplateListQueryDTO(BaseModel): + """合同模板列表查询参数。""" + + keyword: str | None = Field(None, description="关键词") + category_id: int | None = Field(None, description="分类ID") + category_name: 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="页码") + page_size: int = Field(12, ge=1, le=200, description="分页大小") + sort_by: str = Field("updated_at", description="排序字段") + sort_order: str = Field("desc", description="排序方向") + + +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="分类名称") + 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="排序方向") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py new file mode 100644 index 0000000..d19641f --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, Field + + +class ContractTemplateCategoryVO(BaseModel): + """合同模板分类。""" + + id: int = Field(..., description="分类ID") + name: str = Field(..., description="分类名称") + icon: str | None = Field(None, description="分类图标") + description: str | None = Field(None, description="分类描述") + sort_order: int = Field(0, description="排序") + template_count: int = Field(0, description="分类下模板数量") + is_enabled: bool = Field(True, description="是否启用") + + +class ContractTemplateListItemVO(BaseModel): + """合同模板列表项。""" + + id: int = Field(..., description="模板ID") + template_code: str = Field(..., description="模板编码") + title: str = Field(..., description="模板标题") + category_id: int = Field(..., description="分类ID") + category_name: str | None = Field(None, description="分类名称") + category_icon: str | None = Field(None, description="分类图标") + description: str | None = Field(None, description="模板简介") + file_path: str | None = Field(None, description="原始模板文件路径") + pdf_file_path: str | None = Field(None, description="PDF 预览文件路径") + file_format: str = Field(..., description="文件格式") + is_featured: bool = Field(False, description="是否推荐") + created_at: str | None = Field(None, description="创建时间") + updated_at: str | None = Field(None, description="更新时间") + + +class ContractTemplatePageVO(BaseModel): + """合同模板分页结果。""" + + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + page_size: int = Field(..., description="分页大小") + total_pages: int = Field(..., description="总页数") + templates: list[ContractTemplateListItemVO] = Field(default_factory=list, description="模板列表") + + +class ContractTemplateDetailVO(ContractTemplateListItemVO): + """合同模板详情。""" + + category_description: str | None = Field(None, description="分类描述") + placeholder_schema: dict | None = Field(None, description="模板占位符结构") + + +class ContractTemplateSearchCategoryVO(BaseModel): + """搜索结果分类统计。""" + + id: int = Field(..., description="分类ID") + name: str = Field(..., description="分类名称") + search_count: int = Field(0, description="当前关键词命中的模板数") + + +class ContractTemplateSearchResultVO(BaseModel): + """合同模板搜索结果。""" + + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + page_size: int = Field(..., description="分页大小") + total_pages: int = Field(..., description="总页数") + templates: list[ContractTemplateListItemVO] = Field(default_factory=list, description="模板列表") + category_stats: list[ContractTemplateSearchCategoryVO] = Field(default_factory=list, description="分类统计") diff --git a/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py b/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py new file mode 100644 index 0000000..b864b82 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/contractTemplateService.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( + ContractTemplateListQueryDTO, + ContractTemplateSearchQueryDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( + ContractTemplateCategoryVO, + ContractTemplateDetailVO, + ContractTemplatePageVO, + ContractTemplateSearchResultVO, +) + + +class IContractTemplateService(ABC): + """合同模板服务接口。""" + + @abstractmethod + async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]: + ... + + @abstractmethod + async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO: + ... + + @abstractmethod + async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: + ... + + @abstractmethod + async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None: + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py new file mode 100644 index 0000000..f7c2dfe --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import bindparam, text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( + ContractTemplateListQueryDTO, + ContractTemplateSearchQueryDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( + ContractTemplateCategoryVO, + ContractTemplateDetailVO, + ContractTemplateListItemVO, + ContractTemplatePageVO, + ContractTemplateSearchCategoryVO, + ContractTemplateSearchResultVO, +) +from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService + +_ALLOWED_SORT_FIELDS = { + "id": "t.id", + "title": "t.title", + "created_at": "t.created_at", + "updated_at": "t.updated_at", +} + + +class ContractTemplateServiceImpl(IContractTemplateService): + """合同模板服务实现。""" + + 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" + sql = text( + f""" + SELECT + c.id, + c.name, + c.icon, + c.description, + COALESCE(c.sort_order, 0) AS sort_order, + {count_select}, + TRUE AS is_enabled + FROM contract_categories c + LEFT JOIN contract_templates t + ON t.category_id = c.id + WHERE 1=1 + 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: + 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 with GetAsyncSession() as session: + total = int((await session.execute(count_sql, params)).scalar_one()) + rows = (await session.execute(list_sql, params)).mappings().all() + + return ContractTemplatePageVO( + total=total, + page=Query.page, + page_size=Query.page_size, + total_pages=max((total + Query.page_size - 1) // Query.page_size, 1) if total else 0, + templates=[self._to_list_item_vo(row) for row in rows], + ) + + async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: + list_query = ContractTemplateListQueryDTO( + keyword=Query.q, + category_id=Query.category_id, + category_name=Query.category_name, + 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) + + return ContractTemplateSearchResultVO( + total=page_result.total, + page=page_result.page, + page_size=page_result.page_size, + total_pages=page_result.total_pages, + templates=page_result.templates, + 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 with GetAsyncSession() as session: + row = (await session.execute(sql, {"template_id": TemplateId})).mappings().first() + + if not row: + return None + return self._to_detail_vo(row) + + async def _load_search_category_stats( + self, + keyword: str, + ) -> 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: + rows = (await session.execute(sql, params)).mappings().all() + + return [ + ContractTemplateSearchCategoryVO( + id=int(row["id"]), + name=str(row["name"] or ""), + search_count=int(row["search_count"] or 0), + ) + for row in rows + if row.get("id") is not None + ] + + def _build_template_filters( + self, + keyword: str | None, + category_id: int | None, + category_name: str | None, + file_format: str | None, + is_featured: bool | None, + ) -> tuple[str, dict[str, Any], bool]: + filters = ["1=1"] + params: dict[str, Any] = {} + needs_category_name_filter = False + + if category_id is not None: + filters.append("t.category_id = :category_id") + params["category_id"] = category_id + elif category_name: + filters.append("c.name = :category_name") + params["category_name"] = category_name.strip() + needs_category_name_filter = True + + if file_format: + filters.append("t.file_format = :file_format") + params["file_format"] = file_format.strip().lower() + + if is_featured is not None: + filters.append("COALESCE(t.is_featured, FALSE) = :is_featured") + params["is_featured"] = is_featured + + clean_keyword = (keyword or "").strip() + if clean_keyword: + filters.append( + "(" + "t.title ILIKE :keyword " + "OR COALESCE(t.description, '') ILIKE :keyword " + "OR COALESCE(t.template_code, '') ILIKE :keyword " + "OR COALESCE(c.name, '') ILIKE :keyword" + ")" + ) + params["keyword"] = f"%{clean_keyword}%" + needs_category_name_filter = True + + return " AND ".join(filters), params, needs_category_name_filter + + def _build_template_from_sql(self, needs_category_name_filter: bool) -> str: + _ = needs_category_name_filter + return """ + FROM contract_templates t + LEFT JOIN contract_categories c ON c.id = t.category_id + """ + + def _build_order_clause(self, sort_by: str | None, sort_order: str | None, default_field: str, default_order: str) -> str: + field = _ALLOWED_SORT_FIELDS.get(str(sort_by or "").strip().lower(), _ALLOWED_SORT_FIELDS[default_field]) + direction = "DESC" if str(sort_order or default_order).strip().lower() == "desc" else "ASC" + return f"{field} {direction}, t.id ASC" + + 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] + return tuple(sql_objects) + + def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO: + return ContractTemplateCategoryVO( + id=int(row["id"]), + name=str(row["name"] or ""), + icon=row.get("icon"), + description=row.get("description"), + sort_order=int(row.get("sort_order") or 0), + template_count=int(row.get("template_count") or 0), + is_enabled=bool(row.get("is_enabled", True)), + ) + + def _to_list_item_vo(self, row: Any) -> ContractTemplateListItemVO: + return ContractTemplateListItemVO( + id=int(row["id"]), + template_code=str(row.get("template_code") or ""), + title=str(row.get("title") or ""), + category_id=int(row.get("category_id") or 0), + category_name=row.get("category_name"), + category_icon=row.get("category_icon"), + description=row.get("description"), + file_path=row.get("file_path"), + pdf_file_path=row.get("pdf_file_path"), + file_format=str(row.get("file_format") or ""), + is_featured=bool(row.get("is_featured", False)), + created_at=self._stringify_time(row.get("created_at")), + updated_at=self._stringify_time(row.get("updated_at")), + ) + + def _to_detail_vo(self, row: Any) -> ContractTemplateDetailVO: + base = self._to_list_item_vo(row) + return ContractTemplateDetailVO( + **base.model_dump(), + category_description=row.get("category_description"), + placeholder_schema=None, + ) + + def _stringify_time(self, value: Any) -> str | None: + if value is None: + return None + return str(value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index 1979741..eac192c 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -222,6 +222,9 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "usage_stats:departments:read", "display_name": "查看部门统计", "module": "usage_stats", "resource": "departments", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-departments", "route_path": "/usage-stats"}, {"permission_key": "usage_stats:areas:read", "display_name": "查看地区统计", "module": "usage_stats", "resource": "areas", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-areas", "route_path": "/usage-stats"}, {"permission_key": "usage_stats:details:read", "display_name": "查看统计明细", "module": "usage_stats", "resource": "details", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/details", "route_path": "/usage-stats"}, + {"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": "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 new file mode 100644 index 0000000..4d92ee9 --- /dev/null +++ b/scripts/migrate_legacy_contract_templates.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Migrate legacy contract templates from docauditai to leaudit_platform.""" + +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +import asyncpg +from minio import Minio + +from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils + + +ROOT = Path(__file__).resolve().parents[1] +APP_TOML = ROOT / "app.toml" +OLD_BUCKET = "docauditai" + + +@dataclass(frozen=True) +class LegacyCategory: + id: int + name: str + icon: str | None + description: str | None + sort_order: int + + +@dataclass(frozen=True) +class LegacyTemplate: + id: int + template_code: str + title: str + category_id: int + description: str | None + file_path: str | None + file_format: str | None + is_featured: bool | None + created_at: object + updated_at: object + pdf_file_path: str | None + category_name: str + + +def load_target_config() -> dict[str, 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"] + oss = config["OSS"] + return { + "target_dsn": ( + f"postgresql://{db['USER']}:{db['PASSWORD']}" + f"@{db['HOST']}:{db['PORT']}/{db['NAME']}" + ), + "oss_endpoint": oss["ENDPOINT"], + "oss_base_url": oss.get("BASE_URL", ""), + "oss_access_key": oss["ACCESS_KEY"], + "oss_secret_key": oss["SECRET_KEY"], + "oss_bucket": oss["BUCKET"], + } + + +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 build_minio_client(config: dict[str, str]) -> Minio: + endpoint = config["oss_endpoint"] + base_url = config.get("oss_base_url", "") + if base_url.startswith("http://"): + secure = False + elif base_url.startswith("https://"): + secure = True + else: + secure = endpoint.startswith("https://") + host = endpoint.replace("http://", "").replace("https://", "") + return Minio( + host, + access_key=config["oss_access_key"], + secret_key=config["oss_secret_key"], + secure=secure, + ) + + +async def fetch_legacy_categories(conn: asyncpg.Connection) -> list[LegacyCategory]: + rows = await conn.fetch( + """ + SELECT id, name, icon, description, COALESCE(sort_order, 0) AS sort_order + FROM public.contract_categories + ORDER BY id + """ + ) + return [LegacyCategory(**dict(row)) for row in rows] + + +async def fetch_legacy_templates(conn: asyncpg.Connection) -> list[LegacyTemplate]: + rows = await conn.fetch( + """ + SELECT + t.id, + t.template_code, + t.title, + t.category_id, + t.description, + t.file_path, + t.file_format, + t.is_featured, + t.created_at, + t.updated_at, + t.pdf_file_path, + c.name AS category_name + FROM public.contract_templates t + LEFT JOIN public.contract_categories c ON c.id = t.category_id + ORDER BY t.id + """ + ) + return [LegacyTemplate(**dict(row)) for row in rows] + + +def resolve_docx_path(template: LegacyTemplate, object_keys: set[str]) -> str: + file_path = (template.file_path or "").strip() + if not file_path: + raise ValueError(f"template {template.id} missing file_path") + if file_path in object_keys: + pdf_path = (template.pdf_file_path or "").strip() + if pdf_path and pdf_path in object_keys: + expected_docx = str(Path(pdf_path).with_suffix(".docx")) + if expected_docx in object_keys: + current_name = Path(file_path).name + expected_name = Path(expected_docx).name + if current_name != expected_name: + return expected_docx + return file_path + + pdf_path = (template.pdf_file_path or "").strip() + if pdf_path: + expected_docx = str(Path(pdf_path).with_suffix(".docx")) + if expected_docx in object_keys: + return expected_docx + + raise FileNotFoundError(f"template {template.id} docx not found: {file_path}") + + +def resolve_pdf_path(template: LegacyTemplate, object_keys: set[str]) -> str: + pdf_path = (template.pdf_file_path or "").strip() + if not pdf_path: + raise ValueError(f"template {template.id} missing pdf_file_path") + if pdf_path in object_keys: + return pdf_path + raise FileNotFoundError(f"template {template.id} pdf not found: {pdf_path}") + + +def build_new_object_keys(template: LegacyTemplate, docx_path: str, pdf_path: str) -> tuple[str, str]: + docx_key = OssPathUtils.BuildContractTemplateKey( + CategoryName=template.category_name, + TemplateCode=template.template_code, + FileRole="source", + FileName=Path(docx_path).name, + ) + pdf_key = OssPathUtils.BuildContractTemplateKey( + CategoryName=template.category_name, + TemplateCode=template.template_code, + FileRole="preview", + FileName=Path(pdf_path).name, + ) + return docx_key, pdf_key + + +def copy_object_bytes( + client: Minio, + *, + source_bucket: str, + source_key: str, + target_bucket: str, + target_key: str, +) -> None: + response = client.get_object(source_bucket, source_key) + try: + payload = response.read() + finally: + response.close() + response.release_conn() + + client.put_object( + target_bucket, + target_key, + data=BytesIO(payload), + length=len(payload), + ) + + +def ensure_bucket(client: Minio, bucket: str) -> None: + if not client.bucket_exists(bucket): + client.make_bucket(bucket) + + +async def reset_target_tables(conn: asyncpg.Connection) -> None: + await conn.execute("TRUNCATE TABLE public.contract_templates RESTART IDENTITY CASCADE") + await conn.execute("TRUNCATE TABLE public.contract_categories RESTART IDENTITY CASCADE") + + +async def insert_categories(conn: asyncpg.Connection, categories: list[LegacyCategory]) -> None: + for category in categories: + await conn.execute( + """ + INSERT INTO public.contract_categories (id, name, icon, description, sort_order) + VALUES ($1, $2, $3, $4, $5) + """, + category.id, + category.name, + category.icon, + category.description, + category.sort_order, + ) + await conn.execute( + """ + SELECT setval( + pg_get_serial_sequence('public.contract_categories', 'id'), + COALESCE((SELECT MAX(id) FROM public.contract_categories), 1), + TRUE + ) + """ + ) + + +async def insert_templates( + conn: asyncpg.Connection, + templates: list[LegacyTemplate], + template_paths: dict[int, tuple[str, str]], +) -> None: + for template in templates: + file_path, pdf_file_path = template_paths[template.id] + await conn.execute( + """ + INSERT INTO public.contract_templates ( + id, + template_code, + title, + category_id, + description, + file_path, + file_format, + is_featured, + created_at, + updated_at, + pdf_file_path + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """, + template.id, + template.template_code, + template.title, + template.category_id, + template.description, + file_path, + (template.file_format or "docx").lower(), + bool(template.is_featured), + template.created_at, + template.updated_at, + pdf_file_path, + ) + await conn.execute( + """ + SELECT setval( + pg_get_serial_sequence('public.contract_templates', 'id'), + COALESCE((SELECT MAX(id) FROM public.contract_templates), 1), + TRUE + ) + """ + ) + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Migrate legacy contract templates.") + parser.add_argument("--legacy-host", default="nas.7bm.co") + parser.add_argument("--legacy-port", type=int, default=54302) + parser.add_argument("--legacy-db", default="docauditai") + parser.add_argument("--legacy-user", default="root") + parser.add_argument("--legacy-password", default="postgresql.2025.qwe") + parser.add_argument("--apply", action="store_true", help="Apply migration to OSS and target DB.") + args = parser.parse_args() + + config = load_target_config() + legacy_dsn = build_legacy_dsn(args) + target_dsn = config["target_dsn"] + target_bucket = config["oss_bucket"] + minio_client = build_minio_client(config) + + legacy_conn = await asyncpg.connect(legacy_dsn) + target_conn = await asyncpg.connect(target_dsn) + try: + ensure_bucket(minio_client, target_bucket) + categories = await fetch_legacy_categories(legacy_conn) + templates = await fetch_legacy_templates(legacy_conn) + object_keys = { + obj.object_name + for obj in minio_client.list_objects(OLD_BUCKET, prefix="contract-template/", recursive=True) + } + + template_paths: dict[int, tuple[str, str]] = {} + for template in templates: + docx_path = resolve_docx_path(template, object_keys) + pdf_path = resolve_pdf_path(template, object_keys) + template_paths[template.id] = build_new_object_keys(template, docx_path, pdf_path) + + print(f"legacy categories: {len(categories)}") + print(f"legacy templates: {len(templates)}") + for template in templates: + old_docx = resolve_docx_path(template, object_keys) + old_pdf = resolve_pdf_path(template, object_keys) + new_docx, new_pdf = template_paths[template.id] + print( + f"[{template.id}] {template.template_code} | " + f"{old_docx} -> {new_docx} | {old_pdf} -> {new_pdf}" + ) + + if not args.apply: + print("dry-run complete; rerun with --apply to execute migration") + return + + if args.apply: + found_correction = False + for template in templates: + old_docx = resolve_docx_path(template, object_keys) + old_pdf = resolve_pdf_path(template, object_keys) + new_docx, new_pdf = template_paths[template.id] + if old_docx != (template.file_path or "").strip(): + print( + f"corrected docx path for template {template.id}: " + f"{template.file_path} -> {old_docx}" + ) + found_correction = True + copy_object_bytes( + minio_client, + source_bucket=OLD_BUCKET, + source_key=old_docx, + target_bucket=target_bucket, + target_key=new_docx, + ) + copy_object_bytes( + minio_client, + source_bucket=OLD_BUCKET, + source_key=old_pdf, + target_bucket=target_bucket, + target_key=new_pdf, + ) + if not found_correction: + print("no legacy path corrections required") + + async with target_conn.transaction(): + await reset_target_tables(target_conn) + await insert_categories(target_conn, categories) + await insert_templates(target_conn, templates, template_paths) + + print("migration applied successfully") + finally: + await legacy_conn.close() + await target_conn.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/创建sql/README.md b/scripts/创建sql/README.md index 67a62f6..b9de2da 100644 --- a/scripts/创建sql/README.md +++ b/scripts/创建sql/README.md @@ -46,6 +46,11 @@ psql -h -U -d -v ON_ERROR_STOP=1 -f scripts/创建sql/ -U -d -v ON_ERROR_STOP=1 -f scripts/创建sql/schema_contract_templates.sql +psql -h -U -d -v ON_ERROR_STOP=1 -f scripts/创建sql/seed_contract_templates_rbac.sql + +# 先 dry-run,看旧路径 -> 新路径映射 +python scripts/migrate_legacy_contract_templates.py + +# 确认无误后正式执行:复制 OSS 文件 + 回写主库 +python scripts/migrate_legacy_contract_templates.py --apply +``` + +#### 执行后验收 + +```sql +SELECT to_regclass('public.contract_categories'); +SELECT to_regclass('public.contract_templates'); + +SELECT permission_key +FROM permissions +WHERE permission_key LIKE 'contract_template:%' +ORDER BY permission_key; + +SELECT r.role_key, p.permission_key, rp.grant_type, rp.data_scope +FROM role_permissions rp +JOIN roles r ON r.id = rp.role_id +JOIN permissions p ON p.id = rp.permission_id +WHERE p.permission_key LIKE 'contract_template:%' +ORDER BY r.role_key, p.permission_key; + +SELECT COUNT(*) AS category_count FROM public.contract_categories; +SELECT COUNT(*) AS template_count FROM public.contract_templates; + +SELECT id, template_code, title, file_path, pdf_file_path +FROM public.contract_templates +ORDER BY id +LIMIT 10; +``` + +#### 当前基线验收结果 + +- 主库 `leaudit_platform` + - `contract_categories = 9` + - `contract_templates = 27` +- 新 bucket `leaudit` + - `contract-templates/...` 对象总数 = `54` +- 新路径样例 + - `contract-templates/买卖合同/mmht/source__买卖合同范本.docx` + - `contract-templates/买卖合同/mmht/preview__买卖合同范本.pdf` + ### 七、RAG - `schema_add_rag_chat.sql` diff --git a/scripts/创建sql/schema_contract_templates.sql b/scripts/创建sql/schema_contract_templates.sql new file mode 100644 index 0000000..0955d65 --- /dev/null +++ b/scripts/创建sql/schema_contract_templates.sql @@ -0,0 +1,66 @@ +BEGIN; + +-- ============================================================================ +-- LeAudit Platform Contract Template Schema +-- 目标: +-- 1. 在主库 leaudit_platform 创建合同模板分类表 +-- 2. 在主库 leaudit_platform 创建合同模板表 +-- 3. 补齐索引与基础约束 +-- 说明: +-- - 本脚本不依赖旧库 docauditai +-- - 幂等脚本,可重复执行 +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.contract_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + icon VARCHAR(100) NULL, + description TEXT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_categories_name + ON public.contract_categories(name); + +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, + description TEXT NULL, + file_path VARCHAR(500) NULL, + file_format VARCHAR(10) NOT NULL, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + pdf_file_path VARCHAR(500) NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_templates_code + ON public.contract_templates(template_code); + +CREATE INDEX IF NOT EXISTS idx_contract_templates_category_id + ON public.contract_templates(category_id); + +CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_at + ON public.contract_templates(updated_at DESC); + +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 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.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预览文件路径'; + +COMMIT; diff --git a/scripts/创建sql/seed_contract_templates_rbac.sql b/scripts/创建sql/seed_contract_templates_rbac.sql new file mode 100644 index 0000000..07f24cb --- /dev/null +++ b/scripts/创建sql/seed_contract_templates_rbac.sql @@ -0,0 +1,140 @@ +BEGIN; + +-- ============================================================================ +-- LeAudit Platform Contract Template RBAC Seed +-- 目标: +-- 1. 补齐合同模板读权限 +-- 2. 给 super_admin / provincial_admin / admin 分配模板读权限 +-- 说明: +-- - 依赖 user_rbac_schema_patch.sql +-- - 依赖合同模板前端路由已存在于 sys_routes +-- - 幂等脚本,可重复执行 +-- ============================================================================ + +WITH route_map AS ( + SELECT id, route_path + FROM sys_routes + WHERE deleted_at IS NULL + AND route_path IN ('/contract-template/list', '/contract-template/search') +) +INSERT INTO permissions ( + permission_key, + module, + resource, + action, + description, + display_name, + permission_type, + is_system, + metadata, + created_at, + updated_at, + created_by, + updated_by, + parent_id, + sort_order, + route_id, + api_path, + api_method, + related_routes +) +SELECT + seed.permission_key, + seed.module, + seed.resource, + seed.action, + seed.description, + seed.display_name, + 'API', + TRUE, + NULL::jsonb, + NOW(), + NOW(), + NULL::bigint, + NULL::bigint, + NULL::bigint, + seed.sort_order, + route_map.id, + seed.api_path, + seed.api_method, + NULL::bigint[] +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') +) AS seed( + permission_key, + module, + resource, + action, + description, + display_name, + route_path, + sort_order, + api_path, + api_method +) +JOIN route_map ON route_map.route_path = seed.route_path +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, + route_id = EXCLUDED.route_id, + api_path = EXCLUDED.api_path, + api_method = EXCLUDED.api_method, + sort_order = EXCLUDED.sort_order, + updated_at = NOW(); + +WITH role_map AS ( + SELECT id, role_key + FROM roles + WHERE role_key IN ('super_admin', 'provincial_admin', 'admin') +), +perm_map AS ( + SELECT id, permission_key + FROM permissions + WHERE permission_key LIKE 'contract_template:%' +), +seed(role_key, permission_key, grant_type, data_scope) AS ( + VALUES + ('super_admin', 'contract_template:list:read', 'GRANT', 'ALL'), + ('super_admin', 'contract_template:search:read', 'GRANT', 'ALL'), + ('super_admin', 'contract_template:detail:read', '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'), + + ('admin', 'contract_template:list:read', 'GRANT', 'DEPT'), + ('admin', 'contract_template:search:read', 'GRANT', 'DEPT'), + ('admin', 'contract_template:detail:read', 'GRANT', 'DEPT') +) +INSERT INTO role_permissions ( + role_id, + permission_id, + grant_type, + data_scope, + created_at, + updated_at +) +SELECT + role_map.id, + perm_map.id, + seed.grant_type, + seed.data_scope, + NOW(), + NOW() +FROM seed +JOIN role_map ON role_map.role_key = seed.role_key +JOIN perm_map ON perm_map.permission_key = seed.permission_key +ON CONFLICT (role_id, permission_id) DO UPDATE SET + grant_type = EXCLUDED.grant_type, + data_scope = EXCLUDED.data_scope, + updated_at = NOW(); + +COMMIT;