Files
leaudit-platform-backend/docs/合同模板搜索合同起草/合同模板搜索功能后端接口补充设计.md
T

16 KiB

背景

当前 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 是独立业务域,不适合继续塞入 homeServicedocumentService
  • 当前仓库主要采用“每个业务一个 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

草稿如下:

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

草稿如下:

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

方法签名草案:

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

接口草案:

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_idcategory_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}

如果权限体系希望更简化,也可以把 categoriessearch 并入 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 中补充:

{"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.pyevaluationPointGroupVo.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 迁出。