Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a3b1d09d3 | |||
| 7c6f134808 | |||
| 980996d933 | |||
| c10914dc58 | |||
| d58755a063 | |||
| ebcfe4281a | |||
| 01ffb183a7 | |||
| 16e8668150 |
@@ -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`
|
||||||
@@ -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 迁出。
|
||||||
@@ -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` 的只读接口迁移
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 <commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
但它**不能说明这次提交改过的内容现在还保留在代码树里**。
|
||||||
|
|
||||||
|
实际开发中常见情况是:
|
||||||
|
|
||||||
|
- 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 <commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 再检查关键文件内容是否保住
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff <commit>..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 "<message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
不要图省事直接:
|
||||||
|
|
||||||
|
```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 <commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查关键 patch 是否仍保留
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff <commit>..HEAD -- <关键文件>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 精确恢复某次提交改动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git restore --source=<commit> -- <文件列表>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 只提交指定文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add <文件列表>
|
||||||
|
git commit -m "<message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推送当前分支
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin wren-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、结论
|
||||||
|
|
||||||
|
以后判断一次合并是否真正成功,不能只看:
|
||||||
|
|
||||||
|
- 分支图里有没有那个 commit
|
||||||
|
|
||||||
|
还必须同时看:
|
||||||
|
|
||||||
|
- 关键文件内容还在不在
|
||||||
|
- 关键标识还能不能搜到
|
||||||
|
- 最终页面行为是不是正确
|
||||||
|
|
||||||
|
一句话总结:
|
||||||
|
|
||||||
|
> 合并成功的标准,不是“历史里有 commit”,而是“当前代码树里还保留对应 patch,并且功能行为正确”。
|
||||||
|
|
||||||
@@ -52,6 +52,26 @@ class OssPathUtils:
|
|||||||
prefix = f"{Region}/" if Region else ""
|
prefix = f"{Region}/" if Region else ""
|
||||||
return f"{prefix}rules/{RuleType}/{VersionNo}/validation_report.json"
|
return f"{prefix}rules/{RuleType}/{VersionNo}/validation_report.json"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def BuildContractTemplateKey(
|
||||||
|
Region: str,
|
||||||
|
CategoryName: str,
|
||||||
|
TemplateCode: str,
|
||||||
|
FileRole: str,
|
||||||
|
FileName: str,
|
||||||
|
) -> 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_region}/{safe_category}/{safe_template_code}/"
|
||||||
|
f"{safe_role}__{safe_stem}{ext}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def BuildSafeFileStem(FileName: str) -> str:
|
def BuildSafeFileStem(FileName: str) -> str:
|
||||||
"""生成适合放进 object key 的可读文件名主体。"""
|
"""生成适合放进 object key 的可读文件名主体。"""
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""合同模板控制器。"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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="分类名称"),
|
||||||
|
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="页码"),
|
||||||
|
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,
|
||||||
|
region=region,
|
||||||
|
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, 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")
|
||||||
|
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="分类名称"),
|
||||||
|
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="排序字段"),
|
||||||
|
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,
|
||||||
|
region=region,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
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}")
|
||||||
|
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, 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):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""文档控制器。"""
|
"""文档控制器。"""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, File, Form, Query, UploadFile
|
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_sqlalchemy.database import GetAsyncSession
|
||||||
from fastapi_common.fastapi_common_web.controller import BaseController
|
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_common.fastapi_common_security.security import verify_access_token
|
||||||
|
|
||||||
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
||||||
@@ -21,6 +23,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
|||||||
DocumentTypeRootCreateDTO,
|
DocumentTypeRootCreateDTO,
|
||||||
DocumentTypeRootItemVO,
|
DocumentTypeRootItemVO,
|
||||||
DocumentTypeRootUpdateDTO,
|
DocumentTypeRootUpdateDTO,
|
||||||
|
ContractTemplateUploadVO,
|
||||||
DocumentTypeUpdateDTO,
|
DocumentTypeUpdateDTO,
|
||||||
DocumentUploadVO,
|
DocumentUploadVO,
|
||||||
)
|
)
|
||||||
@@ -94,6 +97,33 @@ class DocumentController(BaseController):
|
|||||||
)
|
)
|
||||||
return Result.success(data=Data)
|
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])
|
@self.router.get("/documents/list", response_model=Result[DocumentListPageVO])
|
||||||
async def ListDocuments(
|
async def ListDocuments(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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="分类名称")
|
||||||
|
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="页码")
|
||||||
|
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="分类名称")
|
||||||
|
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="是否推荐")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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="模板简介")
|
||||||
|
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="更新时间")
|
||||||
|
|
||||||
|
|
||||||
|
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 ContractTemplateCreateVO(ContractTemplateDetailVO):
|
||||||
|
"""合同模板上传结果。"""
|
||||||
|
|
||||||
|
|
||||||
|
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="分类统计")
|
||||||
@@ -28,6 +28,17 @@ class DocumentUploadVO(BaseModel):
|
|||||||
run: AuditRunVO | None = Field(None, description="自动触发后的运行信息")
|
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):
|
class DocumentStatusItemVO(BaseModel):
|
||||||
"""文档状态项。"""
|
"""文档状态项。"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IContractTemplateService(ABC):
|
||||||
|
"""合同模板服务接口。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
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:
|
||||||
|
...
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
||||||
|
ContractTemplateUploadVO,
|
||||||
DocumentDetailVO,
|
DocumentDetailVO,
|
||||||
DocumentListPageVO,
|
DocumentListPageVO,
|
||||||
DocumentStatusItemVO,
|
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
|
@abstractmethod
|
||||||
async def ListDocumentTypes(self, Ids: list[int] | None = None, EntryModuleId: int | None = None) -> list[DocumentTypeItemVO]:
|
async def ListDocumentTypes(self, Ids: list[int] | None = None, EntryModuleId: int | None = None) -> list[DocumentTypeItemVO]:
|
||||||
"""获取文档类型列表。"""
|
"""获取文档类型列表。"""
|
||||||
|
|||||||
@@ -0,0 +1,771 @@
|
|||||||
|
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,
|
||||||
|
ContractTemplateSearchCategoryVO,
|
||||||
|
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",
|
||||||
|
"title": "t.title",
|
||||||
|
"created_at": "t.created_at",
|
||||||
|
"updated_at": "t.updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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, 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()
|
||||||
|
|
||||||
|
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, 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, CurrentUserId)
|
||||||
|
category_stats = await self._load_search_category_stats(Query.q, Query.region, CurrentUserId)
|
||||||
|
|
||||||
|
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, CurrentUserId: int) -> ContractTemplateDetailVO | 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)
|
||||||
|
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 []
|
||||||
|
|
||||||
|
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 [
|
||||||
|
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,
|
||||||
|
region: str | None,
|
||||||
|
file_format: str | None,
|
||||||
|
is_featured: bool | None,
|
||||||
|
currentUser: dict[str, Any],
|
||||||
|
) -> tuple[str, dict[str, Any], bool]:
|
||||||
|
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
|
||||||
|
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("LOWER(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 "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:
|
||||||
|
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"),
|
||||||
|
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")),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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_common.fastapi_common_storage.oss_path_utils import OssPathUtils
|
||||||
|
|
||||||
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
|
||||||
|
ContractTemplateUploadVO,
|
||||||
DocumentAttachmentVO,
|
DocumentAttachmentVO,
|
||||||
DocumentDetailVO,
|
DocumentDetailVO,
|
||||||
DocumentHistoryVersionVO,
|
DocumentHistoryVersionVO,
|
||||||
@@ -632,6 +633,7 @@ class DocumentServiceImpl(IDocumentService):
|
|||||||
async with GetAsyncSession() as Session:
|
async with GetAsyncSession() as Session:
|
||||||
await self._ensureDocumentGroupColumn(Session)
|
await self._ensureDocumentGroupColumn(Session)
|
||||||
await self._ensureReviewPointAuditTable(Session)
|
await self._ensureReviewPointAuditTable(Session)
|
||||||
|
await self._ensureCrossReviewProposalSchema(Session)
|
||||||
currentUser = await self._getCurrentUserContext(CurrentUserId)
|
currentUser = await self._getCurrentUserContext(CurrentUserId)
|
||||||
documentColumns = await self._loadDocumentColumns(Session)
|
documentColumns = await self._loadDocumentColumns(Session)
|
||||||
detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns)
|
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"]))
|
reviewPoints = await self._loadReviewPointResults(Session, detail, int(runRow["id"]))
|
||||||
stats = self._buildReviewPointStats(reviewPoints)
|
stats = self._buildReviewPointStats(reviewPoints)
|
||||||
|
|
||||||
|
approvedSupplementDelta = await self._loadApprovedSupplementScoreDelta(Session, detail.documentId)
|
||||||
|
if approvedSupplementDelta:
|
||||||
|
stats.score += approvedSupplementDelta
|
||||||
|
|
||||||
documentPayload = await self._buildReviewDocumentPayload(Session, detail, runRow)
|
documentPayload = await self._buildReviewDocumentPayload(Session, detail, runRow)
|
||||||
reviewInfo = self._buildReviewInfo(runRow, reviewPoints, stats)
|
reviewInfo = self._buildReviewInfo(runRow, reviewPoints, stats)
|
||||||
comparisonDocument = await self._loadComparisonDocument(Session, detail.documentId)
|
comparisonDocument = await self._loadComparisonDocument(Session, detail.documentId)
|
||||||
@@ -866,6 +872,85 @@ class DocumentServiceImpl(IDocumentService):
|
|||||||
for row in rows
|
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 def UpdateDocument(self, CurrentUserId: int, Id: int, Body: DocumentUpdateDTO) -> DocumentDetailVO:
|
||||||
"""更新文档元数据,并执行数据隔离校验。"""
|
"""更新文档元数据,并执行数据隔离校验。"""
|
||||||
async with GetAsyncSession() as Session:
|
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:
|
async def _tableExists(self, Session, TableName: str) -> bool:
|
||||||
"""检查表是否存在。"""
|
"""检查表是否存在。"""
|
||||||
row = (
|
row = (
|
||||||
@@ -2268,20 +2561,33 @@ class DocumentServiceImpl(IDocumentService):
|
|||||||
async def _loadScoringProposals(self, Session, DocumentId: int) -> list[dict[str, Any]]:
|
async def _loadScoringProposals(self, Session, DocumentId: int) -> list[dict[str, Any]]:
|
||||||
"""读取交叉评分提案;缺表时降级为空。"""
|
"""读取交叉评分提案;缺表时降级为空。"""
|
||||||
if await self._tableExists(Session, "leaudit_cross_review_proposals"):
|
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 = (
|
rows = (
|
||||||
await Session.execute(
|
await Session.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
{', '.join(selectColumns)}
|
||||||
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
|
|
||||||
FROM leaudit_cross_review_proposals
|
FROM leaudit_cross_review_proposals
|
||||||
WHERE document_id = :document_id
|
WHERE document_id = :document_id
|
||||||
AND delete_time IS NULL
|
AND delete_time IS NULL
|
||||||
@@ -2336,6 +2642,29 @@ class DocumentServiceImpl(IDocumentService):
|
|||||||
).mappings().all()
|
).mappings().all()
|
||||||
return [dict(row) for row in rows]
|
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(
|
async def _loadReviewPointResults(
|
||||||
self,
|
self,
|
||||||
Session,
|
Session,
|
||||||
@@ -2377,6 +2706,7 @@ class DocumentServiceImpl(IDocumentService):
|
|||||||
FROM leaudit_cross_review_proposals
|
FROM leaudit_cross_review_proposals
|
||||||
WHERE rule_result_id = rr.id
|
WHERE rule_result_id = rr.id
|
||||||
AND document_id = :document_id
|
AND document_id = :document_id
|
||||||
|
AND COALESCE(proposal_type, 'review_point') = 'review_point'
|
||||||
AND status = 'approved'
|
AND status = 'approved'
|
||||||
AND delete_time IS NULL
|
AND delete_time IS NULL
|
||||||
) ad ON TRUE
|
) ad ON TRUE
|
||||||
@@ -2652,7 +2982,7 @@ class DocumentServiceImpl(IDocumentService):
|
|||||||
stats.warning += 1
|
stats.warning += 1
|
||||||
elif item.status == "error":
|
elif item.status == "error":
|
||||||
stats.error += 1
|
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
|
return stats
|
||||||
|
|
||||||
def _buildReviewInfo(
|
def _buildReviewInfo(
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ 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: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: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": "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": "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: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: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"},
|
{"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"},
|
||||||
|
|||||||
+1
-1
Submodule legal-platform-frontend updated: c41ddc844c...dc8159837b
@@ -0,0 +1,376 @@
|
|||||||
|
#!/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(
|
||||||
|
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",
|
||||||
|
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())
|
||||||
@@ -46,6 +46,11 @@ psql -h <host> -U <user> -d <db_name> -v ON_ERROR_STOP=1 -f scripts/创建sql/<f
|
|||||||
3. `schema_v2_add_evaluation_tables.sql`
|
3. `schema_v2_add_evaluation_tables.sql`
|
||||||
4. `seed_home_entry_modules.sql`
|
4. `seed_home_entry_modules.sql`
|
||||||
|
|
||||||
|
### 合同模板模块上线
|
||||||
|
|
||||||
|
1. `schema_contract_templates.sql`
|
||||||
|
2. `seed_contract_templates_rbac.sql`
|
||||||
|
|
||||||
### 系统使用统计上线
|
### 系统使用统计上线
|
||||||
|
|
||||||
1. `schema_add_usage_stats.sql`
|
1. `schema_add_usage_stats.sql`
|
||||||
@@ -221,6 +226,91 @@ ORDER BY permission_key;
|
|||||||
1. `schema_v3_add_cross_review_phase1.sql`
|
1. `schema_v3_add_cross_review_phase1.sql`
|
||||||
2. `seed_cross_review_phase1_permissions.sql`
|
2. `seed_cross_review_phase1_permissions.sql`
|
||||||
|
|
||||||
|
### 七、合同模板
|
||||||
|
|
||||||
|
- `schema_contract_templates.sql`
|
||||||
|
- 用途:在主库创建合同模板分类表和模板表
|
||||||
|
- 主要内容:新增 `contract_categories`、`contract_templates`、补索引和注释
|
||||||
|
- 执行时机:上线合同模板新后端接口前必跑
|
||||||
|
|
||||||
|
- `seed_contract_templates_rbac.sql`
|
||||||
|
- 用途:补齐合同模板只读权限点
|
||||||
|
- 主要内容:新增 `contract_template:list:read`、`contract_template:search:read`、`contract_template:detail:read`
|
||||||
|
- 依赖:`sys_routes` 中已经存在 `/contract-template/list` 和 `/contract-template/search`
|
||||||
|
|
||||||
|
- `migrate_legacy_contract_templates.py`
|
||||||
|
- 用途:把老库 `docauditai` 的合同模板分类、模板记录和旧 bucket 文件迁入主库 `leaudit_platform`
|
||||||
|
- 主要内容:
|
||||||
|
- 读取老库 `public.contract_categories`、`public.contract_templates`
|
||||||
|
- 从旧 bucket `docauditai` 读取 `contract-template/...` 对象
|
||||||
|
- 复制到新 bucket `leaudit` 的 `contract-templates/...` 相对路径
|
||||||
|
- 回写主库 `contract_categories`、`contract_templates.file_path`、`contract_templates.pdf_file_path`
|
||||||
|
- 适用场景:主库已完成建表与权限初始化,但仍是 demo 数据或空数据时
|
||||||
|
- 注意:
|
||||||
|
- 脚本会重置主库 `contract_categories` / `contract_templates` 当前数据并按老库正式数据重建
|
||||||
|
- 当前已知会自动修正 1 条老脏数据:
|
||||||
|
- `contract_templates.id=3`
|
||||||
|
- 标题:`房屋租赁合同(我方承租)`
|
||||||
|
- 老 `file_path` 误指向“我方出租”docx,迁移时会自动改成“我方承租”docx
|
||||||
|
|
||||||
|
#### 推荐顺序
|
||||||
|
|
||||||
|
1. `schema_contract_templates.sql`
|
||||||
|
2. `seed_contract_templates_rbac.sql`
|
||||||
|
3. `python scripts/migrate_legacy_contract_templates.py`
|
||||||
|
4. `python scripts/migrate_legacy_contract_templates.py --apply`
|
||||||
|
|
||||||
|
#### 标准执行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h <host> -U <user> -d <db_name> -v ON_ERROR_STOP=1 -f scripts/创建sql/schema_contract_templates.sql
|
||||||
|
psql -h <host> -U <user> -d <db_name> -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
|
### 七、RAG
|
||||||
|
|
||||||
- `schema_add_rag_chat.sql`
|
- `schema_add_rag_chat.sql`
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- LeAudit Platform Contract Template Schema
|
||||||
|
-- 目标:
|
||||||
|
-- 1. 在主库 leaudit_platform 创建 / 升级合同模板分类表
|
||||||
|
-- 2. 在主库 leaudit_platform 创建 / 升级合同模板主表
|
||||||
|
-- 3. 补齐地区字段、审计字段、软删除字段、索引与 updated_at 触发器
|
||||||
|
-- 说明:
|
||||||
|
-- - 本脚本不依赖旧库 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_by BIGINT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by BIGINT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
UPDATE public.contract_templates
|
||||||
|
SET region = '省级'
|
||||||
|
WHERE region IS NULL OR BTRIM(region) = '';
|
||||||
|
|
||||||
|
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.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;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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'),
|
||||||
|
('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,
|
||||||
|
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'),
|
||||||
|
('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:create:write', 'GRANT', 'DEPT'),
|
||||||
|
('admin', 'contract_template:update:write', 'GRANT', 'DEPT'),
|
||||||
|
('admin', 'contract_template:delete:delete', '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;
|
||||||
Reference in New Issue
Block a user