From 16e866815092c9fa7493024e03fbcc58924a224c Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Tue, 19 May 2026 18:38:17 +0800 Subject: [PATCH 1/4] feat: add contract template v3 api and legacy oss migration --- .../fastapi_common_storage/oss_path_utils.py | 18 + .../controllers/contractTemplateController.py | 107 +++++ .../domian/Dto/contractTemplateDto.py | 27 ++ .../domian/vo/contractTemplateVo.py | 67 ++++ .../services/contractTemplateService.py | 32 ++ .../impl/contractTemplateServiceImpl.py | 317 +++++++++++++++ .../services/impl/rbacAdminServiceImpl.py | 3 + scripts/migrate_legacy_contract_templates.py | 374 ++++++++++++++++++ scripts/创建sql/README.md | 90 +++++ scripts/创建sql/schema_contract_templates.sql | 66 ++++ .../创建sql/seed_contract_templates_rbac.sql | 140 +++++++ 11 files changed, 1241 insertions(+) create mode 100644 fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py create mode 100644 fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py create mode 100644 fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py create mode 100644 fastapi_modules/fastapi_leaudit/services/contractTemplateService.py create mode 100644 fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py create mode 100644 scripts/migrate_legacy_contract_templates.py create mode 100644 scripts/创建sql/schema_contract_templates.sql create mode 100644 scripts/创建sql/seed_contract_templates_rbac.sql 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; From d58755a0635bc1dce32d26971f3b7bc9d439128c Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Tue, 19 May 2026 20:47:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=90=88?= =?UTF-8?q?=E5=90=8C=E6=A8=A1=E6=9D=BF=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E8=AE=B0=E5=BD=95=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/documentController.py | 32 +- .../fastapi_leaudit/domian/vo/documentVo.py | 11 + .../services/documentService.py | 14 + .../services/impl/documentServiceImpl.py | 352 +++++++++++++++++- legal-platform-frontend | 2 +- 5 files changed, 398 insertions(+), 13 deletions(-) diff --git a/fastapi_modules/fastapi_leaudit/controllers/documentController.py b/fastapi_modules/fastapi_leaudit/controllers/documentController.py index e9c6e4b..b3cb029 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/documentController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/documentController.py @@ -1,5 +1,6 @@ """文档控制器。""" +import json from typing import Any from fastapi import Depends, File, Form, Query, UploadFile @@ -8,7 +9,8 @@ from sqlalchemy import text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.controller import BaseController -from fastapi_common.fastapi_common_web.domain.responses import Result +from fastapi_common.fastapi_common_web.domain.responses import Result, StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_common.fastapi_common_security.security import verify_access_token from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( @@ -21,6 +23,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( DocumentTypeRootCreateDTO, DocumentTypeRootItemVO, DocumentTypeRootUpdateDTO, + ContractTemplateUploadVO, DocumentTypeUpdateDTO, DocumentUploadVO, ) @@ -94,6 +97,33 @@ class DocumentController(BaseController): ) return Result.success(data=Data) + @self.router.post("/upload/upload_contract_template", response_model=Result[ContractTemplateUploadVO]) + async def UploadContractTemplate( + file: UploadFile = File(..., description="合同模板文件"), + upload_info: str = Form(..., description="模板上传信息 JSON,包含 document_id/comparison_id"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + """兼容旧前端的合同模板上传接口。""" + try: + uploadInfo = json.loads(upload_info or "{}") + except json.JSONDecodeError as error: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "upload_info 不是合法 JSON") from error + + documentId = int(uploadInfo.get("document_id") or 0) + comparisonIdRaw = uploadInfo.get("comparison_id") + comparisonId = int(comparisonIdRaw) if comparisonIdRaw not in (None, "") else None + content = await file.read() + + data = await self.DocumentService.UploadContractTemplate( + CurrentUserId=int(payload["user_id"]), + DocumentId=documentId, + FileName=file.filename or "template.bin", + FileContent=content, + ContentType=file.content_type, + ComparisonId=comparisonId, + ) + return Result.success(data=data, message="合同模板上传成功") + @self.router.get("/documents/list", response_model=Result[DocumentListPageVO]) async def ListDocuments( page: int = 1, diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py index 19dbcc5..eeead21 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py @@ -28,6 +28,17 @@ class DocumentUploadVO(BaseModel): run: AuditRunVO | None = Field(None, description="自动触发后的运行信息") +class ContractTemplateUploadVO(BaseModel): + """合同模板上传响应。""" + + documentId: int = Field(..., description="目标文档ID") + comparisonId: int = Field(..., description="合同结构对比记录ID") + templateName: str = Field(..., description="模板文件名") + templateContractPath: str = Field(..., description="模板文件 OSS 路径") + fileSize: int = Field(..., description="模板文件大小") + status: str = Field("uploaded", description="上传状态") + + class DocumentStatusItemVO(BaseModel): """文档状态项。""" diff --git a/fastapi_modules/fastapi_leaudit/services/documentService.py b/fastapi_modules/fastapi_leaudit/services/documentService.py index c01b9c1..3e9362c 100644 --- a/fastapi_modules/fastapi_leaudit/services/documentService.py +++ b/fastapi_modules/fastapi_leaudit/services/documentService.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( + ContractTemplateUploadVO, DocumentDetailVO, DocumentListPageVO, DocumentStatusItemVO, @@ -118,6 +119,19 @@ class IDocumentService(ABC): """为现有文档追加附件,并执行数据隔离校验。""" ... + @abstractmethod + async def UploadContractTemplate( + self, + CurrentUserId: int, + DocumentId: int, + FileName: str, + FileContent: bytes, + ContentType: str | None, + ComparisonId: int | None = None, + ) -> ContractTemplateUploadVO: + """为现有合同文档上传结构对比模板,并持久化记录。""" + ... + @abstractmethod async def ListDocumentTypes(self, Ids: list[int] | None = None, EntryModuleId: int | None = None) -> list[DocumentTypeItemVO]: """获取文档类型列表。""" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index 9be7f12..bfe2447 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -24,6 +24,7 @@ from fastapi_common.fastapi_common_web.exception.LeauditException import Leaudit from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( + ContractTemplateUploadVO, DocumentAttachmentVO, DocumentDetailVO, DocumentHistoryVersionVO, @@ -632,6 +633,7 @@ class DocumentServiceImpl(IDocumentService): async with GetAsyncSession() as Session: await self._ensureDocumentGroupColumn(Session) await self._ensureReviewPointAuditTable(Session) + await self._ensureCrossReviewProposalSchema(Session) currentUser = await self._getCurrentUserContext(CurrentUserId) documentColumns = await self._loadDocumentColumns(Session) detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns) @@ -655,6 +657,10 @@ class DocumentServiceImpl(IDocumentService): reviewPoints = await self._loadReviewPointResults(Session, detail, int(runRow["id"])) stats = self._buildReviewPointStats(reviewPoints) + approvedSupplementDelta = await self._loadApprovedSupplementScoreDelta(Session, detail.documentId) + if approvedSupplementDelta: + stats.score += approvedSupplementDelta + documentPayload = await self._buildReviewDocumentPayload(Session, detail, runRow) reviewInfo = self._buildReviewInfo(runRow, reviewPoints, stats) comparisonDocument = await self._loadComparisonDocument(Session, detail.documentId) @@ -866,6 +872,85 @@ class DocumentServiceImpl(IDocumentService): for row in rows ] + async def UploadContractTemplate( + self, + CurrentUserId: int, + DocumentId: int, + FileName: str, + FileContent: bytes, + ContentType: str | None, + ComparisonId: int | None = None, + ) -> ContractTemplateUploadVO: + """为现有合同文档上传结构对比模板,并写入 contract_structure_comparison。""" + if DocumentId <= 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "文档ID不能为空") + if not FileName: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板文件名不能为空") + if not FileContent: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板文件内容不能为空") + + fileExt = Path(FileName).suffix.lstrip(".").lower() + if fileExt not in {"pdf", "doc", "docx"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板文件仅支持 pdf/doc/docx") + + mimeType = ContentType or mimetypes.guess_type(FileName)[0] or "application/octet-stream" + fileSize = len(FileContent) + uploadedAt = datetime.now() + + async with GetAsyncSession() as Session: + await self._ensureDocumentGroupColumn(Session) + await self._ensureContractStructureComparisonTable(Session) + currentUser = await self._getCurrentUserContext(CurrentUserId) + documentColumns = await self._loadDocumentColumns(Session) + detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns) + if not detail and await self._hasCrossReviewDocumentAccess(Session, DocumentId, CurrentUserId): + detail = await self._getDocumentDetail( + Session, + DocumentId, + CurrentUserId, + currentUser, + documentColumns, + BypassScopeCheck=True, + ) + if not detail: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "目标文档不存在或无权限访问") + + versionLabel = f"v{int(detail.versionNo or 1)}" + objectKey = OssPathUtils.BuildBusinessDocKey( + Region=detail.region or "default", + TypeCode=detail.typeCode or "contract", + DocumentId=DocumentId, + Version=versionLabel, + FileRole="template", + FileName=FileName, + Year=uploadedAt.year, + Month=uploadedAt.month, + ) + ossUrl = await self.OssService.UploadBytes( + ObjectKey=objectKey, + Content=FileContent, + ContentType=mimeType, + ) + + comparisonId = await self._upsertContractStructureComparison( + Session=Session, + DocumentId=DocumentId, + ComparisonId=ComparisonId, + TemplateName=FileName, + TemplatePath=ossUrl, + FileSize=fileSize, + ) + await Session.commit() + + return ContractTemplateUploadVO( + documentId=DocumentId, + comparisonId=comparisonId, + templateName=FileName, + templateContractPath=ossUrl, + fileSize=fileSize, + status="uploaded", + ) + async def UpdateDocument(self, CurrentUserId: int, Id: int, Body: DocumentUpdateDTO) -> DocumentDetailVO: """更新文档元数据,并执行数据隔离校验。""" async with GetAsyncSession() as Session: @@ -1521,6 +1606,214 @@ class DocumentServiceImpl(IDocumentService): ) ) + async def _ensureCrossReviewProposalSchema(self, Session) -> None: + """补齐交叉评查提案表的补充意见字段,兼容旧环境。""" + if not await self._tableExists(Session, "leaudit_cross_review_proposals"): + return + + proposalColumns = await self._loadTableColumns(Session, "leaudit_cross_review_proposals") + touched = False + + if "proposal_type" not in proposalColumns: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ADD COLUMN proposal_type VARCHAR(32) NOT NULL DEFAULT 'review_point'" + ) + ) + touched = True + if "evaluation_point_name" not in proposalColumns: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ADD COLUMN evaluation_point_name VARCHAR(255)" + ) + ) + touched = True + if "extraction_result_text" not in proposalColumns: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ADD COLUMN extraction_result_text TEXT" + ) + ) + touched = True + + isRuleResultNotNull = bool( + await Session.scalar( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'leaudit_cross_review_proposals' + AND column_name = 'rule_result_id' + AND is_nullable = 'NO' + LIMIT 1 + """ + ) + ) + ) + if isRuleResultNotNull: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ALTER COLUMN rule_result_id DROP NOT NULL" + ) + ) + touched = True + + if touched: + await Session.commit() + + async def _ensureContractStructureComparisonTable(self, Session) -> None: + """补齐合同结构比对表,兼容旧前端模板上传与详情页读取。""" + await Session.execute( + text( + """ + CREATE TABLE IF NOT EXISTS contract_structure_comparison ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL, + comparison_id BIGINT NULL, + template_contract_name VARCHAR(512) NULL, + template_contract_path TEXT NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, + comparison_results JSONB NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + await Session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_contract_structure_comparison_document_id ON contract_structure_comparison(document_id)" + ) + ) + + existingColumns = await self._loadTableColumns(Session, "contract_structure_comparison") + if "comparison_id" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN comparison_id BIGINT NULL")) + if "template_contract_name" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN template_contract_name VARCHAR(512) NULL")) + if "template_contract_path" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN template_contract_path TEXT NOT NULL DEFAULT ''")) + if "file_size" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN file_size BIGINT NOT NULL DEFAULT 0")) + if "comparison_results" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN comparison_results JSONB NULL")) + + async def _upsertContractStructureComparison( + self, + Session, + DocumentId: int, + ComparisonId: int | None, + TemplateName: str, + TemplatePath: str, + FileSize: int, + ) -> int: + """新增或更新合同结构比对模板记录。""" + targetRow = None + if ComparisonId is not None: + targetRow = ( + await Session.execute( + text( + """ + SELECT id + FROM contract_structure_comparison + WHERE id = :comparison_id + AND document_id = :document_id + LIMIT 1 + """ + ), + {"comparison_id": ComparisonId, "document_id": DocumentId}, + ) + ).mappings().first() + + if targetRow is None: + targetRow = ( + await Session.execute( + text( + """ + SELECT id + FROM contract_structure_comparison + WHERE document_id = :document_id + ORDER BY id DESC + LIMIT 1 + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().first() + + if targetRow: + comparisonId = int(targetRow["id"]) + await Session.execute( + text( + """ + UPDATE contract_structure_comparison + SET comparison_id = COALESCE(comparison_id, :comparison_id), + template_contract_name = :template_name, + template_contract_path = :template_path, + file_size = :file_size, + updated_at = NOW() + WHERE id = :comparison_id + """ + ), + { + "comparison_id": comparisonId, + "template_name": TemplateName, + "template_path": TemplatePath, + "file_size": FileSize, + }, + ) + return comparisonId + + inserted = ( + await Session.execute( + text( + """ + INSERT INTO contract_structure_comparison ( + document_id, + comparison_id, + template_contract_name, + template_contract_path, + file_size, + created_at, + updated_at + ) VALUES ( + :document_id, + NULL, + :template_name, + :template_path, + :file_size, + NOW(), + NOW() + ) + RETURNING id + """ + ), + { + "document_id": DocumentId, + "template_name": TemplateName, + "template_path": TemplatePath, + "file_size": FileSize, + }, + ) + ).mappings().first() + if not inserted: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板记录写入失败") + + comparisonId = int(inserted["id"]) + await Session.execute( + text( + """ + UPDATE contract_structure_comparison + SET comparison_id = COALESCE(comparison_id, :comparison_id), + updated_at = NOW() + WHERE id = :comparison_id + """ + ), + {"comparison_id": comparisonId}, + ) + return comparisonId + async def _tableExists(self, Session, TableName: str) -> bool: """检查表是否存在。""" row = ( @@ -2268,20 +2561,33 @@ class DocumentServiceImpl(IDocumentService): async def _loadScoringProposals(self, Session, DocumentId: int) -> list[dict[str, Any]]: """读取交叉评分提案;缺表时降级为空。""" if await self._tableExists(Session, "leaudit_cross_review_proposals"): + columns = await self._loadTableColumns(Session, "leaudit_cross_review_proposals") + selectColumns = [ + "id", + "'review_point' AS proposal_type", + "rule_result_id AS evaluation_result_id", + "proposer_id", + "NULL::varchar AS evaluation_point_name", + "NULL::text AS extraction_result_text", + "proposed_score_delta AS proposed_score", + "reason", + "status", + "create_time AS created_at", + "update_time AS updated_at", + "document_id", + ] + if "proposal_type" in columns: + selectColumns[1] = "proposal_type" + if "evaluation_point_name" in columns: + selectColumns[4] = "evaluation_point_name" + if "extraction_result_text" in columns: + selectColumns[5] = "extraction_result_text" rows = ( await Session.execute( text( - """ + f""" SELECT - id, - rule_result_id AS evaluation_result_id, - proposer_id, - proposed_score_delta AS proposed_score, - reason, - status, - create_time AS created_at, - update_time AS updated_at, - document_id + {', '.join(selectColumns)} FROM leaudit_cross_review_proposals WHERE document_id = :document_id AND delete_time IS NULL @@ -2336,6 +2642,29 @@ class DocumentServiceImpl(IDocumentService): ).mappings().all() return [dict(row) for row in rows] + async def _loadApprovedSupplementScoreDelta(self, Session, DocumentId: int) -> float: + """读取已通过的补充意见分值调整,计入详情页文档总分。""" + if not await self._tableExists(Session, "leaudit_cross_review_proposals"): + return 0.0 + columns = await self._loadTableColumns(Session, "leaudit_cross_review_proposals") + if "proposal_type" not in columns: + return 0.0 + + delta = await Session.scalar( + text( + """ + SELECT COALESCE(SUM(proposed_score_delta), 0) + FROM leaudit_cross_review_proposals + WHERE document_id = :document_id + AND COALESCE(proposal_type, 'review_point') = 'supplement' + AND status = 'approved' + AND delete_time IS NULL + """ + ), + {"document_id": DocumentId}, + ) + return float(delta or 0.0) + async def _loadReviewPointResults( self, Session, @@ -2377,6 +2706,7 @@ class DocumentServiceImpl(IDocumentService): FROM leaudit_cross_review_proposals WHERE rule_result_id = rr.id AND document_id = :document_id + AND COALESCE(proposal_type, 'review_point') = 'review_point' AND status = 'approved' AND delete_time IS NULL ) ad ON TRUE @@ -2652,7 +2982,7 @@ class DocumentServiceImpl(IDocumentService): stats.warning += 1 elif item.status == "error": stats.error += 1 - stats.score += float(item.score or 0) + stats.score += float(item.currentScore if item.currentScore is not None else (item.score or 0)) return stats def _buildReviewInfo( diff --git a/legal-platform-frontend b/legal-platform-frontend index c41ddc8..dc81598 160000 --- a/legal-platform-frontend +++ b/legal-platform-frontend @@ -1 +1 @@ -Subproject commit c41ddc844ce04ad2ae5ee2679cc2155cc25d44bf +Subproject commit dc8159837b1911c29f8a94b5dc5861862c7ca174 From 980996d93332457479d40329bcc89e296675890c Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Tue, 19 May 2026 20:50:41 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E5=90=88?= =?UTF-8?q?=E5=90=8C=E6=A8=A1=E6=9D=BF=E4=B8=8E=E6=96=87=E6=A1=A3=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E6=96=B9=E6=A1=88=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../合同模板前端切换新接口改造清单.md | 253 ++++ .../合同模板搜索功能后端接口补充设计.md | 519 +++++++ .../合同模板权限蓝图补充建议.md | 86 ++ .../文档图片质量校验模块第3版接口与落地清单.md | 1227 +++++++++++++++++ ...前端分支提交与合并防覆盖操作规范-2026-05-19.md | 456 ++++++ 5 files changed, 2541 insertions(+) create mode 100644 docs/合同模板搜索合同起草/合同模板前端切换新接口改造清单.md create mode 100644 docs/合同模板搜索合同起草/合同模板搜索功能后端接口补充设计.md create mode 100644 docs/合同模板搜索合同起草/合同模板权限蓝图补充建议.md create mode 100644 docs/文档图片质量校验模块/文档图片质量校验模块第3版接口与落地清单.md create mode 100644 docs/项目总览/前端分支提交与合并防覆盖操作规范-2026-05-19.md diff --git a/docs/合同模板搜索合同起草/合同模板前端切换新接口改造清单.md b/docs/合同模板搜索合同起草/合同模板前端切换新接口改造清单.md new file mode 100644 index 0000000..1de46a9 --- /dev/null +++ b/docs/合同模板搜索合同起草/合同模板前端切换新接口改造清单.md @@ -0,0 +1,253 @@ +## 目标 + +将 `contract-template` 相关页面从旧的 PostgREST 直连方式切换到新的 FastAPI 业务接口,范围仅覆盖: + +- 模板分类 +- 模板列表 +- 模板搜索 +- 模板详情 + +本清单不包含“起草合同”能力,后续会作为独立模块重新开发。 + + +## 当前前端依赖点 + +当前前端核心依赖集中在: + +- `legal-platform-frontend/lib/api/legacy/contract-template/templates.ts` + +这个文件现在直接调用: + +- `/api/postgrest/proxy/contract_categories` +- `/api/postgrest/proxy/contract_templates` + +受影响页面: + +- `app/(audit)/contract-template/search/page.tsx` +- `app/(audit)/contract-template/search/results/page.tsx` +- `app/(audit)/contract-template/list/page.tsx` +- `app/(audit)/contract-template/detail/[id]/page.tsx` + + +## 推荐新接口 + +建议前端最终切换为: + +1. `GET /api/v3/contract-templates/categories` +2. `GET /api/v3/contract-templates` +3. `GET /api/v3/contract-templates/search` +4. `GET /api/v3/contract-templates/{id}` + + +## 改造步骤 + +### 第 1 步:新增新接口客户端文件 + +建议新增: + +- `legal-platform-frontend/lib/api/contract-template/index.ts` + +职责: + +- 仅封装新的业务后端接口 +- 不再依赖 `postgrest-client.ts` +- 返回字段尽量与页面现有消费结构兼容 + +建议方法: + +- `getContractTemplateCategories` +- `getContractTemplateList` +- `searchContractTemplateList` +- `getContractTemplateDetail` + + +### 第 2 步:定义前端接口类型 + +建议在新客户端文件中定义或复用以下类型: + +- `ContractTemplateCategory` +- `ContractTemplateListItem` +- `ContractTemplatePage` +- `ContractTemplateDetail` +- `ContractTemplateSearchResult` + +字段建议优先与新后端对齐,然后在页面边界做一次轻量转换,避免业务层长期保留 snake_case 和 camelCase 混用。 + + +### 第 3 步:改造搜索首页 + +文件: + +- `app/(audit)/contract-template/search/page.tsx` + +当前行为: + +- 调 `getContractCategoriesWithCount` + +改造后: + +- 改为调用 `getContractTemplateCategories` +- 直接消费后端返回的 `templateCount` + +页面影响: + +- `transformCategory` 中的 `template_count` 改为新接口字段 + + +### 第 4 步:改造列表页 + +文件: + +- `app/(audit)/contract-template/list/page.tsx` + +当前行为: + +- 调 `getContractTemplates` +- 同时调 `getContractCategoriesWithCount` + +改造后: + +- 列表数据改为 `getContractTemplateList` +- 分类数据改为 `getContractTemplateCategories` + +注意事项: + +- 当前页面把 `sortBy=relevance` 映射成 `id.asc`,这是旧 PostgREST 兼容逻辑 +- 切换新接口后应明确排序语义: + - `relevance` 仅搜索场景有效 + - 列表页默认建议改为 `updated_at desc` + + +### 第 5 步:改造搜索结果页 + +文件: + +- `app/(audit)/contract-template/search/results/page.tsx` + +当前行为: + +- 调 `searchContractTemplates` +- 额外循环所有分类,再逐类搜索统计命中数 + +改造后: + +- 改为一次调用 `searchContractTemplateList` +- 直接使用后端返回的 `categoryStats` + +收益: + +- 避免当前每次搜索都发起多次分类二次查询 +- 页面搜索耗时会明显降低 + + +### 第 6 步:改造详情页 + +文件: + +- `app/(audit)/contract-template/detail/[id]/page.tsx` + +当前行为: + +- 调 `getContractTemplate` + +改造后: + +- 改为调用 `getContractTemplateDetail` + +注意事项: + +- 页面中依赖: + - `template_code` + - `file_path` + - `pdf_file_path` + - `placeholder_schema` + - `category.name` + - `category.description` +- 新接口最好直接扁平返回,前端减少再拼装 + + +### 第 7 步:保留旧文件作为过渡,避免一次性大改 + +建议不要立刻删除: + +- `lib/api/legacy/contract-template/templates.ts` + +更稳妥的做法: + +1. 新建新接口客户端文件 +2. 页面逐个切换 +3. 确认没有页面再引用旧文件 +4. 再删除旧实现 + + +## 字段映射建议 + +建议后端返回使用 camelCase 还是 snake_case,前后端尽量统一一次定死。 + +如果后端沿用当前 Python VO 风格的 snake_case,那么前端建议统一在 API client 中做转换: + +- `template_code -> templateCode` +- `category_id -> categoryId` +- `file_path -> filePath` +- `pdf_file_path -> pdfFilePath` +- `placeholder_schema -> placeholderSchema` +- `updated_at -> updatedAt` + +不要把这类转换分散在页面组件里。 + + +## 具体函数替换建议 + +当前旧函数: + +- `getContractCategories` +- `getContractCategoriesWithCount` +- `getContractTemplates` +- `getContractTemplate` +- `searchContractTemplates` + +建议替换为新函数: + +- `getContractTemplateCategories` +- `getContractTemplateList` +- `getContractTemplateDetail` +- `searchContractTemplateList` + + +## 风险点 + +1. 排序语义变化 + - 旧逻辑混入了 PostgREST 风格排序拼接 + - 新接口需要明确合法排序字段白名单 + +2. 搜索分类统计变化 + - 旧页面自己循环查询分类统计 + - 新接口如果不返回 `categoryStats`,页面逻辑还要保留一部分旧实现 + +3. 详情页字段结构变化 + - 旧页面默认拿 `template.category?.name` + - 新接口若改成扁平字段,需要同步调整页面 transform + +4. token 传递方式变化 + - 旧实现依赖 `postgrest-client.ts` + - 新接口应统一走 `axios-client.ts` 或新业务客户端 + + +## 建议落地顺序 + +1. 新增 `lib/api/contract-template/index.ts` +2. 先切 `search/page.tsx` +3. 再切 `list/page.tsx` +4. 再切 `search/results/page.tsx` +5. 最后切 `detail/[id]/page.tsx` +6. 全部验证通过后删除旧 `legacy/contract-template/templates.ts` + + +## 验收标准 + +1. `contract-template/search` 能正常展示分类和数量 +2. `contract-template/list` 能分页、筛选、排序 +3. `contract-template/search/results` 能搜索并展示分类统计 +4. `contract-template/detail/[id]` 能正常查看详情与预览 +5. 页面不再直接请求 `/api/postgrest/proxy/contract_categories` +6. 页面不再直接请求 `/api/postgrest/proxy/contract_templates` diff --git a/docs/合同模板搜索合同起草/合同模板搜索功能后端接口补充设计.md b/docs/合同模板搜索合同起草/合同模板搜索功能后端接口补充设计.md new file mode 100644 index 0000000..32985eb --- /dev/null +++ b/docs/合同模板搜索合同起草/合同模板搜索功能后端接口补充设计.md @@ -0,0 +1,519 @@ +## 背景 + +当前 `contract-template` 模块前端页面已经迁移到 Next.js,但核心数据仍依赖旧的 PostgREST 直查表能力: + +- `contract_categories` +- `contract_templates` + +受影响页面包括: + +- `/contract-template/search` +- `/contract-template/search/results` +- `/contract-template/list` +- `/contract-template/detail/[id]` + +现状问题不是“前端页面不存在”,而是“新后端业务接口尚未补齐”。本文补充一版适合当前仓库风格的后端接口设计,覆盖: + +- VO/DTO 草稿 +- Controller 方法签名建议 +- 权限 key 建议 +- 代码目录落点建议 + + +## 目标 + +为合同模板搜索、列表、详情链路补齐新的 FastAPI 业务接口,替代前端对 PostgREST 的直接依赖。 + +原则: + +- 统一走 `fastapi_modules/fastapi_leaudit` 业务后端 +- 不继续扩散 PostgREST 依赖 +- 接口命名、分层、权限风格与现有 `v3` 模块保持一致 + + +## 现有前端依赖梳理 + +当前前端依赖的旧查询行为包括: + +1. 分类列表 + - 首页展示合同分类 + - 需要附带每个分类下的模板数量 + +2. 模板列表 + - 支持分页 + - 支持按分类筛选 + - 支持排序 + +3. 模板搜索 + - 支持关键词查询 + - 支持分类过滤 + - 支持结果分页 + - 搜索结果页还需要分类统计 + +4. 模板详情 + - 查看模板元数据 + - 下载模板 + - 预览模板 + + +## 推荐后端目录结构 + +建议新增以下文件: + +- `fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py` +- `fastapi_modules/fastapi_leaudit/services/contractTemplateService.py` +- `fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py` +- `fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py` + +原因: + +- `contract-template` 是独立业务域,不适合继续塞入 `homeService` 或 `documentService` +- 当前仓库主要采用“每个业务一个 controller/service/dto/vo”的组织方式 +- 前端菜单和 RBAC 已经把 `contract-template` 视作独立模块,后端也应保持同样边界 + + +## 路由设计建议 + +建议统一挂在: + +- `/api/v3/contract-templates` + +建议提供以下接口: + +1. `GET /api/v3/contract-templates/categories` +2. `GET /api/v3/contract-templates` +3. `GET /api/v3/contract-templates/search` +4. `GET /api/v3/contract-templates/{TemplateId}` + +以上 4 个接口用于替代当前页面的 PostgREST 查询。 + + +## VO 设计草案 + +建议新增文件: + +- `fastapi_modules/fastapi_leaudit/domian/vo/contractTemplateVo.py` + +草稿如下: + +```python +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="分类描述") + sortOrder: int = Field(0, description="排序") + templateCount: int = Field(0, description="分类下模板数量") + isEnabled: bool = Field(True, description="是否启用") + + +class ContractTemplateListItemVO(BaseModel): + """合同模板列表项。""" + + id: int = Field(..., description="模板ID") + templateCode: str = Field(..., description="模板编码") + title: str = Field(..., description="模板标题") + categoryId: int = Field(..., description="分类ID") + categoryName: str | None = Field(None, description="分类名称") + categoryIcon: str | None = Field(None, description="分类图标") + description: str | None = Field(None, description="模板简介") + filePath: str | None = Field(None, description="原始模板文件路径") + pdfFilePath: str | None = Field(None, description="PDF 预览文件路径") + fileFormat: str = Field(..., description="文件格式") + isFeatured: bool = Field(False, description="是否推荐") + createdAt: str | None = Field(None, description="创建时间") + updatedAt: str | None = Field(None, description="更新时间") + + +class ContractTemplatePageVO(BaseModel): + """合同模板分页结果。""" + + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + pageSize: int = Field(..., description="分页大小") + totalPages: int = Field(..., description="总页数") + templates: list[ContractTemplateListItemVO] = Field(default_factory=list, description="模板列表") + + +class ContractTemplateDetailVO(ContractTemplateListItemVO): + """合同模板详情。""" + + categoryDescription: str | None = Field(None, description="分类描述") + placeholderSchema: dict | None = Field(None, description="模板占位符结构") + + +class ContractTemplateSearchCategoryVO(BaseModel): + """搜索结果分类统计。""" + + id: int = Field(..., description="分类ID") + name: str = Field(..., description="分类名称") + searchCount: int = Field(0, description="当前关键词命中的模板数") + + +class ContractTemplateSearchResultVO(BaseModel): + """合同模板搜索结果。""" + + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + pageSize: int = Field(..., description="分页大小") + totalPages: int = Field(..., description="总页数") + templates: list[ContractTemplateListItemVO] = Field(default_factory=list, description="模板列表") + categoryStats: list[ContractTemplateSearchCategoryVO] = Field(default_factory=list, description="分类统计") +``` + + +## DTO 设计草案 + +建议新增文件: + +- `fastapi_modules/fastapi_leaudit/domian/Dto/contractTemplateDto.py` + +草稿如下: + +```python +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") + 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="排序方向") +``` + +说明: + +- 列表查询和搜索查询可以拆开,保持语义清晰 +- 如果后端最终希望统一实现,也可以在 service 层共用同一套内部查询对象 + + +## Controller 设计建议 + +建议新增文件: + +- `fastapi_modules/fastapi_leaudit/controllers/contractTemplateController.py` + +建议风格对齐现有 `BaseController` 用法,路由前缀采用: + +- `/v3/contract-templates` + +方法签名草案: + +```python +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.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), + ): + ... + + @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), + ): + ... + + @self.router.get("/search") + async def SearchContractTemplates( + q: str = Query(..., min_length=1, description="搜索关键词"), + category_id: int | None = Query(None, description="分类ID"), + 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), + ): + ... + + @self.router.get("/{TemplateId}") + async def GetContractTemplateDetail( + TemplateId: int, + payload: dict = Depends(verify_access_token), + ): + ... +``` + + +## Service 接口建议 + +建议新增文件: + +- `fastapi_modules/fastapi_leaudit/services/contractTemplateService.py` + +接口草案: + +```python +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]: + raise NotImplementedError + + @abstractmethod + async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO: + raise NotImplementedError + + @abstractmethod + async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO: + raise NotImplementedError + + @abstractmethod + async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None: + raise NotImplementedError +``` + + +## ServiceImpl 实现建议 + +建议新增文件: + +- `fastapi_modules/fastapi_leaudit/services/impl/contractTemplateServiceImpl.py` + +职责边界建议: + +1. `ListCategories` + - 直接查 `contract_categories` + - 如 `with_template_count=true`,通过聚合一次性返回每类模板数 + - 不要像前端当前逻辑那样逐分类循环查询 + +2. `ListTemplates` + - 负责分页、筛选、排序 + - 支持 `category_id` 和 `category_name` + - 统一返回 `ContractTemplatePageVO` + +3. `SearchTemplates` + - 可复用 `ListTemplates` 的底层查询逻辑 + - 额外补 `categoryStats` + - 搜索条件建议覆盖: + - `title` + - `description` + - `template_code` + - 分类名称 + +4. `GetTemplateDetail` + - 查单个模板及所属分类信息 + - 必要时回填 `placeholderSchema` + + +## 权限 key 建议 + +当前仓库权限格式见: + +- `module:resource:action` + +参考现有: + +- `entry_module:list:read` +- `evaluation_point:detail:read` +- `rules:create:write` + +因此建议新增以下权限: + +1. `contract_template:list:read` + - 查看模板列表 + - 对应: + - `GET /api/v3/contract-templates` + - `GET /api/v3/contract-templates/categories` + +2. `contract_template:search:read` + - 使用模板搜索 + - 对应: + - `GET /api/v3/contract-templates/search` + +3. `contract_template:detail:read` + - 查看模板详情 + - 对应: + - `GET /api/v3/contract-templates/{id}` + +如果权限体系希望更简化,也可以把 `categories` 和 `search` 并入 `contract_template:list:read`。但从产品语义上,保留 `search` 独立权限更清晰,方便以后做入口控制和审计。 + + +## Controller 权限校验建议 + +建议: + +1. 分类接口 + - 允许以下任一权限: + - `contract_template:list:read` + - `contract_template:search:read` + +2. 列表接口 + - `contract_template:list:read` + +3. 搜索接口 + - `contract_template:search:read` + +4. 详情接口 + - `contract_template:detail:read` + - 或兼容放宽为: + - `contract_template:detail:read` + - `contract_template:list:read` + +建议控制器内沿用现有 `_check_permission` 风格,允许多个 key 中任一通过。 + + +## RBAC 权限蓝图补充建议 + +建议在: + +- `fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py` + +的 `_MANAGEABLE_PERMISSION_BLUEPRINTS` 中补充: + +```python +{"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"}, +``` + + +## 命名与字段映射建议 + +数据库字段当前大概率仍是 snake_case,例如: + +- `template_code` +- `category_id` +- `file_path` +- `pdf_file_path` +- `updated_at` + +建议保持: + +- DB / SQL 层继续使用 snake_case +- 对外 VO 统一转为 camelCase + +这样可以和当前 `documentVo.py`、`evaluationPointGroupVo.py` 等风格保持一致,减少前端做字段转换的成本。 + + +## SQL / 查询实现建议 + +### 1. 分类数量统计 + +不要逐分类循环查询模板数量,建议使用聚合: + +- `contract_categories` 左连接 `contract_templates` +- 按分类分组统计 + +### 2. 搜索匹配 + +建议搜索条件覆盖: + +- 模板标题 +- 模板描述 +- 模板编码 +- 分类名称 + +### 3. 排序白名单 + +建议只允许以下排序字段: + +- `id` +- `title` +- `updated_at` +- `created_at` + +避免任意字段透传造成 SQL 注入或不可控查询。 + + +## 推荐实施顺序 + +1. 新增 `VO/DTO` +2. 新增 `Service` 接口与 `ServiceImpl` 空实现 +3. 新增 `Controller` +4. 在 RBAC 权限蓝图中补充权限 key +5. 前端 `contract-template/templates.ts` 从 PostgREST 切换到新后端接口 + + +## 最小落地范围 + +如果本轮只做最小可用闭环,建议先补齐: + +1. `GET /api/v3/contract-templates/categories` +2. `GET /api/v3/contract-templates` +3. `GET /api/v3/contract-templates/search` +4. `GET /api/v3/contract-templates/{id}` + +这样可以先解决: + +- 搜索首页 +- 搜索结果页 +- 列表页 +- 详情页 + + +## 结论 + +`contract-template` 模块当前缺的不是前端页面,而是新的业务后端接口层。推荐按现有仓库习惯新增独立的: + +- `contractTemplateController` +- `contractTemplateService` +- `contractTemplateServiceImpl` +- `contractTemplateDto` +- `contractTemplateVo` + +并优先落地 4 个只读接口,把 `search / list / detail` 从 PostgREST 迁出。 diff --git a/docs/合同模板搜索合同起草/合同模板权限蓝图补充建议.md b/docs/合同模板搜索合同起草/合同模板权限蓝图补充建议.md new file mode 100644 index 0000000..5de88c3 --- /dev/null +++ b/docs/合同模板搜索合同起草/合同模板权限蓝图补充建议.md @@ -0,0 +1,86 @@ +## 目标 + +补齐 `contract-template` 当前阶段只读能力所需的权限蓝图,仅覆盖: + +- 分类 +- 列表 +- 搜索 +- 详情 + +明确不包含: + +- 起草合同 +- 草稿管理 +- 合同编辑 + + +## 建议新增权限 key + +建议在 `fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py` 的 `_MANAGEABLE_PERMISSION_BLUEPRINTS` 中补充以下 3 个权限: + +```python +{"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"}, +``` + + +## 分类接口权限建议 + +接口: + +- `GET /api/v3/contract-templates/categories` + +建议权限策略: + +- 允许 `contract_template:list:read` +- 或 `contract_template:search:read` + +原因: + +- 分类数据同时服务于列表页和搜索页 +- 不建议单独再拆一个 `category:read` 权限,当前阶段收益不高 + + +## Controller 校验建议 + +### 分类 + +允许任一权限: + +- `contract_template:list:read` +- `contract_template:search:read` + +### 列表 + +- `contract_template:list:read` + +### 搜索 + +- `contract_template:search:read` + +### 详情 + +建议放宽为任一权限: + +- `contract_template:detail:read` +- `contract_template:list:read` + +原因: + +- 详情通常从列表页进入 +- 允许列表权限兼容详情访问,可以减少菜单和权限配置初期的阻塞 + + +## 当前阶段不应新增的权限 + +以下权限本轮不要进入蓝图: + +- `contract_draft:create:write` +- `contract_draft:update:write` +- `contract_draft:delete:delete` + +原因: + +- 你已经明确起草能力将重做为独立模块 +- 当前阶段只解决 `contract-template` 的只读接口迁移 diff --git a/docs/文档图片质量校验模块/文档图片质量校验模块第3版接口与落地清单.md b/docs/文档图片质量校验模块/文档图片质量校验模块第3版接口与落地清单.md new file mode 100644 index 0000000..cd66994 --- /dev/null +++ b/docs/文档图片质量校验模块/文档图片质量校验模块第3版接口与落地清单.md @@ -0,0 +1,1227 @@ +# 文档图片质量校验模块第3版接口与落地清单 + +## 1. 目标边界 + +本模块用于对案卷文档中的拍照图片、扫描图片、附件图片做“图片清晰度预警检测”。 + +本模块的边界固定如下: + +- 与现有 OCR 抽取、评查主流程完全独立。 +- 即使检测出图片模糊,也不能阻断上传、不能阻断 OCR、不能阻断评查。 +- 结果只做预警展示和留痕,分三档: + - `pass`:通过 + - `review`:疑似模糊待人工确认 + - `reject`:不通过需重拍 +- 第一版优先支持: + - 图片文件 + - PDF / 扫描 PDF + - 图片附件 + - PDF 附件 +- `doc/docx/wps` 第一版允许降级为“正文第 N 张图”定位,不强承诺精确页码。 + +## 2. 建议代码落点 + +### 2.1 后端目录 + +建议新增如下目录与文件: + +- `fastapi_modules/fastapi_leaudit/domian/vo/imageQualityVo.py` +- `fastapi_modules/fastapi_leaudit/services/imageQualityService.py` +- `fastapi_modules/fastapi_leaudit/services/impl/imageQualityServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/controllers/imageQualityController.py` +- `fastapi_modules/fastapi_leaudit/image_quality/tasks.py` +- `fastapi_modules/fastapi_leaudit/image_quality/runner.py` +- `fastapi_modules/fastapi_leaudit/image_quality/storage_adapter.py` +- `fastapi_modules/fastapi_leaudit/image_quality/input_resolver.py` +- `fastapi_modules/fastapi_leaudit/image_quality/extractors.py` +- `fastapi_modules/fastapi_leaudit/image_quality/detector.py` +- `fastapi_modules/fastapi_leaudit/image_quality/config_resolver.py` + +### 2.2 现有文件改动点 + +- `fastapi_modules/fastapi_leaudit/controllers/documentController.py` +- `fastapi_modules/fastapi_leaudit/services/documentService.py` +- `fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py` +- `fastapi_admin/config/_settings.py` +- `fastapi_admin/config/__init__.pyi` + +## 3. 新增 VO 建议 + +按当前仓库 `documentVo.py / auditVo.py / reviewPointVo.py` 风格,建议单独新增 `imageQualityVo.py`。 + +### 3.1 配置相关 VO + +```python +from pydantic import BaseModel, Field + + +class ImageQualityConfigItemVO(BaseModel): + """图片质量校验配置项。""" + + id: int = Field(..., description="配置ID") + scopeType: str = Field(..., description="作用域类型:global/doc_type/region/doc_type_region") + documentTypeId: int | None = Field(None, description="文档类型ID") + region: str | None = Field(None, description="地区") + enabled: bool = Field(..., description="是否启用") + warnThreshold: float | None = Field(None, description="疑似模糊阈值") + rejectThreshold: float | None = Field(None, description="不通过阈值") + maxImagesPerDoc: int | None = Field(None, description="单文档最大检测图片数") + maxConcurrency: int | None = Field(None, description="单任务并发数") + updatedAt: str | None = Field(None, description="更新时间") + + +class ImageQualityConfigUpsertDTO(BaseModel): + """图片质量校验配置新增/更新请求。""" + + scopeType: str = Field(..., description="作用域类型") + documentTypeId: int | None = Field(None, description="文档类型ID") + region: str | None = Field(None, description="地区") + enabled: bool = Field(..., description="是否启用") + warnThreshold: float | None = Field(None, description="疑似模糊阈值") + rejectThreshold: float | None = Field(None, description="不通过阈值") + maxImagesPerDoc: int | None = Field(None, description="单文档最大检测图片数") + maxConcurrency: int | None = Field(None, description="单任务并发数") +``` + +### 3.2 明细与摘要相关 VO + +```python +class ImageQualityItemVO(BaseModel): + """单张图片质检明细。""" + + itemId: int = Field(..., description="明细ID") + runId: int = Field(..., description="质检运行ID") + documentId: int = Field(..., description="文档ID") + documentFileId: int | None = Field(None, description="文档文件ID") + sourceKind: str = Field(..., description="来源类型") + sourceFileName: str | None = Field(None, description="来源文件名") + sourcePageNum: int | None = Field(None, description="来源页码") + imageIndexInPage: int | None = Field(None, description="页内图片序号") + imageIndexInFile: int | None = Field(None, description="文件内图片序号") + bbox: dict | list | None = Field(None, description="图片定位框") + qualityStatus: str = Field(..., description="pass/review/reject") + qualityScore: float | None = Field(None, description="清晰度分值") + reasonCode: str | None = Field(None, description="原因编码") + reasonText: str | None = Field(None, description="原因说明") + cropOssUrl: str | None = Field(None, description="裁剪图OSS地址") + displayText: str | None = Field(None, description="展示文案,例如第12页第1张图片模糊") + + +class ImageQualitySummaryVO(BaseModel): + """文档图片质检摘要。""" + + runId: int | None = Field(None, description="最新运行ID") + runStatus: str | None = Field(None, description="queued/running/completed/failed/skipped") + summaryStatus: str | None = Field(None, description="pass/review/reject") + skipReason: str | None = Field(None, description="跳过原因") + totalImages: int = Field(0, description="总图片数") + passCount: int = Field(0, description="通过数") + reviewCount: int = Field(0, description="待人工确认数") + rejectCount: int = Field(0, description="需重拍数") + warningText: str | None = Field(None, description="摘要提示文案") + finishedAt: str | None = Field(None, description="完成时间") + + +class ImageQualityDetailVO(BaseModel): + """文档图片质检详情。""" + + summary: ImageQualitySummaryVO = Field(..., description="质检摘要") + items: list[ImageQualityItemVO] = Field(default_factory=list, description="问题图片列表") +``` + +### 3.3 批量状态与重检相关 VO + +```python +class ImageQualityStatusItemVO(BaseModel): + """文档图片质检状态项。""" + + documentId: int = Field(..., description="文档ID") + runId: int | None = Field(None, description="最新运行ID") + runStatus: str | None = Field(None, description="运行状态") + summaryStatus: str | None = Field(None, description="摘要状态") + rejectCount: int = Field(0, description="需重拍数") + reviewCount: int = Field(0, description="待确认数") + updatedAt: str | None = Field(None, description="更新时间") + + +class ImageQualityRecheckVO(BaseModel): + """手工重检响应。""" + + runId: int = Field(..., description="新质检运行ID") + documentId: int = Field(..., description="文档ID") + status: str = Field(..., description="queued") + message: str = Field("", description="提示信息") +``` + +## 4. 对现有 Document VO 的扩展建议 + +建议直接扩充 `documentVo.py`,这样上传页、列表页、详情页可以少开新接口或少拼数据。 + +### 4.1 `DocumentUploadVO` 增加字段 + +```python +imageQualityEnabled: bool = Field(False, description="当前文档是否启用图片质量校验") +imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID") +imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态") +imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态") +``` + +### 4.2 `DocumentStatusItemVO` 增加字段 + +```python +imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID") +imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态") +imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态") +imageQualityRejectCount: int = Field(0, description="需重拍图片数") +imageQualityReviewCount: int = Field(0, description="待人工确认图片数") +``` + +### 4.3 `DocumentListItemVO` 增加字段 + +```python +imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID") +imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态") +imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态") +imageQualityIssueCount: int = Field(0, description="问题图片数") +imageQualityWarningText: str | None = Field(None, description="图片质量提示文案") +``` + +### 4.4 `DocumentDetailVO` 增加字段 + +```python +imageQualitySummary: ImageQualitySummaryVO | None = Field(None, description="图片质量摘要") +``` + +## 5. 新增 Service 接口签名 + +建议新建 `services/imageQualityService.py`,不要污染 `IDocumentService` 主职责。 + +```python +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.vo.imageQualityVo import ( + ImageQualityConfigItemVO, + ImageQualityConfigUpsertDTO, + ImageQualityDetailVO, + ImageQualityRecheckVO, + ImageQualityStatusItemVO, + ImageQualitySummaryVO, +) + + +class IImageQualityService(ABC): + """图片质量校验服务接口。""" + + @abstractmethod + async def DispatchForDocument( + self, + DocumentId: int, + TriggerUserId: int | None = None, + Force: bool = False, + Speed: str = "normal", + ) -> ImageQualityRecheckVO | None: + """按文档触发图片质量校验任务。""" + ... + + @abstractmethod + async def GetDocumentSummary( + self, + CurrentUserId: int, + DocumentId: int, + ) -> ImageQualitySummaryVO: + """获取文档图片质检摘要。""" + ... + + @abstractmethod + async def GetDocumentDetail( + self, + CurrentUserId: int, + DocumentId: int, + ) -> ImageQualityDetailVO: + """获取文档图片质检详情。""" + ... + + @abstractmethod + async def GetDocumentsStatus( + self, + CurrentUserId: int, + Ids: list[int], + ) -> list[ImageQualityStatusItemVO]: + """批量获取文档图片质检状态。""" + ... + + @abstractmethod + async def RecheckDocument( + self, + CurrentUserId: int, + DocumentId: int, + Speed: str = "normal", + ) -> ImageQualityRecheckVO: + """手工重跑文档图片质量校验。""" + ... + + @abstractmethod + async def ListConfigs(self) -> list[ImageQualityConfigItemVO]: + """获取图片质量校验配置列表。""" + ... + + @abstractmethod + async def UpsertConfig(self, Body: ImageQualityConfigUpsertDTO) -> ImageQualityConfigItemVO: + """新增或更新图片质量校验配置。""" + ... + + @abstractmethod + async def DeleteConfig(self, Id: int) -> None: + """删除图片质量校验配置。""" + ... +``` + +## 6. 对现有 DocumentService 的接入点建议 + +建议只在现有 `IDocumentService / DocumentServiceImpl` 中补“触发点”,不要把查询逻辑也塞进去。 + +### 6.1 `DocumentServiceImpl.Upload` + +在上传主文件和附件落库完成后,补一段: + +```python +await self.ImageQualityService.DispatchForDocument( + DocumentId=document.Id, + TriggerUserId=CreatedBy, + Force=False, + Speed=Speed, +) +``` + +要求: + +- try/except 包裹 +- 失败只记 warning log +- 不影响现有上传成功返回 + +### 6.2 `DocumentServiceImpl.AppendAttachments` + +在追加附件成功后,补一段: + +```python +await self.ImageQualityService.DispatchForDocument( + DocumentId=Id, + TriggerUserId=CurrentUserId, + Force=True, + Speed="normal", +) +``` + +原因: + +- 附件变化后,图片质检结果已过期 +- 需要按最新附件重新跑 + +## 7. Controller 路由建议 + +建议新建 `controllers/imageQualityController.py`,风格保持与 `documentController.py / auditController.py` 一致。 + +### 7.1 文档级路由 + +```python +@self.router.get("/documents/{DocumentId}/image-quality/summary", response_model=Result[ImageQualitySummaryVO]) +async def GetDocumentImageQualitySummary( + DocumentId: int, + payload: dict[str, Any] = Depends(verify_access_token), +): + """获取单个文档图片质量校验摘要。""" + + +@self.router.get("/documents/{DocumentId}/image-quality", response_model=Result[ImageQualityDetailVO]) +async def GetDocumentImageQualityDetail( + DocumentId: int, + payload: dict[str, Any] = Depends(verify_access_token), +): + """获取单个文档图片质量校验详情。""" + + +@self.router.post("/documents/{DocumentId}/image-quality/recheck", response_model=Result[ImageQualityRecheckVO]) +async def RecheckDocumentImageQuality( + DocumentId: int, + speed: str = Form("normal", description="执行速度档位:urgent/normal"), + payload: dict[str, Any] = Depends(verify_access_token), +): + """手工重跑单个文档图片质量校验。""" +``` + +### 7.2 批量状态路由 + +```python +@self.router.get("/documents/image-quality/status", response_model=Result[list[ImageQualityStatusItemVO]]) +async def GetDocumentsImageQualityStatus( + ids: str = Query(..., description="逗号分隔的文档ID列表"), + payload: dict[str, Any] = Depends(verify_access_token), +): + """批量获取文档图片质量校验状态。""" +``` + +### 7.3 配置管理路由 + +如果第一期不做后台配置页,可以先只保留 service,不先开放 controller。 + +如果要开接口,建议: + +```python +@self.router.get("/v3/image-quality/configs", response_model=Result[list[ImageQualityConfigItemVO]]) +async def ListImageQualityConfigs(): + """获取图片质量校验配置。""" + + +@self.router.post("/v3/image-quality/configs", response_model=Result[ImageQualityConfigItemVO]) +async def UpsertImageQualityConfig(Body: ImageQualityConfigUpsertDTO): + """新增或更新图片质量校验配置。""" + + +@self.router.delete("/v3/image-quality/configs/{ConfigId}", response_model=Result[None]) +async def DeleteImageQualityConfig(ConfigId: int): + """删除图片质量校验配置。""" +``` + +## 8. Celery 任务与函数签名建议 + +新增文件:`fastapi_modules/fastapi_leaudit/image_quality/tasks.py` + +```python +def resolve_image_quality_queue(speed: str = "normal") -> str: + """根据优先级返回图片质量校验队列名。""" + + +def dispatch_image_quality_task( + run_id: int, + *, + speed: str = "normal", + trigger_user_id: int | None = None, +) -> Any: + """投递图片质量校验任务。""" + + +@celery_app.task( + bind=True, + name="leaudit.image_quality.process_document", + acks_late=True, +) +def image_quality_process_document_task( + self, + run_id: int, + trigger_user_id: int | None = None, +) -> dict[str, Any]: + """Celery worker 入口。""" +``` + +新增文件:`fastapi_modules/fastapi_leaudit/image_quality/runner.py` + +```python +class ImageQualityRunner: + """图片质量校验执行器。""" + + async def Execute( + self, + RunId: int, + TriggerUserId: int | None = None, + ) -> dict[str, Any]: + """执行一次完整的图片质量校验。""" +``` + +## 9. SQL 草案文件命名建议 + +按你当前仓库习惯,建议直接放到: + +- `scripts/创建sql/` + +建议拆 3 个 SQL 草案文件。 + +### 9.1 表结构 SQL + +文件名: + +- `scripts/创建sql/schema_add_image_quality_module.sql` + +内容范围: + +- `leaudit_image_quality_configs` +- `leaudit_image_quality_runs` +- `leaudit_image_quality_items` +- 索引 +- 唯一约束 +- 软删除字段 + +### 9.2 权限点 SQL + +文件名: + +- `scripts/创建sql/seed_image_quality_permissions.sql` + +内容范围: + +- 图片质量校验查询权限 +- 图片质量校验重检权限 +- 图片质量配置管理权限 + +### 9.3 路由/入口 SQL + +文件名: + +- `scripts/创建sql/seed_image_quality_routes.sql` + +内容范围: + +- 新增接口路由资源 +- 如需配置后台菜单,再补菜单项 + +## 10. 建议表结构草案 + +### 10.1 `leaudit_image_quality_configs` + +建议字段: + +- `id BIGSERIAL PRIMARY KEY` +- `scope_type VARCHAR(32) NOT NULL` +- `document_type_id BIGINT NULL` +- `region VARCHAR(64) NULL` +- `enabled BOOLEAN NOT NULL DEFAULT TRUE` +- `warn_threshold NUMERIC(10,4) NULL` +- `reject_threshold NUMERIC(10,4) NULL` +- `max_images_per_doc INTEGER NULL` +- `max_concurrency INTEGER NULL` +- `created_at TIMESTAMP NOT NULL DEFAULT NOW()` +- `updated_at TIMESTAMP NOT NULL DEFAULT NOW()` +- `deleted_at TIMESTAMP NULL` + +建议索引: + +- `idx_leaudit_image_quality_configs_scope` +- `idx_leaudit_image_quality_configs_doc_type` +- `idx_leaudit_image_quality_configs_region` + +### 10.2 `leaudit_image_quality_runs` + +建议字段: + +- `id BIGSERIAL PRIMARY KEY` +- `document_id BIGINT NOT NULL` +- `document_file_id BIGINT NULL` +- `status VARCHAR(32) NOT NULL DEFAULT 'queued'` +- `skip_reason VARCHAR(64) NULL` +- `summary_status VARCHAR(32) NULL` +- `total_images INTEGER NOT NULL DEFAULT 0` +- `pass_count INTEGER NOT NULL DEFAULT 0` +- `review_count INTEGER NOT NULL DEFAULT 0` +- `reject_count INTEGER NOT NULL DEFAULT 0` +- `task_id VARCHAR(255) NULL` +- `error_message TEXT NULL` +- `started_at TIMESTAMP NULL` +- `finished_at TIMESTAMP NULL` +- `created_by BIGINT NULL` +- `created_at TIMESTAMP NOT NULL DEFAULT NOW()` +- `updated_at TIMESTAMP NOT NULL DEFAULT NOW()` +- `deleted_at TIMESTAMP NULL` + +建议索引: + +- `idx_leaudit_image_quality_runs_document_id` +- `idx_leaudit_image_quality_runs_status` +- `idx_leaudit_image_quality_runs_document_created_at` + +### 10.3 `leaudit_image_quality_items` + +建议字段: + +- `id BIGSERIAL PRIMARY KEY` +- `run_id BIGINT NOT NULL` +- `document_id BIGINT NOT NULL` +- `document_file_id BIGINT NULL` +- `source_kind VARCHAR(64) NOT NULL` +- `source_file_name VARCHAR(255) NULL` +- `source_page_num INTEGER NULL` +- `image_index_in_page INTEGER NULL` +- `image_index_in_file INTEGER NULL` +- `bbox_json JSONB NULL` +- `image_key VARCHAR(255) NULL` +- `parent_image_key VARCHAR(255) NULL` +- `crop_oss_url TEXT NULL` +- `quality_status VARCHAR(32) NOT NULL` +- `quality_score NUMERIC(10,4) NULL` +- `reason_code VARCHAR(64) NULL` +- `reason_text TEXT NULL` +- `extra_json JSONB NULL` +- `created_at TIMESTAMP NOT NULL DEFAULT NOW()` +- `updated_at TIMESTAMP NOT NULL DEFAULT NOW()` +- `deleted_at TIMESTAMP NULL` + +建议索引: + +- `idx_leaudit_image_quality_items_run_id` +- `idx_leaudit_image_quality_items_document_id` +- `idx_leaudit_image_quality_items_quality_status` +- `idx_leaudit_image_quality_items_source_page_num` + +## 11. 配置项建议 + +在 `fastapi_admin/config/_settings.py` 的 `LeauditSettings` 增加: + +```python +LEAUDIT_IMAGE_QUALITY_ENABLED: bool = False +LEAUDIT_IMAGE_QUALITY_WARN_THRESHOLD: float = 0.45 +LEAUDIT_IMAGE_QUALITY_REJECT_THRESHOLD: float = 0.30 +LEAUDIT_IMAGE_QUALITY_MAX_IMAGES_PER_DOC: int = 80 +LEAUDIT_IMAGE_QUALITY_MAX_CONCURRENCY: int = 4 +LEAUDIT_IMAGE_QUALITY_QUEUE_NORMAL: str = "leaudit.image_quality.normal" +LEAUDIT_IMAGE_QUALITY_QUEUE_URGENT: str = "leaudit.image_quality.urgent" +LEAUDIT_IMAGE_QUALITY_TIMEOUT: int = 120 +``` + +并同步补到 `fastapi_admin/config/__init__.pyi`。 + +## 12. 前端接口命名建议 + +建议前端 API 文件新增: + +- `legal-platform-frontend/lib/api/legacy/files/image-quality.ts` + +建议函数: + +```ts +export async function getDocumentImageQualitySummary(documentId: number) +export async function getDocumentImageQualityDetail(documentId: number) +export async function getDocumentsImageQualityStatus(ids: number[]) +export async function recheckDocumentImageQuality(documentId: number, speed?: string) +``` + +## 13. 第一版实施顺序 + +建议按下面顺序做: + +1. 先补 SQL 与后端表模型 +2. 再补 `imageQualityVo.py` +3. 再补 `IImageQualityService / ImageQualityServiceImpl` +4. 再补 `tasks.py / runner.py` +5. 再在 `DocumentServiceImpl.Upload / AppendAttachments` 挂触发 +6. 再补 `imageQualityController.py` +7. 最后接前端上传页、列表页、详情页 + +## 14. 结论 + +按当前仓库风格,这个模块最稳妥的落地方式是: + +- 新开独立 `VO + Service + Controller + Celery task + SQL` +- 只在 `DocumentServiceImpl` 做触发,不侵入现有 `AuditServiceImpl` +- 对外暴露文档级摘要、明细、批量状态、手工重检四类接口 +- SQL 先拆成结构、权限、路由三个脚本,方便分阶段上线 + +这个拆法能保证图片质量校验模块后续可单独演进,不会把现有 OCR / 评查主链路拖乱。 + +## 15. 页码定位策略修正版 + +这一节用于修正第一版里对 `doc/docx/wps` 页码定位的表述,避免研发误以为可以直接靠当前 `python-docx` 兜底逻辑拿到真实页码。 + +### 15.1 现有 OCR / 评查链路里的页码定位是怎么来的 + +当前系统里,评查详情页能展示字段页码、部分情况下还能做定位高亮,主来源并不是“原始 Word 文档页码”,而是下面两层能力: + +- 第一层:OCR/归一化结果自带页结构 + - `ocr_result.pages[].page_num` + - OCR chunk 的 `bbox` + - `field_positions` 里的 `pageNum / bbox / matchPosition` +- 第二层:详情页兜底文本匹配 + - 对 PDF 重新逐页抽文本,再把字段文本匹配回页码 + - 这是兜底逻辑,不是主定位来源 + +也就是说,当前评查模块“真正稳定可复用”的页码语义,主要来自 OCR / normalized document,而不是详情页自己的回推逻辑。 + +### 15.2 当前项目里 `docx` 的兜底页码能力并不是真分页 + +当前 `documentServiceImpl.py` 里的 `_extract_page_texts_from_docx()` 实现,实际是: + +- 读取段落和表格文本 +- 拼成整篇文档文本 +- 最终只返回 `[(1, text)]` + +这意味着: + +- 当前详情页兜底逻辑对 `docx` 并没有真实分页能力 +- 它只是把整篇文档退化成“第 1 页” +- 因此不能把这条逻辑当成 `docx` 图片定位的基础 + +结论要固定为: + +- 当前系统里 `docx` 不是完全不能定位 +- 但不能依赖现有 `python-docx` 兜底逻辑做精确页码 +- 如果要把 `docx` 图片定位做准,必须优先复用 OCR / 归一化后的页结构 + +### 15.3 图片质量模块对 `docx` 的正确做法 + +图片质量模块在处理 `doc/docx/wps` 时,页码定位策略建议按优先级分层: + +1. 优先复用 OCR 结果中的页结构和视觉对象定位 +2. 其次复用 `visual_manifest` 中已有的视觉对象元数据 +3. 如果 OCR 已能给出该图片或父图像所在页,则直接采用该页码 +4. 如果拿不到稳定页码,则降级为“正文第 N 张图”或“附件第 N 张图” + +推荐优先使用的字段包括: + +- `ocr_result.pages[].page_num` +- `ocr_result.visual_manifest` +- `visual_manifest` 中对象的 `page_num` +- `visual_manifest` 中对象的 `bbox` +- `image_key / parent_image_key` + +这样做的本质是: + +- 不自己重新发明 Word 分页 +- 而是复用现有 OCR pipeline 已经归一化出来的页概念 + +### 15.4 为什么图片质量模块不能直接复用详情页 `docx` 兜底逻辑 + +因为那条逻辑只适合“文本字段没有页码时,尽量补一个最小可用页码”,不适合图片质量场景。 + +图片质量场景要解决的是: + +- 哪一张图片模糊 +- 它在第几页 +- 它在页内第几张 +- 最好还能给出 bbox 或裁剪图 + +而当前 `docx` 兜底逻辑没有: + +- 真分页 +- 图片级索引 +- 页内图片序号 +- 图片 bbox + +所以如果直接复用它,只会得到一个看似有页码、其实不可用的假定位。 + +### 15.5 图片质量模块的页码定位分级策略 + +建议在方案里明确写死: + +- `PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件` + - 第一优先级支持精确定位 + - 目标输出:`source_page_num + image_index_in_page + bbox` +- `doc/docx/wps` + - 第一优先级复用 OCR / visual_manifest 的页码与 bbox + - 第二优先级降级为 `image_index_in_file` + - 第一版不承诺所有 Office 文档都能稳定精确到预览页码 + +也就是说,`docx` 可以做定位,但实现前提不是当前 `python-docx` 的退化逻辑,而是 OCR 归一化之后的页结构。 + +### 15.6 对图片质量模块的实现修正 + +基于上面的分析,图片质量模块实现时建议补一个约束: + +- 对 `doc/docx/wps`,图片抽取和质量判断可以独立做 +- 但页码回指优先放在“抽图后结合 OCR visual metadata 回填”这一层完成 +- 不允许直接调用当前 `_extract_page_texts_from_docx()` 来生成图片页码 + +更具体地说: + +- `extractors.py` 负责抽图和生成图片级唯一索引 +- `detector.py` 负责清晰度三档判断 +- `runner.py` 在汇总结果前,优先尝试将图片索引和 OCR/visual_manifest 做关联 +- 关联成功则写入 `source_page_num / bbox` +- 关联失败则写入 `image_index_in_file`,前端展示“正文第 N 张图” + +### 15.7 最终结论 + +本项目当前“评查页码定位”对 `docx` 的可用性,主要来自 OCR 归一化页结构,而不是 `python-docx` 的原生分页能力。 + +因此文档图片质量校验模块如果要把 `docx` 也做准,正确方向是: + +- 参考现有 OCR / 评查流程的页结构设计 +- 复用 `ocr_result.pages / visual_manifest / bbox / image_key` +- 避免把当前 `docx` 文本兜底逻辑误当成图片级定位能力 + +这一点在后续实施时应作为明确技术约束,不建议再走“直接解析 Word 文本推页码”的路线。 + +## 16. 图片索引表与 OCR visual_manifest 关联策略 + +这一节用于把图片质量模块里最关键的“抽图记录如何回绑到 OCR 页码与 bbox”讲清楚,尤其是 `doc/docx/wps` 场景。 + +### 16.1 为什么必须做关联层 + +图片质量模块自己的 `extractors.py` 会先把图片从原始文档里抽出来,并落到 `leaudit_image_quality_items`。 + +但抽图本身只能保证下面这些信息: + +- 这张图片来自哪个文档文件 +- 它是主文件还是附件 +- 它是文件内第几张图 +- 它在抽图阶段的原始宽高、字节、hash + +仅靠这些信息,还不够支持前端稳定回指到: + +- 第几页 +- 页内第几张 +- 具体 bbox + +所以必须增加一层“图片索引表与 OCR visual metadata 的关联层”,把抽图记录和 OCR/visual_manifest 里的页码、坐标、父图像标识打通。 + +### 16.2 建议增加的落库字段 + +在 `leaudit_image_quality_items` 里,建议补充或明确以下字段: + +- `image_sha256` + - 抽图后二进制 hash +- `image_width` + - 抽图宽度 +- `image_height` + - 抽图高度 +- `source_ext` + - 来源文件扩展名 +- `ocr_page_num` + - 关联到 OCR 后得到的页码 +- `ocr_bbox_json` + - 关联到 OCR 后得到的 bbox +- `ocr_image_key` + - 关联到 OCR visual object 的 image_key +- `ocr_parent_image_key` + - 关联到 OCR visual object 的 parent_image_key +- `ocr_match_mode` + - 关联命中方式 +- `ocr_match_score` + - 关联命中置信度 + +说明: + +- `source_page_num` 表示模块自己理解的来源页码 +- `ocr_page_num` 表示从 OCR / visual_manifest 里回填的页码 +- 第一版前端展示时,优先取 `ocr_page_num`,取不到再回退到 `source_page_num` + +### 16.3 关联优先级建议 + +建议在 `runner.py` 里做一段统一的关联流程,优先级从高到低如下: + +1. `image_key / parent_image_key` 直接命中 +2. 图片二进制 hash 命中 +3. 图片尺寸 + 裁剪相似度命中 +4. 页级候选范围内按 bbox / area 比例匹配 +5. 全部失败则降级为文件内序号定位 + +这套顺序的原因是: + +- `image_key / parent_image_key` 一旦能命中,最稳定 +- hash 命中次稳,但前提是 OCR side 和抽图 side 用的是同一图像字节 +- 尺寸和相似度可以做弱匹配 +- bbox/area 更适合已经知道页候选时做局部定位 + +### 16.4 各类文件的推荐关联策略 + +#### 16.4.1 PDF / 扫描PDF / PDF附件 + +推荐策略: + +- 抽图阶段直接记录 `source_page_num` +- OCR 若返回同页 visual object,则用页内 bbox 做二次确认 +- 最终定位以 `source_page_num + image_index_in_page + bbox` 为准 + +这一类文件通常不需要过度依赖 `visual_manifest`,因为自身抽图时就有稳定页概念。 + +#### 16.4.2 图片文件 / 图片附件 + +推荐策略: + +- `source_page_num` 固定记为 `1` +- `image_index_in_page = 1` +- 若 OCR 输出 visual object,则仅作为 bbox 或 `image_key` 补充 + +这类文件定位最简单,本身就是单页单图。 + +#### 16.4.3 `doc/docx/wps` + +推荐策略: + +- 抽图阶段只保证: + - `image_index_in_file` + - `image_sha256` + - `image_width / image_height` + - `source_file_name` +- 页码不要在抽图阶段硬算 +- 等 OCR/visual_manifest 结果出来后,再做回填 + +这一类文件的关联优先级建议是: + +1. `parent_image_key` 命中 +2. `image_key` 命中 +3. hash 命中 +4. 尺寸与内容相似度命中 +5. 命不中则只展示“正文第 N 张图” + +### 16.5 建议的 `ocr_match_mode` 枚举 + +建议在 `ocr_match_mode` 里固定以下值,方便后续排查: + +- `parent_image_key_exact` +- `image_key_exact` +- `image_sha256_exact` +- `size_similarity_match` +- `bbox_overlap_match` +- `page_candidate_match` +- `file_index_fallback` +- `no_match` + +这样后面如果业务反馈“页码不准”,可以直接按命中模式排查是哪一层出了问题。 + +### 16.6 `visual_manifest` 关联的实现建议 + +建议把这块逻辑放在 `image_quality/runner.py` 或单独的 `image_quality/locator.py` 中,职责清晰一点: + +- `extractors.py` + - 只负责抽图 + - 不负责猜页码 +- `detector.py` + - 只负责判断图片清晰度 +- `locator.py` 或 `runner.py` + - 负责把图片索引记录与 OCR `visual_manifest` 做关联 + - 回填 `ocr_page_num / ocr_bbox_json / ocr_match_mode` + +也就是说,页码定位不是抽图逻辑的一部分,而是“抽图后 + OCR结果可用后”的补全逻辑。 + +### 16.7 为什么建议先落图片索引,再做关联 + +因为如果把“抽图、关联、检测”混在一起,会有两个问题: + +- 当 OCR 结果未就绪时,图片质量模块会被迫等待主流程 +- 当 OCR side 结构调整时,图片质量模块会被连带影响 + +正确拆法是: + +1. 先独立抽图并持久化图片索引 +2. 独立跑清晰度检测 +3. 如果 OCR 结果已就绪,则补做 visual_manifest 关联 +4. 如果 OCR 结果尚未就绪,则先以弱定位展示,后续可异步补齐 + +这样既保持了两条 pipeline 独立,又能最大化复用 OCR 页结构。 + +### 16.8 页面展示的取值优先级 + +前端展示“第几页/第几张图”时,建议按下面顺序取值: + +1. `ocr_page_num + image_index_in_page` +2. `source_page_num + image_index_in_page` +3. `image_index_in_file` +4. 仅展示来源文件名 + +示例文案: + +- `主文件第 12 页第 1 张图片模糊` +- `附件《现场照片2.pdf》第 3 页第 2 张图片疑似模糊` +- `正文第 5 张内嵌图片模糊` +- `附件《取证照片.jpg》图片模糊` + +### 16.9 对第一版实施的建议收口 + +为了控制复杂度,第一版建议这样收: + +- `PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件` + - 做强定位 +- `doc/docx/wps` + - 先把 OCR `visual_manifest` 关联能力做出来 + - 关联成功就展示页码 + - 关联失败就降级到“正文第 N 张图” + +不要第一版就要求所有 Word/WPS 内嵌图都 100% 精确到预览页码,否则开发成本和误判风险都会明显上升。 + +### 16.10 最终结论 + +图片质量模块能不能把 `docx` 场景做准,关键不在“能不能把图抽出来”,而在“抽出来以后,能不能与 OCR visual metadata 建立稳定关联”。 + +所以第一版技术路线应明确为: + +- 先做图片索引表 +- 再做 visual_manifest 关联层 +- 最后才是页码/坐标展示 + +这比直接依赖 `python-docx` 或手工猜 Word 分页要稳得多,也更贴合当前项目已有的 OCR / 评查体系。 + +## 17. `extractors.py` 职责拆分与 `runner.py` 执行时序 + +这一节用于把模块内部分层再落细一点,方便研发直接按文件拆任务。 + +### 17.1 模块职责分层建议 + +建议图片质量模块按下面几层拆,不要把所有逻辑堆在一个 service 或一个 task 文件里。 + +#### 17.1.1 `input_resolver.py` + +职责: + +- 读取文档主文件与附件 +- 统一返回原始文件输入列表 +- 不做抽图 +- 不做检测 +- 不做 OCR 关联 + +建议输出结构: + +- `document_id` +- `document_file_id` +- `file_role` +- `file_name` +- `file_ext` +- `mime_type` +- `source_type` +- `source_path` +- `file_bytes` + +#### 17.1.2 `extractors.py` + +职责: + +- 针对不同文件类型抽图 +- 生成图片级索引记录 +- 记录图片最基础元数据 +- 不负责模糊判断 +- 不负责最终页码回指策略 + +建议内部再拆为: + +- `extract_images_from_pdf()` +- `extract_images_from_image_file()` +- `extract_images_from_docx()` +- `extract_images_from_attachment()` +- `persist_image_index_items()` + +建议统一输出的数据结构: + +- `documentId` +- `documentFileId` +- `sourceKind` +- `sourceFileName` +- `sourcePageNum` +- `imageIndexInPage` +- `imageIndexInFile` +- `imageSha256` +- `imageWidth` +- `imageHeight` +- `imageBytes` +- `extraJson` + +其中: + +- `PDF / 扫描PDF / 图片文件 / 图片附件` 可以在这里直接给出 `sourcePageNum` +- `doc/docx/wps` 这里不强行给精确页码,允许先留空 + +#### 17.1.3 `detector.py` + +职责: + +- 对抽出的图片执行清晰度检测 +- 返回三档结果 +- 输出原因码和说明 +- 不负责页码定位 + +建议统一返回: + +- `qualityStatus` +- `qualityScore` +- `reasonCode` +- `reasonText` + +建议原因码先收敛为有限集合: + +- `blur_detected` +- `low_resolution` +- `over_exposure` +- `under_exposure` +- `motion_blur` +- `text_unreadable` +- `detector_timeout` +- `detector_failed` + +#### 17.1.4 `locator.py` + +职责: + +- 读取 OCR / visual_manifest +- 把图片索引记录与 visual object 做关联 +- 回填 `ocr_page_num / ocr_bbox_json / ocr_match_mode` +- 不负责清晰度打分 + +如果第一期不想单独开 `locator.py`,也可以先内聚到 `runner.py`,但职责概念上仍然建议独立。 + +#### 17.1.5 `storage_adapter.py` + +职责: + +- 创建 run +- 批量写入 image items +- 更新图片检测结果 +- 更新关联结果 +- 汇总 run 主状态 + +不要把业务判断写进这里,这里只做存取。 + +### 17.2 `extractors.py` 建议的实现规则 + +#### 17.2.1 PDF 抽图规则 + +建议: + +- 按页遍历 +- 同页内按出现顺序编号 `image_index_in_page` +- 全文件维度编号 `image_index_in_file` +- 能拿到 bbox 就记录 bbox +- 原始图片字节计算 `sha256` + +最终目标: + +- PDF 在抽图阶段就拿到强定位能力 + +#### 17.2.2 图片文件抽图规则 + +建议: + +- 单文件直接视为 1 页 1 图 +- `sourcePageNum = 1` +- `imageIndexInPage = 1` +- `imageIndexInFile = 1` + +#### 17.2.3 `doc/docx/wps` 抽图规则 + +建议: + +- 只抽出内嵌图片 +- 保证文件内顺序稳定 +- 第一版不在这里做页码推断 +- 重点保留: + - `image_sha256` + - `image_width` + - `image_height` + - `image_index_in_file` + +原因: + +- 这类文件的难点不在抽图,而在“抽图后如何挂回 OCR 页结构” + +#### 17.2.4 附件抽图规则 + +建议: + +- 每个附件独立视为一个来源文件 +- 不要沿用主文件页码空间 +- `source_file_name` 必填 +- 前端展示时允许出现: + - `附件《xxx.pdf》第 2 页第 1 张图片模糊` + - `附件《xxx.jpg》图片模糊` + +### 17.3 `runner.py` 建议执行时序 + +建议整条图片质量任务按下面时序执行: + +1. 创建或确认 `image_quality_run` +2. 更新 run 状态为 `running` +3. 调用 `input_resolver.py` 读取主文件与附件 +4. 调用 `extractors.py` 抽图 +5. 将抽图结果落表 `leaudit_image_quality_items` +6. 判断是否无图 +7. 若无图则 run 标记 `skipped/no_images` +8. 若有图则进入并发检测 +9. 调用 `detector.py` 批量检测,回写每条 item 的质量结果 +10. 尝试读取 OCR / visual_manifest +11. 若可读,则做 `locator` 关联并回填页码/bbox +12. 汇总 `pass/review/reject` 计数 +13. 更新 run 的 `summary_status` +14. 标记 run `completed` 或 `partial_failed` + +### 17.4 推荐时序图口径 + +可以把执行理解成下面这条线: + +1. 上传成功 +2. 文档主流程正常投递 OCR/评查 +3. 图片质量任务独立投递 +4. 图片质量任务先抽图、先检测 +5. OCR 若先完成,则图片质量任务顺手做 visual_manifest 关联 +6. OCR 若未完成,则图片质量任务先给弱定位结果 +7. 后续可通过补偿任务把弱定位升级成强定位 + +这意味着: + +- 图片质量任务不依赖 OCR 主任务完成后才能启动 +- 但它可以在 OCR 结果可用时变得更准 + +### 17.5 是否需要补偿任务 + +建议保留“可选补偿任务”设计,但第一版不强求一定实现。 + +补偿任务适合处理: + +- 图片质检先完成,但 OCR 尚未完成 +- 首次只拿到了 `image_index_in_file` +- OCR 完成后再补齐 `ocr_page_num / bbox` + +如果要做,建议命名: + +- `leaudit.image_quality.relocate_by_ocr` + +但第一版也可以先不单独开 task,而是在详情查询时做一次懒补偿,或者在 OCR 主流程结束后顺手触发一次回填。 + +### 17.6 `runner.py` 的失败处理原则 + +建议明确: + +- 抽图失败: + - 当前 run 标记 `failed` + - 不影响文档主流程 +- 单张图片检测失败: + - 当前 item 标记失败原因 + - run 最终允许为 `partial_failed` +- OCR 关联失败: + - 不算质检失败 + - 只算定位降级 +- 全部图片检测完成但都无法定位页码: + - run 仍可 `completed` + - 只是展示层回退到“第 N 张图” + +也就是说,质量检测失败和定位失败要分开统计,不能混成一类。 + +### 17.7 推荐的汇总规则 + +建议 `runner.py` 最终汇总时按下面规则判断: + +- 只要存在至少 1 张 `reject`,`summary_status = reject` +- 否则只要存在至少 1 张 `review`,`summary_status = review` +- 否则若全部 `pass`,`summary_status = pass` +- 若任务本身没跑起来,则 `status = failed` +- 若任务跑完但部分图片检测失败,则 `status = partial_failed` +- 若命中无图、纯文本、开关关闭,则 `status = skipped` + +### 17.8 第一版最小可交付实现 + +为了避免第一版过重,建议把可交付标准压到下面这一级: + +- `extractors.py` + - 先支持 PDF、图片、docx 内嵌图、附件 +- `detector.py` + - 先给出稳定三档判断 +- `runner.py` + - 先能完成抽图、检测、汇总 +- `locator` + - 先支持 OCR `visual_manifest` 存在时的强关联 + - 不存在时允许降级 + +只要能做到: + +- 不影响主流程 +- 能把问题图片列出来 +- 大多数 PDF/图片类文档能精确到页 +- `docx` 至少能稳定定位到“正文第 N 张图” + +第一版就已经是可上线状态。 + +### 17.9 最终建议 + +研发拆任务时,建议直接按下面方式分: + +- A 同学:`SQL + VO + Controller + Service` +- B 同学:`input_resolver + extractors` +- C 同学:`detector + runner` +- D 同学:`locator / OCR visual_manifest 关联` +- 前端同学:`上传页 + 列表页 + 详情页展示` + +这样拆分写入面冲突最小,也最贴合当前仓库结构。 diff --git a/docs/项目总览/前端分支提交与合并防覆盖操作规范-2026-05-19.md b/docs/项目总览/前端分支提交与合并防覆盖操作规范-2026-05-19.md new file mode 100644 index 0000000..35909ca --- /dev/null +++ b/docs/项目总览/前端分支提交与合并防覆盖操作规范-2026-05-19.md @@ -0,0 +1,456 @@ +# 前端分支提交与合并防覆盖操作规范 + +## 适用范围 + +本文档适用于 `legal-platform-frontend` 前端仓库,重点解决以下高频问题: + +- 本地有未提交改动时,如何安全合并别人的分支 +- `main`、`wren-dev`、`shiy-dev` 等并行开发分支之间,如何避免代码被覆盖 +- 为什么“提交历史已经包含某个 commit”,但代码内容实际丢了 +- 如何做正确的提交、推送、PR 和合并后校验 + +--- + +## 一、核心原则 + +### 1. 不要在脏工作区直接合并 + +合并前必须先执行: + +```bash +git status +``` + +如果存在未提交改动,必须先处理: + +- 能独立提交的,先提交 +- 暂时不想提交的,先 `stash` + +例如: + +```bash +git stash -u +``` + +否则很容易出现: + +- 合并过程把本地改动混进别人的改动 +- 冲突解决时误选本地旧代码 +- 最终提交混杂多个需求,后续难以回溯 + +### 2. 不要把“历史包含”误判成“功能保留” + +以下命令只能说明“某个提交进入过历史”: + +```bash +git branch --contains +``` + +但它**不能说明这次提交改过的内容现在还保留在代码树里**。 + +实际开发中常见情况是: + +- A 分支的提交先合入 `main` +- 后续 `main` 再合到 B 分支 +- 冲突时错误选择了旧版本 +- 结果:`commit` 在历史里,但 `patch` 被覆盖没了 + +所以合并后必须额外做“内容保留校验”。 + +### 3. 冲突处理不能图快全选一边 + +遇到冲突时,不能默认: + +- 全部选 `ours` +- 全部选 `theirs` + +必须逐文件判断: + +- 哪些是对方新增能力 +- 哪些是我方已有修复 +- 哪些要手工拼接 + +尤其是以下类型文件最容易被误覆盖: + +- 页面组件 +- API 路由 +- 配置文件 +- 公共组件 +- 菜单/路由白名单 + +### 4. 合并完成后必须做“保留性校验” + +至少要检查三件事: + +1. 提交历史是否进入 +2. 关键文件是否仍保留目标改动 +3. 关键标识是否还能在代码中搜到 + +--- + +## 二、标准操作流程 + +## 1. 合并别人的分支到自己分支 + +假设目标是:把 `origin/shiy-dev` 合到当前 `wren-dev` + +### 第一步:同步远程 + +```bash +git fetch origin +``` + +### 第二步:检查本地工作区 + +```bash +git status +``` + +如果有未提交改动: + +```bash +git stash -u +``` + +或者先拆分提交。 + +### 第三步:确认自己当前所在分支 + +```bash +git branch --show-current +``` + +必须确认当前就在 `wren-dev`,不要站错分支。 + +### 第四步:执行合并 + +```bash +git merge --no-ff origin/shiy-dev +``` + +如果要明确记录来源,建议使用: + +```bash +git merge --no-ff origin/shiy-dev -m "merge: sync origin/shiy-dev into wren-dev" +``` + +### 第五步:如果冲突,逐文件处理 + +处理完冲突后: + +```bash +git add <冲突文件> +git commit +``` + +### 第六步:恢复之前的 stash + +```bash +git stash pop +``` + +如果 `stash pop` 冲突,不要慌,继续按文件处理。 + +--- + +## 2. 合并 `main` 到自己分支 + +目标:保持 `wren-dev` 跟上主线进度 + +标准步骤: + +```bash +git fetch origin +git status +git stash -u # 如果有本地未提交改动 +git merge --no-ff origin/main -m "merge: sync origin/main into wren-dev" +git stash pop # 如果前面 stash 了 +``` + +说明: + +- 不要直接把 `main` 切到当前脏工作区里操作 +- 如果本地 `main` 有 worktree,优先在独立 worktree 同步 +- 当前主开发工作区建议长期停留在 `wren-dev` + +--- + +## 3. 合并后做“内容保留校验” + +这是最关键的一步。 + +### 先检查目标提交是否进入历史 + +```bash +git branch --contains +``` + +### 再检查关键文件内容是否保住 + +```bash +git diff ..HEAD -- <关键文件> +``` + +如果看到的是“反向撤销”差异,说明: + +- 历史里有这个提交 +- 但代码内容被后续 merge 覆盖掉了 + +### 再搜关键符号 + +例如某次改动新增了: + +- `ENTRY_MODULE_ROUTE_OPTIONS` +- `FormSelect` +- `isAllowedEntryModuleRoutePath` + +就应该执行: + +```bash +rg "ENTRY_MODULE_ROUTE_OPTIONS|FormSelect|isAllowedEntryModuleRoutePath" . +``` + +如果关键标识不在,说明功能实际上没有保住。 + +--- + +## 三、发现“历史包含但代码没了”怎么办 + +这是本项目已经真实发生过的情况。 + +## 处理原则 + +不要重新大范围 merge。 + +正确做法是: + +- 精确定位丢失的是哪些文件 +- 只恢复这些文件 +- 单独提交 + +### 推荐命令 + +```bash +git restore --source=<目标提交> -- <文件1> <文件2> ... +``` + +例如: + +```bash +git restore --source=2b912ca -- \ + app/(audit)/documents/list/DocumentsListClient.tsx \ + app/(audit)/entry-modules/new/EntryModuleNewClient.tsx \ + app/api/pdf-proxy/route.ts \ + components/layout/Sidebar.tsx \ + lib/config/entry-module-route-options.ts +``` + +然后单独提交: + +```bash +git add <这些文件> +git commit -m "fix: restore shiy-dev entry route whitelist changes" +git push origin wren-dev +``` + +这种方式最安全,且不会影响你当前其他本地需求改动。 + +--- + +## 四、提交规范 + +## 1. 一个提交只解决一类问题 + +不要把以下内容混在一个 commit: + +- 聊天功能修复 +- 公文审查 UI 收敛 +- 合同模板页面开发 +- 分支恢复补丁 + +应该拆成: + +- `fix: remove govdoc inspector file info tab` +- `fix: restore shiy-dev entry route whitelist changes` +- `feat: add contract template search results page` + +## 2. 提交前先看范围 + +提交前务必执行: + +```bash +git status +git diff --stat +``` + +如果只想提交部分文件: + +```bash +git add <目标文件> +git commit -m "" +``` + +不要图省事直接: + +```bash +git add . +``` + +除非你确认工作区所有改动都属于同一件事。 + +## 3. 提交信息规范 + +推荐格式: + +```text +feat: 新增功能 +fix: 修复问题 +refactor: 重构实现 +merge: 分支合并 +docs: 文档更新 +``` + +示例: + +- `feat: stabilize rag chat conversation lifecycle` +- `fix: remove govdoc inspector file info tab` +- `fix: restore shiy-dev entry route whitelist changes` +- `merge: sync origin/main into wren-dev` + +--- + +## 五、推送规范 + +推送前先确认: + +```bash +git status +git log --oneline --max-count=5 +``` + +再执行: + +```bash +git push origin wren-dev +``` + +如果本地还有未提交改动,不影响推送已提交的 commit,但要明确知道: + +- 已提交内容会推上去 +- 未提交内容不会推上去 + +不要误以为“工作区里看到的所有代码”都已经在远程。 + +--- + +## 六、PR 规范 + +PR 标题必须说明“做了什么”,不要只写模块名。 + +推荐示例: + +- `恢复入口模块跳转路径白名单与 FormSelect 收敛改动` +- `修复 RAG 对话会话生命周期、自动重命名刷新与列表状态问题` + +PR 描述建议固定包含: + +### 1. 背景 + +为什么要改。 + +### 2. 本次改动 + +具体改了哪些点。 + +### 3. 影响范围 + +改到了哪些页面、接口、组件、模块。 + +### 4. 验证建议 + +告诉审核人怎么测。 + +### 5. 特别说明 + +如果是“恢复被覆盖改动”,要明确写出来,避免评审人误解成重复开发。 + +--- + +## 七、推荐命令清单 + +### 检查工作区 + +```bash +git status +git diff --stat +``` + +### 同步远程 + +```bash +git fetch origin +``` + +### 暂存本地未提交改动 + +```bash +git stash -u +git stash pop +``` + +### 合并主线或他人分支 + +```bash +git merge --no-ff origin/main +git merge --no-ff origin/shiy-dev +``` + +### 检查某个提交是否进入历史 + +```bash +git branch --contains +``` + +### 检查关键 patch 是否仍保留 + +```bash +git diff ..HEAD -- <关键文件> +``` + +### 精确恢复某次提交改动 + +```bash +git restore --source= -- <文件列表> +``` + +### 只提交指定文件 + +```bash +git add <文件列表> +git commit -m "" +``` + +### 推送当前分支 + +```bash +git push origin wren-dev +``` + +--- + +## 八、结论 + +以后判断一次合并是否真正成功,不能只看: + +- 分支图里有没有那个 commit + +还必须同时看: + +- 关键文件内容还在不在 +- 关键标识还能不能搜到 +- 最终页面行为是不是正确 + +一句话总结: + +> 合并成功的标准,不是“历史里有 commit”,而是“当前代码树里还保留对应 patch,并且功能行为正确”。 + 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 4/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=90=88?= =?UTF-8?q?=E5=90=8C=E6=A8=A1=E6=9D=BF=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=9C=B0?= =?UTF-8?q?=E5=8C=BA=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,