6 Commits

31 changed files with 5827 additions and 511 deletions
@@ -0,0 +1,470 @@
# 合同模板上传与地区隔离改造方案
## 1. 背景
当前合同模板模块已具备以下只读能力:
- `/api/v3/contract-templates/categories`
- `/api/v3/contract-templates`
- `/api/v3/contract-templates/search`
- `/api/v3/contract-templates/{id}`
当前实现可以支撑模板分类、列表、搜索、详情展示,但还不具备正式的模板管理上传能力。现阶段新增需求包括:
-`/contract-template/list` 页面支持上传合同模板
- 新模板必须存入 `leaudit_platform` 主库,不再依赖旧项目库
- 模板数据需要支持地区区分
- 模板数据需要支持完整审计字段
- 模板数据需要支持软删除
- 前端与后端需统一按当前 LeAudit/FastAPI 风格实现
本方案仅覆盖“合同模板管理与上传”,不扩展“合同起草”独立业务模块。
## 2. 现状问题
### 2.1 数据表不足
当前 `contract_templates` 仅包含:
- `template_code`
- `title`
- `category_id`
- `description`
- `file_path`
- `file_format`
- `pdf_file_path`
- `is_featured`
- `created_at`
- `updated_at`
存在以下问题:
- 没有 `region`,无法做地区隔离
- 没有 `created_by / updated_by`,无法追踪操作者
- 没有 `deleted_at`,无法软删除
- 没有 `original_file_name / mime_type / file_size`,无法完整表达上传文件元数据
- 当前唯一约束仅为 `template_code` 全局唯一,不适合多地区模板管理
### 2.2 读接口缺少统一数据范围控制
当前合同模板读接口没有套用平台现有“按用户地区可见”的规则,无法满足:
- 省级管理员查看全部或指定地区
- 地市管理员仅查看本地区及公共模板
- 非管理用户限制可见范围
### 2.3 OSS 路径未带地区
当前 `BuildContractTemplateKey()` 只按:
- 分类
- 模板编码
- 文件角色
生成路径,未带 `region`,多地区下会出现路径命名冲突与后期归档困难。
### 2.4 权限不足
当前仅具备读权限:
- `contract_template:list:read`
- `contract_template:search:read`
- `contract_template:detail:read`
尚未具备:
- 上传创建权限
- 编辑权限
- 删除权限
## 3. 设计目标
本次改造目标如下:
1. 为合同模板模块补齐上传能力
2. 按地区隔离模板数据
3. 支持审计字段与软删除
4. 保持与现有 `govdoc` / `document` 模块相同的权限和地区控制风格
5. 新上传模板统一走新 OSS 路径规范
6. 不影响现有搜索、列表、详情页面的继续使用
## 4. 数据模型设计
### 4.1 `contract_categories`
分类暂不做地区化,保留为全局字典表,但补齐审计与软删除字段。
建议字段:
- `id BIGSERIAL/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`
索引建议:
- `UNIQUE INDEX uq_contract_categories_name_active ON contract_categories(name) WHERE deleted_at IS NULL`
- `INDEX idx_contract_categories_sort_active ON contract_categories(sort_order) WHERE deleted_at IS NULL`
### 4.2 `contract_templates`
模板表补齐地区、上传元数据、审计字段、软删除字段。
建议字段:
- `id BIGSERIAL PRIMARY KEY`
- `template_code VARCHAR(50) NOT NULL`
- `title VARCHAR(200) NOT NULL`
- `category_id INTEGER NOT NULL REFERENCES contract_categories(id)`
- `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`
索引建议:
- `UNIQUE INDEX uq_contract_templates_region_code_active ON contract_templates(region, template_code) WHERE deleted_at IS NULL`
- `INDEX idx_contract_templates_region_active ON contract_templates(region) WHERE deleted_at IS NULL`
- `INDEX idx_contract_templates_category_active ON contract_templates(category_id) WHERE deleted_at IS NULL`
- `INDEX idx_contract_templates_updated_active ON contract_templates(updated_at DESC) WHERE deleted_at IS NULL`
### 4.3 软删除策略
所有列表、搜索、详情查询统一增加:
- `deleted_at IS NULL`
删除接口只做:
- `deleted_at = NOW()`
- `updated_at = NOW()`
- `updated_by = 当前用户`
本期不执行物理删除 OSS 对象,避免误删无法恢复。
## 5. 地区隔离策略
### 5.1 用户上下文来源
复用现有用户上下文判断逻辑,基于:
- `sso_users.area`
- 用户角色 `super_admin / provincial_admin / admin / 普通用户`
参考现有实现:
- `documentServiceImpl._getCurrentUserContext`
- `documentServiceImpl._buildDocumentScopeFilters`
- `govdocServiceImpl._resolve_upload_region`
### 5.2 模板可见性规则
采用“省级公共模板 + 地区私有模板”的一层可见性模型:
- `region = '省级'`:全局公共模板
- `region = 用户地区`:当前地区私有模板
读取规则:
- `super_admin / provincial_admin`
- 默认可查看全部地区
- 支持显式传 `region` 做筛选
- `admin`
- 默认可查看 `('省级', 自己地区)` 模板
- 若传入其他地区参数,返回空或直接拒绝
- 普通用户
- 默认可查看 `('省级', 自己地区)` 模板
- 不允许跨地区筛选
### 5.3 上传地区规则
上传接口中,`region` 使用以下规则解析:
- `super_admin / provincial_admin`
- 可选择上传到任意地区,含 `省级`
- `admin`
- 只能上传到自己地区
- 普通用户
- 默认不开放上传权限
### 5.4 为什么本期不做“地区模板覆盖省级模板”
本期不做同 `template_code` 的覆盖优先级逻辑,原因:
- 列表/搜索会出现同编码多条模板
- 详情页需要增加“实际命中版本”优先级
- 后续还会影响起草、下载、预览引用
本期先做“地区隔离 + 独立模板记录”,后续若业务明确需要,再做二期“本地覆盖省级模板”。
## 6. OSS 存储设计
### 6.1 新路径规范
建议合同模板 OSS key 改为:
`contract-templates/{region}/{category}/{template_code}/{role}__{filename}`
例如:
- `contract-templates/省级/房屋租赁/HT-RL-001/source__房屋租赁合同.docx`
- `contract-templates/梅州/房屋租赁/HT-RL-001/preview__房屋租赁合同.pdf`
### 6.2 文件角色
- 主模板文件:`source`
- 预览 PDF 文件:`preview`
### 6.3 老数据策略
老数据短期不强制迁移到新地区路径:
- 已有历史数据继续可读
- 新上传统一走新路径
后续若需要统一整洁,再单独执行历史迁移脚本,将存量模板迁到 `contract-templates/省级/...`
## 7. 接口设计
### 7.1 新增接口
#### 7.1.1 上传模板
- `POST /api/v3/contract-templates`
- 权限:`contract_template:create`
- Content-Type`multipart/form-data`
表单字段:
- `title: str`
- `template_code: str`
- `category_id: int`
- `region: str`
- `description: str | None`
- `is_featured: bool`
- `file: UploadFile`
- `pdf_file: UploadFile | None`
返回:
- 模板基础信息
- 文件路径
- 地区信息
- 审计时间
#### 7.1.2 更新模板
- `PUT /api/v3/contract-templates/{id}`
- 权限:`contract_template:update`
本期先可只支持元数据更新,文件替换可选一起补。
#### 7.1.3 删除模板
- `DELETE /api/v3/contract-templates/{id}`
- 权限:`contract_template:delete`
行为:
- 软删除,不物理删 OSS
### 7.2 既有接口增强
#### 7.2.1 分类接口
- 过滤 `deleted_at IS NULL`
- 返回 `template_count` 时只统计当前用户可见地区且未删除模板
#### 7.2.2 列表接口
新增可选参数:
- `region`
逻辑:
- 仅返回当前用户可见模板
- 默认按 `updated_at DESC`
#### 7.2.3 搜索接口
新增可选参数:
- `region`
逻辑:
- 仅搜索当前用户可见模板
#### 7.2.4 详情接口
逻辑:
- 校验模板存在
- 校验模板未删除
- 校验当前用户对模板所在地区可见
## 8. 权限设计
新增权限:
- `contract_template:create`
- `contract_template:update`
- `contract_template:delete`
保留权限:
- `contract_template:list:read`
- `contract_template:search:read`
- `contract_template:detail:read`
角色建议:
- `super_admin`
- 全部读写删
- `provincial_admin`
- 全部读写删
- `admin`
-
- 上传
- 编辑本地区模板
- 删除本地区模板
- 普通用户
- 仅按需开放读权限
## 9. 前端设计
### 9.1 列表页入口
`/contract-template/list` 页面右上角增加:
- “上传模板”按钮
仅有 `contract_template:create` 权限时展示。
### 9.2 上传弹窗字段
- 模板标题
- 模板编码
- 模板分类
- 所属地区
- 模板简介
- 是否推荐
- 模板主文件
- 预览 PDF 文件(可选)
### 9.3 前端交互规则
- `admin` 用户地区默认锁定为自己地区
- `provincial_admin` 可选择地区
- 上传成功后:
- 提示成功
- 关闭弹窗
- 刷新当前列表
- 上传失败时:
- 表单级错误走 toast
- 403 需给出明确无权限提示
### 9.4 API 封装
`lib/api/contract-template/index.ts` 增加:
- `createContractTemplate`
- `updateContractTemplate`
- `deleteContractTemplate`
上传使用 `FormData` 直接请求后端,不走多余代理语义。
## 10. 数据迁移方案
### 10.1 老表扩展
通过增量 SQL
- 新增缺失列
- 回填默认值
- 修正索引
- 增加 `updated_at` 触发器
### 10.2 老数据回填
建议回填:
- `region = '省级'`
- `original_file_name = ''` 或按旧路径推导
- `file_size = 0`
- `deleted_at = NULL`
### 10.3 回滚策略
若上传接口上线后发现问题:
- 可先关闭前端上传入口
- 已有新字段与索引保持兼容,不影响只读能力
## 11. 开发步骤
### 阶段 1:基础设施
1. 扩展合同模板 SQL
2. 扩展 RBAC seed
3. 更新 OSS 路径工具
### 阶段 2:后端接口
1. 增加 DTO/VO
2. 扩展 Service 接口
3. 实现上传、更新、删除
4. 为列表、搜索、详情补地区过滤
### 阶段 3:前端页面
1. 扩展 API 客户端
2. 在列表页增加上传按钮和弹窗
3. 接地区选择与权限控制
### 阶段 4:验证
1. 省级管理员上传省级模板
2. 地市管理员上传本地区模板
3. 非本地区参数校验
4. 403 提示
5. 列表、搜索、详情联调
## 12. 本期明确不做
- 合同起草模块重构
- 模板占位符自动解析
- docx 自动转 pdf
- 地区模板覆盖省级模板优先级
- 模板版本管理
- OSS 历史模板统一搬迁
## 13. 推荐本期交付范围
建议本期落地以下完整闭环:
1. 合同模板表完成地区化、审计化、软删除改造
2. 合同模板后端新增上传接口
3. 合同模板列表页新增上传弹窗
4. 合同模板读接口支持地区可见性控制
5. 权限种子补齐
这套范围足以让合同模板模块从“只读展示”升级为“可管理上传的地区化模板库”。
@@ -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` 的只读接口迁移
@@ -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,并且功能行为正确”。
@@ -54,6 +54,7 @@ class OssPathUtils:
@staticmethod
def BuildContractTemplateKey(
Region: str,
CategoryName: str,
TemplateCode: str,
FileRole: str,
@@ -61,12 +62,13 @@ class OssPathUtils:
) -> str:
"""生成合同模板 object key。"""
ext = Path(FileName).suffix or ""
safe_region = OssPathUtils.BuildSafeFileStem(Region or "shared")
safe_category = OssPathUtils.BuildSafeFileStem(CategoryName or "uncategorized")
safe_template_code = OssPathUtils.BuildSafeFileStem(TemplateCode or "template")
safe_stem = OssPathUtils.BuildSafeFileStem(FileName)
safe_role = OssPathUtils.BuildSafeFileStem(FileRole or "file")
return (
f"contract-templates/{safe_category}/{safe_template_code}/"
f"contract-templates/{safe_region}/{safe_category}/{safe_template_code}/"
f"{safe_role}__{safe_stem}{ext}"
)
@@ -1,11 +1,12 @@
"""合同模板控制器。"""
from fastapi import Depends, Query
from fastapi import Depends, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse
from fastapi_common.fastapi_common_security.security import verify_access_token
from fastapi_common.fastapi_common_web.controller import BaseController
from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO,
)
@@ -39,6 +40,7 @@ class ContractTemplateController(BaseController):
keyword: str | None = Query(None, description="关键词"),
category_id: int | None = Query(None, description="分类ID"),
category_name: str | None = Query(None, description="分类名称"),
region: str | None = Query(None, description="地区"),
file_format: str | None = Query(None, description="文件格式"),
is_featured: bool | None = Query(None, description="是否推荐"),
page: int = Query(1, ge=1, description="页码"),
@@ -53,6 +55,7 @@ class ContractTemplateController(BaseController):
keyword=keyword,
category_id=category_id,
category_name=category_name,
region=region,
file_format=file_format,
is_featured=is_featured,
page=page,
@@ -60,7 +63,32 @@ class ContractTemplateController(BaseController):
sort_by=sort_by,
sort_order=sort_order,
)
data = await self.ContractTemplateService.ListTemplates(query)
data = await self.ContractTemplateService.ListTemplates(query, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.post("")
async def CreateContractTemplate(
title: str = Form(...),
template_code: str = Form(...),
category_id: int = Form(...),
region: str | None = Form(default=None),
description: str | None = Form(default=None),
is_featured: bool = Form(default=False),
file: UploadFile = File(...),
pdf_file: UploadFile | None = File(default=None),
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:create:write"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前仅允许地区管理员上传合同模板", "data": None})
body = ContractTemplateCreateDTO(
title=title,
template_code=template_code,
category_id=category_id,
region=region,
description=description,
is_featured=is_featured,
)
data = await self.ContractTemplateService.CreateTemplate(body, file, pdf_file, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.get("/search")
@@ -68,6 +96,7 @@ class ContractTemplateController(BaseController):
q: str = Query(..., min_length=1, description="搜索关键词"),
category_id: int | None = Query(None, description="分类ID"),
category_name: str | None = Query(None, description="分类名称"),
region: str | None = Query(None, description="地区"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(12, ge=1, le=200, description="分页大小"),
sort_by: str = Query("updated_at", description="排序字段"),
@@ -80,12 +109,13 @@ class ContractTemplateController(BaseController):
q=q,
category_id=category_id,
category_name=category_name,
region=region,
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
)
data = await self.ContractTemplateService.SearchTemplates(query)
data = await self.ContractTemplateService.SearchTemplates(query, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.get("/{TemplateId}")
@@ -95,11 +125,21 @@ class ContractTemplateController(BaseController):
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read", "contract_template:list:read"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None})
data = await self.ContractTemplateService.GetTemplateDetail(TemplateId)
data = await self.ContractTemplateService.GetTemplateDetail(TemplateId, int(payload["user_id"]))
if not data:
return JSONResponse(status_code=404, content={"code": 404, "msg": "合同模板不存在", "data": None})
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.delete("/{TemplateId}")
async def DeleteContractTemplate(
TemplateId: int,
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:delete:delete"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除合同模板权限", "data": None})
await self.ContractTemplateService.DeleteTemplate(TemplateId, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": True})
async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool:
for permission_key in permission_keys:
if await self.PermissionService.CheckPermission(user_id, permission_key):
@@ -17,6 +17,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import (
CrossReviewTaskQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskDocumentAppendVO,
CrossReviewPendingVotesVO,
CrossReviewPermissionVO,
CrossReviewProposalCancelVO,
@@ -158,6 +159,30 @@ class CrossReviewController(BaseController):
)
return Result.success(data=Data, message="交叉评查任务文档上传成功")
@self.router.post("/tasks/{TaskId}/documents/{DocumentId}/attachments", response_model=Result[CrossReviewTaskDocumentAppendVO])
async def AppendTaskDocumentAttachments(
TaskId: int,
DocumentId: int,
files: list[UploadFile] = File(..., description="附件文件列表"),
remark: str | None = Form(None, description="本次追加附件备注"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""为交叉评查任务文档追加附件,并生成同版本链新版本。"""
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["document_complete"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有交叉评查任务追加附件权限", "data": None})
filePayloads: list[tuple[str, bytes, str | None]] = []
for file in files:
content = await file.read()
filePayloads.append((file.filename or "attachment.bin", content, file.content_type))
Data = await self.CrossReviewService.AppendTaskDocumentAttachments(
CurrentUserId=int(payload["user_id"]),
TaskId=TaskId,
DocumentId=DocumentId,
Files=filePayloads,
Remark=remark,
)
return Result.success(data=Data, message="附件追加成功")
@self.router.post("/proposals", response_model=Result[CrossReviewProposalCreateVO])
async def CreateProposal(
Body: CrossReviewProposalCreateDTO,
@@ -221,6 +221,8 @@ class DocumentController(BaseController):
async def AppendAttachments(
DocumentId: int,
files: list[UploadFile] = File(..., description="附件文件列表"),
mergeMode: str = Form("new", description="附件合并模式:overwrite/new"),
remark: str | None = Form(None, description="本次追加附件备注"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""为现有文档追加附件(带数据隔离校验)。"""
@@ -232,6 +234,8 @@ class DocumentController(BaseController):
CurrentUserId=int(payload["user_id"]),
Id=DocumentId,
Files=filePayloads,
MergeMode=mergeMode,
Remark=remark,
)
return Result.success(data=Data, message="附件上传成功")
@@ -7,6 +7,7 @@ class ContractTemplateListQueryDTO(BaseModel):
keyword: str | None = Field(None, description="关键词")
category_id: int | None = Field(None, description="分类ID")
category_name: str | None = Field(None, description="分类名称")
region: str | None = Field(None, description="地区")
file_format: str | None = Field(None, description="文件格式")
is_featured: bool | None = Field(None, description="是否推荐")
page: int = Field(1, ge=1, description="页码")
@@ -21,7 +22,19 @@ class ContractTemplateSearchQueryDTO(BaseModel):
q: str = Field(..., min_length=1, description="搜索关键词")
category_id: int | None = Field(None, description="分类ID")
category_name: str | None = Field(None, description="分类名称")
region: str | None = Field(None, description="地区")
page: int = Field(1, ge=1, description="页码")
page_size: int = Field(12, ge=1, le=200, description="分页大小")
sort_by: str = Field("updated_at", description="排序字段")
sort_order: str = Field("desc", description="排序方向")
class ContractTemplateCreateDTO(BaseModel):
"""合同模板上传参数。"""
title: str = Field(..., min_length=1, max_length=200, description="模板标题")
template_code: str = Field(..., min_length=1, max_length=50, description="模板编码")
category_id: int = Field(..., description="分类ID")
region: str | None = Field(None, description="所属地区")
description: str | None = Field(None, description="模板简介")
is_featured: bool = Field(False, description="是否推荐")
@@ -23,10 +23,17 @@ class ContractTemplateListItemVO(BaseModel):
category_name: str | None = Field(None, description="分类名称")
category_icon: str | None = Field(None, description="分类图标")
description: str | None = Field(None, description="模板简介")
region: str = Field(..., description="所属地区")
file_path: str | None = Field(None, description="原始模板文件路径")
pdf_file_path: str | None = Field(None, description="PDF 预览文件路径")
file_format: str = Field(..., description="文件格式")
original_file_name: str | None = Field(None, description="原始上传文件名")
mime_type: str | None = Field(None, description="MIME 类型")
file_size: int = Field(0, description="文件大小")
pdf_file_size: int | None = Field(None, description="预览 PDF 文件大小")
is_featured: bool = Field(False, description="是否推荐")
created_by: int | None = Field(None, description="创建人")
updated_by: int | None = Field(None, description="更新人")
created_at: str | None = Field(None, description="创建时间")
updated_at: str | None = Field(None, description="更新时间")
@@ -48,6 +55,10 @@ class ContractTemplateDetailVO(ContractTemplateListItemVO):
placeholder_schema: dict | None = Field(None, description="模板占位符结构")
class ContractTemplateCreateVO(ContractTemplateDetailVO):
"""合同模板上传结果。"""
class ContractTemplateSearchCategoryVO(BaseModel):
"""搜索结果分类统计。"""
@@ -74,6 +74,39 @@ class CrossReviewTaskDocumentVO(BaseModel):
fullScore: float = Field(0, description="满分")
scoreSummary: str = Field("", description="得分摘要")
scorePercent: float = Field(0, description="得分百分比")
historyVersions: list["CrossReviewTaskHistoryVersionVO"] = Field(default_factory=list, description="历史版本列表")
class CrossReviewTaskHistoryVersionVO(BaseModel):
"""任务文档历史版本项。"""
documentId: int = Field(..., description="文档ID")
name: str = Field("", description="文档名称")
documentNumber: str | None = Field(None, description="文号")
typeId: int | None = Field(None, description="文档类型ID")
typeName: str | None = Field(None, description="文档类型名称")
processingStatus: str | None = Field(None, description="处理状态")
versionNo: int = Field(1, description="版本号")
auditStatus: int = Field(0, description="任务内完成状态")
createdAt: datetime | None = Field(None, description="创建时间")
fileSize: int = Field(0, description="文件大小(字节)")
path: str | None = Field(None, description="文件存储路径")
uploadTime: datetime | None = Field(None, description="上传时间")
fileExt: str | None = Field(None, description="文件扩展名")
totalEvaluationPoints: int = Field(0, description="总评查点数")
passCount: int = Field(0, description="通过数")
warningCount: int = Field(0, description="警告数")
errorCount: int = Field(0, description="错误数")
manualCount: int = Field(0, description="人工审核数")
issueCount: int = Field(0, description="问题总数")
warningMessages: list[str] = Field(default_factory=list, description="警告消息")
errorMessages: list[str] = Field(default_factory=list, description="错误消息")
issueMessages: list[str] = Field(default_factory=list, description="问题消息")
manualMessages: list[str] = Field(default_factory=list, description="人工审核消息")
finalScore: float = Field(0, description="最终得分")
fullScore: float = Field(0, description="满分")
scoreSummary: str = Field("", description="得分摘要")
scorePercent: float = Field(0, description="得分百分比")
class CrossReviewTaskDocumentPageVO(BaseModel):
@@ -194,3 +227,15 @@ class CrossReviewTaskDocumentUploadVO(BaseModel):
documentId: int = Field(..., description="文档ID")
auditStatus: int = Field(0, description="任务内评查状态")
processingStatus: str | None = Field(None, description="文档处理状态")
class CrossReviewTaskDocumentAppendVO(BaseModel):
"""交叉评查任务文档追加附件结果。"""
taskId: int = Field(..., description="任务ID")
originalDocumentId: int = Field(..., description="原文档ID")
documentId: int = Field(..., description="新版本文档ID")
versionNo: int = Field(..., description="新版本号")
versionGroupKey: str = Field("", description="版本组Key")
auditStatus: int = Field(0, description="任务内评查状态")
processingStatus: str | None = Field(None, description="文档处理状态")
@@ -59,6 +59,7 @@ class DocumentHistoryVersionVO(BaseModel):
fileName: str | None = Field(None, description="文件名")
fileExt: str | None = Field(None, description="文件扩展名")
fileSize: int | None = Field(None, description="文件大小")
ossUrl: str | None = Field(None, description="OSS 路径")
processingStatus: str | None = Field(None, description="处理状态")
runStatus: str | None = Field(None, description="最新运行状态")
resultStatus: str | None = Field(None, description="最新结果状态")
@@ -24,6 +24,7 @@ from leaudit.ocr.base import BaseOCRClient
from leaudit.ocr.models import OcrResult
from fastapi_modules.fastapi_leaudit.leaudit_bridge.storage_adapter import StorageAdapter
from fastapi_modules.fastapi_leaudit.rag_engine.retriever import RagRetriever
log = logging.getLogger(__name__)
@@ -89,10 +90,12 @@ class LauditPipeline:
ocr_client: BaseOCRClient,
llm_client: BaseLLMClient | None = None,
storage_adapter: StorageAdapter | None = None,
rag_retriever: RagRetriever | None = None,
) -> None:
self.ocr_client = ocr_client
self.llm_client = llm_client
self.storage = storage_adapter or StorageAdapter()
self.rag_retriever = rag_retriever or RagRetriever()
async def run(
self,
@@ -219,6 +222,7 @@ class LauditPipeline:
visual_manifest=visual_manifest,
phase=detected_phase,
external_mocks=external_mocks,
retriever=self.rag_retriever,
)
timing["evaluation"] = round(time.time() - t0, 2)
log.info(
@@ -532,19 +532,41 @@ async def _load_run_context(run_id: int) -> dict[str, Any]:
if not document_file:
raise ValueError(f"未找到 document_file_id={run.documentFileId} 对应的文件记录")
resolver = FileSourceResolver()
payload = await resolver.ResolvePayload(document_file)
attachmentResult = await session.execute(
mergedPdfResult = await session.execute(
select(LeauditDocumentFile)
.where(
LeauditDocumentFile.documentId == document.Id,
LeauditDocumentFile.isActive.is_(True),
LeauditDocumentFile.fileRole == "attachment",
LeauditDocumentFile.fileRole == "merged_pdf",
)
.order_by(LeauditDocumentFile.Id.asc())
.order_by(LeauditDocumentFile.Id.desc())
.limit(1)
)
attachmentFiles = list(attachmentResult.scalars().all())
attachmentPayloads = await resolver.ResolvePayloads(attachmentFiles) if attachmentFiles else []
mergedPdfFile = mergedPdfResult.scalar_one_or_none()
effective_document_file = mergedPdfFile or document_file
resolver = FileSourceResolver()
payload = await resolver.ResolvePayload(effective_document_file)
attachmentFiles: list[LeauditDocumentFile] = []
attachmentPayloads = []
if mergedPdfFile is not None:
attachmentFiles = []
elif str(getattr(document_file, "fileRole", "") or "").lower() != "primary":
attachmentFiles = []
elif str(getattr(document_file, "fileExt", "") or "").lower() not in {"pdf", "docx"}:
attachmentFiles = []
else:
attachmentResult = await session.execute(
select(LeauditDocumentFile)
.where(
LeauditDocumentFile.documentId == document.Id,
LeauditDocumentFile.isActive.is_(True),
LeauditDocumentFile.fileRole == "attachment",
)
.order_by(LeauditDocumentFile.Id.asc())
)
attachmentFiles = list(attachmentResult.scalars().all())
attachmentPayloads = await resolver.ResolvePayloads(attachmentFiles) if attachmentFiles else []
return {
"document_id": document.Id,
@@ -0,0 +1,470 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
import httpx
from sqlalchemy import text
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
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.rag_engine.chroma_client import get_chroma
from fastapi_modules.fastapi_leaudit.rag_engine.config import RAG_CONFIG, build_openai_embeddings_url
EmbedTexts = Callable[[list[str], str], Awaitable[list[list[float]]] | list[list[float]]]
@dataclass
class RagRetrieveResult:
rag_context: str = ""
rag_resources: list[dict[str, Any]] = field(default_factory=list)
chunks: list[dict[str, Any]] = field(default_factory=list)
dataset_name: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"rag_context": self.rag_context,
"rag_resources": self.rag_resources,
}
class RagRetriever:
def __init__(
self,
*,
chroma_client: Any | None = None,
embed_texts: EmbedTexts | None = None,
hydrate_documents: bool = True,
) -> None:
self._chroma_client = chroma_client
self._embed_texts_override = embed_texts
self._hydrate_documents_enabled = hydrate_documents
async def retrieve(
self,
query: str,
collection_name: str | None = None,
dataset_id: int | None = None,
top_k: int = 5,
source_names: list[str] | None = None,
) -> RagRetrieveResult:
query_text = (query or "").strip()
if not query_text:
return RagRetrieveResult()
top_k = max(1, int(top_k or 5))
dataset = await self._load_dataset(dataset_id) if dataset_id else None
resolved_collection = (collection_name or (dataset or {}).get("collection_name") or "").strip()
if not resolved_collection:
return RagRetrieveResult(dataset_name=str((dataset or {}).get("name") or ""))
retrieval_model = (dataset or {}).get("retrieval_model") or {}
if dataset and not collection_name:
top_k = max(1, int(retrieval_model.get("top_k") or top_k))
score_threshold = self._resolve_score_threshold(retrieval_model)
dataset_name = str((dataset or {}).get("name") or "")
embedding_model = str((dataset or {}).get("embedding_model") or "")
chunks: list[dict[str, Any]] = []
try:
chunks = await self._vector_retrieve(
query=query_text,
collection_name=resolved_collection,
dataset_name=dataset_name,
embedding_model=embedding_model,
top_k=top_k,
score_threshold=score_threshold,
source_names=source_names,
)
except Exception:
chunks = []
if not chunks:
try:
chunks = await self._keyword_retrieve_context(
dataset_id=dataset_id,
collection_name=resolved_collection,
dataset_name=dataset_name,
query=query_text,
top_k=top_k,
score_threshold=score_threshold,
source_names=source_names,
)
except Exception:
chunks = []
if dataset_id and self._hydrate_documents_enabled:
chunks = await self._hydrate_document_hits(dataset_id, chunks)
chunks = chunks[:top_k]
return RagRetrieveResult(
rag_context=self._build_context(chunks),
rag_resources=self.build_sources(chunks, dataset_name),
chunks=chunks,
dataset_name=dataset_name,
)
async def _load_dataset(self, dataset_id: int | None) -> dict[str, Any] | None:
if not dataset_id:
return None
async with GetAsyncSession() as session:
row = (
await session.execute(
text(
"""
SELECT id, name, collection_name, retrieval_model, embedding_model
FROM rag_dataset
WHERE id = :dataset_id AND deleted_at IS NULL
LIMIT 1
"""
),
{"dataset_id": dataset_id},
)
).mappings().first()
return dict(row) if row else None
def _resolve_score_threshold(self, retrieval_model: dict[str, Any]) -> float | None:
if not retrieval_model.get("score_threshold_enabled"):
return None
try:
return float(retrieval_model.get("score_threshold"))
except (TypeError, ValueError):
return None
async def _vector_retrieve(
self,
*,
query: str,
collection_name: str,
dataset_name: str,
embedding_model: str,
top_k: int,
score_threshold: float | None,
source_names: list[str] | None,
) -> list[dict[str, Any]]:
query_embedding = await self._embed_texts([query], embedding_model)
collection = self._get_chroma().get_or_create_collection(collection_name)
result = collection.query(
query_embeddings=query_embedding,
n_results=max(top_k, 1),
include=["documents", "metadatas", "distances"],
)
ids = (result.get("ids") or [[]])[0] if result.get("ids") else []
docs = (result.get("documents") or [[]])[0]
metas = (result.get("metadatas") or [[]])[0]
distances = (result.get("distances") or [[]])[0]
allowed_sources = self._normalize_source_filter(source_names)
chunks: list[dict[str, Any]] = []
for idx, doc in enumerate(docs):
meta = metas[idx] if idx < len(metas) and isinstance(metas[idx], dict) else {}
document_name = str(meta.get("document_name") or meta.get("source") or "")
if allowed_sources and document_name not in allowed_sources:
continue
dist = float(distances[idx]) if idx < len(distances) and distances[idx] is not None else 1.0
score = 1.0 / (1.0 + max(dist, 0.0))
if score_threshold is not None and score < score_threshold:
continue
chunks.append(
{
"id": str(ids[idx] if idx < len(ids) else meta.get("id") or idx),
"text": doc or "",
"source": meta.get("source") or meta.get("document_name") or dataset_name,
"score": score,
"chunk_index": int(meta.get("chunk_index") or idx),
"document_name": document_name,
"document_id": meta.get("document_id"),
"page": meta.get("page"),
}
)
return chunks
async def _embed_texts(self, texts: list[str], model_name: str = "") -> list[list[float]]:
if self._embed_texts_override is not None:
result = self._embed_texts_override(texts, model_name)
if hasattr(result, "__await__"):
return await result # type: ignore[misc]
return result
embed_url = (RAG_CONFIG.get("EMBED_URL") or "").strip() or build_openai_embeddings_url(RAG_CONFIG["LLM_BASE_URL"])
embed_key = (RAG_CONFIG.get("EMBED_KEY") or "").strip() or RAG_CONFIG["LLM_API_KEY"]
embed_model = model_name or (RAG_CONFIG.get("EMBED_MODEL") or "").strip() or "text-embedding-v4"
batch_size = max(1, int(RAG_CONFIG.get("EMBED_BATCH_SIZE") or 10))
if not embed_url or not embed_key:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "未配置可用的向量化服务")
embeddings: list[list[float]] = []
async with httpx.AsyncClient(timeout=120.0) as client:
for start in range(0, len(texts), batch_size):
batch_texts = texts[start:start + batch_size]
try:
response = await client.post(
embed_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {embed_key}",
},
json={"model": embed_model, "input": batch_texts},
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
error_message = exc.response.text.strip() or f"{exc.response.status_code} {exc.response.reason_phrase}"
raise LeauditException(
StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR,
f"向量化服务调用失败: {error_message[:300]}",
) from exc
payload = response.json()
rows = payload.get("data") or []
batch_embeddings = [row.get("embedding") for row in rows if isinstance(row, dict) and row.get("embedding")]
if len(batch_embeddings) != len(batch_texts):
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "向量化结果数量异常")
embeddings.extend(batch_embeddings)
if len(embeddings) != len(texts):
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "向量化结果数量异常")
return embeddings
async def _keyword_retrieve_context(
self,
*,
dataset_id: int | None,
collection_name: str,
dataset_name: str,
query: str,
top_k: int,
score_threshold: float | None,
source_names: list[str] | None,
) -> list[dict[str, Any]]:
collection = self._get_chroma().get_or_create_collection(collection_name)
raw = collection.get(include=["documents", "metadatas"])
ids = raw.get("ids") or []
docs = raw.get("documents") or []
metas = raw.get("metadatas") or []
allowed_sources = self._normalize_source_filter(source_names)
terms = self._build_keyword_terms(query)
if not terms:
return []
scored_chunks: list[dict[str, Any]] = []
for idx, chunk_id in enumerate(ids):
doc = docs[idx] if idx < len(docs) else ""
meta = metas[idx] if idx < len(metas) and isinstance(metas[idx], dict) else {}
document_name = str(meta.get("document_name") or meta.get("source") or "")
if allowed_sources and document_name not in allowed_sources:
continue
score = self._score_keyword_chunk(
query=query,
terms=terms,
content=doc or "",
document_name=document_name,
)
if score <= 0:
continue
if score_threshold is not None and score < score_threshold:
continue
scored_chunks.append(
{
"id": str(chunk_id),
"text": doc or "",
"source": meta.get("source") or meta.get("document_name") or dataset_name,
"score": score,
"chunk_index": int(meta.get("chunk_index") or idx),
"document_name": document_name,
"document_id": meta.get("document_id"),
"page": meta.get("page"),
}
)
scored_chunks.sort(key=lambda item: (-float(item.get("score") or 0.0), int(item.get("chunk_index") or 0)))
return scored_chunks[: max(top_k * 3, top_k)]
def _build_keyword_terms(self, query: str) -> list[str]:
normalized = self._normalize_keyword_query(query)
spans = [item.strip() for item in re.findall(r"[\u4e00-\u9fffA-Za-z0-9]+", normalized) if item.strip()]
if not spans:
return []
stop_terms = {
"什么",
"请问",
"一下",
"有关",
"关于",
"如何",
"哪些",
"怎么",
"是否",
"规定",
"办法",
"条例",
"法律",
}
terms: list[str] = []
for span in spans:
if span in stop_terms:
continue
terms.append(span)
if re.fullmatch(r"[\u4e00-\u9fff]+", span):
for size in (2, 3, 4):
if len(span) > size:
for start in range(0, len(span) - size + 1):
token = span[start:start + size]
if token not in stop_terms:
terms.append(token)
unique_terms: list[str] = []
seen: set[str] = set()
for term in sorted(terms, key=len, reverse=True):
if term and term not in seen:
unique_terms.append(term)
seen.add(term)
return unique_terms[:20]
def _normalize_keyword_query(self, query: str) -> str:
normalized = (query or "").strip().lower()
patterns = [
"是什么",
"什么是",
"有哪些",
"有什么",
"是什么?",
"是什么?",
"请问",
"介绍一下",
"解释一下",
"帮我分析",
"帮我看看",
]
for pattern in patterns:
normalized = normalized.replace(pattern, " ")
return re.sub(r"\s+", " ", normalized).strip()
def _score_keyword_chunk(self, *, query: str, terms: list[str], content: str, document_name: str) -> float:
haystack = f"{document_name}\n{content}".lower()
if not haystack:
return 0.0
exact_query = self._normalize_keyword_query(query)
if exact_query and exact_query in haystack:
return 0.98
matched_weight = 0.0
total_weight = 0.0
name_bonus = 0.0
for term in terms:
weight = float(max(len(term), 1) ** 2)
total_weight += weight
if term.lower() in haystack:
matched_weight += weight
if term.lower() in document_name.lower():
name_bonus += min(0.15, 0.03 * len(term))
if total_weight <= 0:
return 0.0
score = (matched_weight / total_weight) + name_bonus
return round(min(score, 0.99), 6)
def _build_context(self, chunks: list[dict[str, Any]]) -> str:
lines: list[str] = []
for index, chunk in enumerate(chunks, start=1):
document_name = chunk.get("document_name") or chunk.get("source") or "未知来源"
text_value = str(chunk.get("text") or "").strip()
if not text_value:
continue
lines.append(f"[{index}] 来源:{document_name}\n{text_value}")
return "\n\n".join(lines)
def build_sources(self, context_chunks: list[dict[str, Any]], dataset_name: str = "") -> list[dict[str, Any]]:
return [
{
"position": index + 1,
"dataset_id": str(chunk.get("dataset_id") or ""),
"dataset_name": dataset_name,
"document_id": str(chunk.get("document_id") or ""),
"document_name": chunk.get("document_name") or chunk.get("source", ""),
"data_source_type": "upload_file",
"segment_id": chunk.get("id", ""),
"retriever_from": "rag",
"score": round(float(chunk.get("score") or 0.0), 4),
"hit_count": chunk.get("hit_count", 0),
"word_count": len(chunk.get("text", "")),
"segment_position": index + 1,
"index_node_hash": "",
"content": chunk.get("text", "")[:500],
"page": None,
}
for index, chunk in enumerate(context_chunks)
]
async def _hydrate_document_hits(self, dataset_id: int, chunks: list[dict[str, Any]]) -> list[dict[str, Any]]:
source_names = sorted(
{
str(chunk.get("document_name") or chunk.get("source") or "").strip()
for chunk in chunks
if str(chunk.get("document_name") or chunk.get("source") or "").strip()
}
)
if not source_names:
return chunks
async with GetAsyncSession() as session:
rows = (
await session.execute(
text(
"""
SELECT id, original_name, enabled, hit_count
FROM rag_document
WHERE dataset_id = :dataset_id
AND deleted_at IS NULL
AND original_name = ANY(:source_names)
"""
),
{
"dataset_id": dataset_id,
"source_names": source_names,
},
)
).mappings().all()
document_map = {str(row["original_name"]): row for row in rows}
visible_chunks: list[dict[str, Any]] = []
hit_document_ids: list[int] = []
for chunk in chunks:
source_name = str(chunk.get("document_name") or chunk.get("source") or "").strip()
document = document_map.get(source_name)
if document and not bool(document.get("enabled")):
continue
if document:
chunk["document_id"] = document["id"]
chunk["dataset_id"] = dataset_id
chunk["document_name"] = document["original_name"]
chunk["hit_count"] = document.get("hit_count") or 0
hit_document_ids.append(int(document["id"]))
visible_chunks.append(chunk)
if hit_document_ids:
async with GetAsyncSession() as session:
async with session.begin():
await session.execute(
text(
"""
UPDATE rag_document
SET hit_count = hit_count + 1,
updated_at = NOW()
WHERE id = ANY(:document_ids)
"""
),
{"document_ids": sorted(set(hit_document_ids))},
)
return visible_chunks
def _normalize_source_filter(self, source_names: list[str] | None) -> set[str]:
return {str(name).strip() for name in (source_names or []) if str(name).strip()}
def _get_chroma(self) -> Any:
return self._chroma_client or get_chroma()
@@ -1,11 +1,14 @@
from abc import ABC, abstractmethod
from fastapi import UploadFile
from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateCategoryVO,
ContractTemplateCreateVO,
ContractTemplateDetailVO,
ContractTemplatePageVO,
ContractTemplateSearchResultVO,
@@ -20,13 +23,27 @@ class IContractTemplateService(ABC):
...
@abstractmethod
async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO:
async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO:
...
@abstractmethod
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO:
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO:
...
@abstractmethod
async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None:
async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | None:
...
@abstractmethod
async def CreateTemplate(
self,
Body: ContractTemplateCreateDTO,
File: UploadFile,
PdfFile: UploadFile | None,
CurrentUserId: int,
) -> ContractTemplateCreateVO:
...
@abstractmethod
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
...
@@ -10,6 +10,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import (
CrossReviewTaskQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskDocumentAppendVO,
CrossReviewPendingVotesVO,
CrossReviewPermissionVO,
CrossReviewProposalCancelVO,
@@ -117,3 +118,15 @@ class ICrossReviewService(ABC):
) -> CrossReviewTaskDocumentUploadVO:
"""向交叉评查任务补传文档。"""
...
@abstractmethod
async def AppendTaskDocumentAttachments(
self,
CurrentUserId: int,
TaskId: int,
DocumentId: int,
Files: list[tuple[str, bytes, str | None]],
Remark: str | None = None,
) -> CrossReviewTaskDocumentAppendVO:
"""为交叉评查任务文档追加附件,并生成同版本链新版本。"""
...
@@ -115,6 +115,8 @@ class IDocumentService(ABC):
CurrentUserId: int,
Id: int,
Files: list[tuple[str, bytes, str | None]],
MergeMode: str = "new",
Remark: str | None = None,
) -> DocumentDetailVO:
"""为现有文档追加附件,并执行数据隔离校验。"""
...
@@ -1,16 +1,24 @@
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Any
from fastapi import UploadFile
from sqlalchemy import bindparam, text
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateCategoryVO,
ContractTemplateCreateVO,
ContractTemplateDetailVO,
ContractTemplateListItemVO,
ContractTemplatePageVO,
@@ -18,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateSearchResultVO,
)
from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService
from fastapi_modules.fastapi_leaudit.services.ossService import IOssService
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
_ALLOWED_SORT_FIELDS = {
"id": "t.id",
@@ -30,8 +40,14 @@ _ALLOWED_SORT_FIELDS = {
class ContractTemplateServiceImpl(IContractTemplateService):
"""合同模板服务实现。"""
def __init__(self, OssService: IOssService | None = None) -> None:
self.OssService = OssService or OssServiceImpl()
async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]:
count_select = "COUNT(t.id)::int AS template_count" if WithTemplateCount else "0::int AS template_count"
filters = ["c.deleted_at IS NULL"]
if not IncludeDisabled:
filters.append("1=1")
sql = text(
f"""
SELECT
@@ -45,64 +61,76 @@ class ContractTemplateServiceImpl(IContractTemplateService):
FROM contract_categories c
LEFT JOIN contract_templates t
ON t.category_id = c.id
WHERE 1=1
AND t.deleted_at IS NULL
WHERE {' AND '.join(filters)}
GROUP BY c.id, c.name, c.icon, c.description, c.sort_order
ORDER BY COALESCE(c.sort_order, 0) ASC, c.name ASC
"""
)
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
rows = (await session.execute(sql)).mappings().all()
return [self._to_category_vo(row) for row in rows]
async def ListTemplates(self, Query: ContractTemplateListQueryDTO) -> ContractTemplatePageVO:
where_clause, params, needs_category_name_filter = self._build_template_filters(
keyword=Query.keyword,
category_id=Query.category_id,
category_name=Query.category_name,
file_format=Query.file_format,
is_featured=Query.is_featured,
)
order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc")
offset = max(Query.page - 1, 0) * Query.page_size
params.update({"limit": Query.page_size, "offset": offset})
from_sql = self._build_template_from_sql(needs_category_name_filter)
count_sql = text(
f"""
SELECT COUNT(*)
{from_sql}
WHERE {where_clause}
"""
)
list_sql = text(
f"""
SELECT
t.id,
t.template_code,
t.title,
t.category_id,
c.name AS category_name,
c.icon AS category_icon,
c.description AS category_description,
t.description,
t.file_path,
t.pdf_file_path,
t.file_format,
COALESCE(t.is_featured, FALSE) AS is_featured,
t.created_at,
t.updated_at
{from_sql}
WHERE {where_clause}
ORDER BY {order_sql}
LIMIT :limit OFFSET :offset
"""
)
count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params)
async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO:
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
where_clause, params, needs_category_name_filter = self._build_template_filters(
keyword=Query.keyword,
category_id=Query.category_id,
category_name=Query.category_name,
region=Query.region,
file_format=Query.file_format,
is_featured=Query.is_featured,
currentUser=currentUser,
)
order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc")
offset = max(Query.page - 1, 0) * Query.page_size
params.update({"limit": Query.page_size, "offset": offset})
from_sql = self._build_template_from_sql(needs_category_name_filter)
count_sql = text(
f"""
SELECT COUNT(*)
{from_sql}
WHERE {where_clause}
"""
)
list_sql = text(
f"""
SELECT
t.id,
t.template_code,
t.title,
t.category_id,
c.name AS category_name,
c.icon AS category_icon,
c.description AS category_description,
t.region,
t.description,
t.file_path,
t.pdf_file_path,
t.file_format,
t.original_file_name,
t.mime_type,
COALESCE(t.file_size, 0) AS file_size,
t.pdf_file_size,
COALESCE(t.is_featured, FALSE) AS is_featured,
t.created_by,
t.updated_by,
t.created_at,
t.updated_at
{from_sql}
WHERE {where_clause}
ORDER BY {order_sql}
LIMIT :limit OFFSET :offset
"""
)
count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params)
total = int((await session.execute(count_sql, params)).scalar_one())
rows = (await session.execute(list_sql, params)).mappings().all()
@@ -114,18 +142,19 @@ class ContractTemplateServiceImpl(IContractTemplateService):
templates=[self._to_list_item_vo(row) for row in rows],
)
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO) -> ContractTemplateSearchResultVO:
async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO:
list_query = ContractTemplateListQueryDTO(
keyword=Query.q,
category_id=Query.category_id,
category_name=Query.category_name,
region=Query.region,
page=Query.page,
page_size=Query.page_size,
sort_by=Query.sort_by,
sort_order=Query.sort_order,
)
page_result = await self.ListTemplates(list_query)
category_stats = await self._load_search_category_stats(Query.q)
page_result = await self.ListTemplates(list_query, CurrentUserId)
category_stats = await self._load_search_category_stats(Query.q, Query.region, CurrentUserId)
return ContractTemplateSearchResultVO(
total=page_result.total,
@@ -136,71 +165,310 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_stats=category_stats,
)
async def GetTemplateDetail(self, TemplateId: int) -> ContractTemplateDetailVO | None:
sql = text(
"""
SELECT
t.id,
t.template_code,
t.title,
t.category_id,
c.name AS category_name,
c.icon AS category_icon,
c.description AS category_description,
t.description,
t.file_path,
t.pdf_file_path,
t.file_format,
COALESCE(t.is_featured, FALSE) AS is_featured,
t.created_at,
t.updated_at
FROM contract_templates t
LEFT JOIN contract_categories c ON c.id = t.category_id
WHERE t.id = :template_id
LIMIT 1
"""
)
async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | None:
async with GetAsyncSession() as session:
row = (await session.execute(sql, {"template_id": TemplateId})).mappings().first()
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"template_id": TemplateId}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None)
sql = text(
f"""
SELECT
t.id,
t.template_code,
t.title,
t.category_id,
c.name AS category_name,
c.icon AS category_icon,
c.description AS category_description,
t.region,
t.description,
t.file_path,
t.pdf_file_path,
t.file_format,
t.original_file_name,
t.mime_type,
COALESCE(t.file_size, 0) AS file_size,
t.pdf_file_size,
COALESCE(t.is_featured, FALSE) AS is_featured,
t.created_by,
t.updated_by,
t.created_at,
t.updated_at
FROM contract_templates t
LEFT JOIN contract_categories c ON c.id = t.category_id
WHERE t.id = :template_id
AND t.deleted_at IS NULL
AND c.deleted_at IS NULL
AND {' AND '.join(scope_filters)}
LIMIT 1
"""
)
row = (await session.execute(sql, params)).mappings().first()
if not row:
return None
return self._to_detail_vo(row)
async def CreateTemplate(
self,
Body: ContractTemplateCreateDTO,
File: UploadFile,
PdfFile: UploadFile | None,
CurrentUserId: int,
) -> ContractTemplateCreateVO:
if File is None or not File.filename:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板主文件不能为空")
fileContent = await File.read()
if not fileContent:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板主文件内容不能为空")
normalizedCode = (Body.template_code or "").strip()
normalizedTitle = (Body.title or "").strip()
if not normalizedCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板编码不能为空")
if not normalizedTitle:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板标题不能为空")
fileExt = Path(File.filename).suffix.lstrip(".").lower()
if fileExt not in {"doc", "docx", "pdf"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前仅支持上传 DOC、DOCX、PDF 模板")
mimeType = File.content_type or mimetypes.guess_type(File.filename)[0] or "application/octet-stream"
pdfContent: bytes | None = None
pdfMimeType: str | None = None
pdfFileName: str | None = None
if PdfFile and PdfFile.filename:
pdfContent = await PdfFile.read()
if not pdfContent:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览 PDF 文件内容不能为空")
pdfExt = Path(PdfFile.filename).suffix.lstrip(".").lower()
if pdfExt != "pdf":
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览文件仅支持 PDF")
pdfMimeType = PdfFile.content_type or "application/pdf"
pdfFileName = PdfFile.filename
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
resolvedRegion = self._resolve_upload_region(currentUser, Body.region)
categoryRow = (
await session.execute(
text(
"""
SELECT id, name
FROM contract_categories
WHERE id = :category_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"category_id": Body.category_id},
)
).mappings().first()
if not categoryRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板分类不存在")
duplicateRow = (
await session.execute(
text(
"""
SELECT id
FROM contract_templates
WHERE region = :region
AND template_code = :template_code
AND deleted_at IS NULL
LIMIT 1
"""
),
{"region": resolvedRegion, "template_code": normalizedCode},
)
).mappings().first()
if duplicateRow:
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前地区已存在模板编码 {normalizedCode}")
categoryName = str(categoryRow["name"] or "未分类")
objectKey = OssPathUtils.BuildContractTemplateKey(
Region=resolvedRegion,
CategoryName=categoryName,
TemplateCode=normalizedCode,
FileRole="source",
FileName=File.filename,
)
filePath = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=fileContent,
ContentType=mimeType,
)
pdfPath: str | None = None
if pdfContent is not None and pdfFileName:
pdfObjectKey = OssPathUtils.BuildContractTemplateKey(
Region=resolvedRegion,
CategoryName=categoryName,
TemplateCode=normalizedCode,
FileRole="preview",
FileName=pdfFileName,
)
pdfPath = await self.OssService.UploadBytes(
ObjectKey=pdfObjectKey,
Content=pdfContent,
ContentType=pdfMimeType or "application/pdf",
)
createdRow = (
await session.execute(
text(
"""
INSERT INTO contract_templates (
template_code,
title,
category_id,
region,
description,
file_path,
pdf_file_path,
file_format,
original_file_name,
mime_type,
file_size,
pdf_file_size,
is_featured,
created_by,
updated_by,
created_at,
updated_at
) VALUES (
:template_code,
:title,
:category_id,
:region,
:description,
:file_path,
:pdf_file_path,
:file_format,
:original_file_name,
:mime_type,
:file_size,
:pdf_file_size,
:is_featured,
:created_by,
:updated_by,
NOW(),
NOW()
)
RETURNING id
"""
),
{
"template_code": normalizedCode,
"title": normalizedTitle,
"category_id": Body.category_id,
"region": resolvedRegion,
"description": (Body.description or "").strip() or None,
"file_path": filePath,
"pdf_file_path": pdfPath,
"file_format": fileExt,
"original_file_name": File.filename,
"mime_type": mimeType,
"file_size": len(fileContent),
"pdf_file_size": len(pdfContent) if pdfContent is not None else None,
"is_featured": Body.is_featured,
"created_by": CurrentUserId,
"updated_by": CurrentUserId,
},
)
).mappings().first()
await session.commit()
if not createdRow:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建失败")
detail = await self.GetTemplateDetail(int(createdRow["id"]), CurrentUserId)
if not detail:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建成功但详情读取失败")
return ContractTemplateCreateVO(**detail.model_dump())
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"template_id": TemplateId}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None, writable=True)
row = (
await session.execute(
text(
f"""
SELECT id
FROM contract_templates t
WHERE t.id = :template_id
AND t.deleted_at IS NULL
AND {' AND '.join(scope_filters)}
LIMIT 1
"""
),
params,
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权删除")
await session.execute(
text(
"""
UPDATE contract_templates
SET deleted_at = NOW(),
updated_at = NOW(),
updated_by = :updated_by
WHERE id = :template_id
AND deleted_at IS NULL
"""
),
{"template_id": TemplateId, "updated_by": CurrentUserId},
)
await session.commit()
async def _load_search_category_stats(
self,
keyword: str,
requestedRegion: str | None,
CurrentUserId: int,
) -> list[ContractTemplateSearchCategoryVO]:
clean_keyword = (keyword or "").strip()
if not clean_keyword:
return []
filters = [
"("
"t.title ILIKE :keyword "
"OR COALESCE(t.description, '') ILIKE :keyword "
"OR COALESCE(t.template_code, '') ILIKE :keyword "
"OR COALESCE(c.name, '') ILIKE :keyword"
")"
]
params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"}
sql = text(
f"""
SELECT
c.id,
c.name,
COUNT(t.id)::int AS search_count
FROM contract_categories c
LEFT JOIN contract_templates t ON t.category_id = c.id
WHERE {' AND '.join(filters)}
GROUP BY c.id, c.name
ORDER BY c.name ASC
"""
)
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=requestedRegion)
filters = [
"c.deleted_at IS NULL",
"t.deleted_at IS NULL",
"("
"t.title ILIKE :keyword "
"OR COALESCE(t.description, '') ILIKE :keyword "
"OR COALESCE(t.template_code, '') ILIKE :keyword "
"OR COALESCE(c.name, '') ILIKE :keyword"
")",
*scope_filters,
]
sql = text(
f"""
SELECT
c.id,
c.name,
COUNT(t.id)::int AS search_count
FROM contract_categories c
LEFT JOIN contract_templates t ON t.category_id = c.id
WHERE {' AND '.join(filters)}
GROUP BY c.id, c.name
ORDER BY c.name ASC
"""
)
rows = (await session.execute(sql, params)).mappings().all()
return [
@@ -218,13 +486,17 @@ class ContractTemplateServiceImpl(IContractTemplateService):
keyword: str | None,
category_id: int | None,
category_name: str | None,
region: str | None,
file_format: str | None,
is_featured: bool | None,
currentUser: dict[str, Any],
) -> tuple[str, dict[str, Any], bool]:
filters = ["1=1"]
filters = ["t.deleted_at IS NULL", "c.deleted_at IS NULL"]
params: dict[str, Any] = {}
needs_category_name_filter = False
filters.extend(self._build_template_scope_filters(currentUser, params, region))
if category_id is not None:
filters.append("t.category_id = :category_id")
params["category_id"] = category_id
@@ -234,7 +506,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
needs_category_name_filter = True
if file_format:
filters.append("t.file_format = :file_format")
filters.append("LOWER(t.file_format) = :file_format")
params["file_format"] = file_format.strip().lower()
if is_featured is not None:
@@ -271,8 +543,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
def _bind_expanding(self, *sql_objects_and_params: Any):
sql_objects = list(sql_objects_and_params[:-1])
params = sql_objects_and_params[-1]
if "category_ids" in params:
sql_objects = [sql.bindparams(bindparam("category_ids", expanding=True)) for sql in sql_objects]
if "visible_regions" in params:
sql_objects = [sql.bindparams(bindparam("visible_regions", expanding=True)) for sql in sql_objects]
return tuple(sql_objects)
def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO:
@@ -295,10 +567,17 @@ class ContractTemplateServiceImpl(IContractTemplateService):
category_name=row.get("category_name"),
category_icon=row.get("category_icon"),
description=row.get("description"),
region=str(row.get("region") or "省级"),
file_path=row.get("file_path"),
pdf_file_path=row.get("pdf_file_path"),
file_format=str(row.get("file_format") or ""),
original_file_name=row.get("original_file_name"),
mime_type=row.get("mime_type"),
file_size=int(row.get("file_size") or 0),
pdf_file_size=int(row["pdf_file_size"]) if row.get("pdf_file_size") is not None else None,
is_featured=bool(row.get("is_featured", False)),
created_by=int(row["created_by"]) if row.get("created_by") is not None else None,
updated_by=int(row["updated_by"]) if row.get("updated_by") is not None else None,
created_at=self._stringify_time(row.get("created_at")),
updated_at=self._stringify_time(row.get("updated_at")),
)
@@ -315,3 +594,180 @@ class ContractTemplateServiceImpl(IContractTemplateService):
if value is None:
return None
return str(value)
def _build_template_scope_filters(
self,
currentUser: dict[str, Any],
params: dict[str, Any],
requestedRegion: str | None,
writable: bool = False,
) -> list[str]:
requested = (requestedRegion or "").strip()
area = str(currentUser["area"] or "").strip()
if currentUser["is_global"]:
if requested:
params["requested_region"] = requested
return ["t.region = :requested_region"]
return ["1=1"]
if writable:
if not area:
return ["1=0"]
if requested and requested != area:
return ["1=0"]
params["scope_region"] = area
return ["t.region = :scope_region"]
if currentUser["can_manage"]:
if not area:
return ["1=0"]
if requested:
if requested == "省级":
params["requested_region"] = requested
return ["t.region = :requested_region"]
if requested != area:
return ["1=0"]
params["requested_region"] = requested
return ["t.region = :requested_region"]
params["visible_regions"] = ["省级", area]
return ["t.region IN :visible_regions"]
if requested:
if requested == "省级":
params["requested_region"] = requested
return ["t.region = :requested_region"]
if area and requested == area:
params["requested_region"] = requested
return ["t.region = :requested_region"]
return ["1=0"]
if area:
params["visible_regions"] = ["省级", area]
return ["t.region IN :visible_regions"]
params["requested_region"] = "省级"
return ["t.region = :requested_region"]
async def _getCurrentUserContext(self, CurrentUserId: int, session=None) -> dict[str, Any]:
own_session = False
if session is None:
own_session = True
session_cm = GetAsyncSession()
session = await session_cm.__aenter__()
try:
row = (
await session.execute(
text(
"""
SELECT
u.id,
COALESCE(u.area, '') AS area,
COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global,
COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage
FROM sso_users u
LEFT JOIN user_role ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = :user_id
GROUP BY u.id, u.area
"""
),
{"user_id": CurrentUserId},
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在")
return {
"id": int(row["id"]),
"area": str(row["area"] or ""),
"is_global": bool(row["is_global"]),
"can_manage": bool(row["can_manage"]),
"is_area_admin": bool(row["can_manage"]) and not bool(row["is_global"]),
}
finally:
if own_session:
await session_cm.__aexit__(None, None, None)
def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None) -> str:
_ = requestedRegion
area = str(currentUser["area"] or "").strip()
if not currentUser.get("is_area_admin"):
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持地区管理员上传合同模板")
if not area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前地区管理员账号未配置所属地区,无法上传合同模板")
return area
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()
@@ -26,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import (
CrossReviewTaskQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskDocumentAppendVO,
CrossReviewTaskHistoryVersionVO,
CrossReviewPendingProposalVO,
CrossReviewPendingVotesVO,
CrossReviewPermissionVO,
@@ -463,14 +465,16 @@ class CrossReviewServiceImpl(ICrossReviewService):
"limit": Body.pageSize,
"offset": (Body.page - 1) * Body.pageSize,
}
whereClauses = [
baseWhereClauses = [
"td.task_id = :task_id",
"td.delete_time IS NULL",
"d.deleted_at IS NULL",
]
if Body.keyword:
whereClauses.append("(d.normalized_name ILIKE :keyword OR CAST(d.biz_document_id AS TEXT) ILIKE :keyword)")
baseWhereClauses.append("(d.normalized_name ILIKE :keyword OR CAST(d.biz_document_id AS TEXT) ILIKE :keyword)")
params["keyword"] = f"%{Body.keyword.strip()}%"
whereSql = " AND ".join(whereClauses)
baseWhereSql = " AND ".join(baseWhereClauses)
latestWhereSql = f"{baseWhereSql} AND COALESCE(d.is_latest_version, false) = true"
total = int(
(
@@ -481,7 +485,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE {whereSql}
WHERE {latestWhereSql}
"""
),
params,
@@ -510,7 +514,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
) AS processing_status,
d.version_no,
d.is_latest_version,
COALESCE(d.version_group_key, '') AS version_group_key,
COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS version_group_key,
COALESCE(vc.total_versions, 1)::int AS total_versions,
d.created_at,
td.audit_status,
@@ -549,7 +553,9 @@ class CrossReviewServiceImpl(ICrossReviewService):
LIMIT 1
) df ON TRUE
LEFT JOIN (
SELECT d2.version_group_key, COUNT(*) AS total_versions
SELECT
COALESCE(NULLIF(d2.version_group_key, ''), CONCAT('root:', COALESCE(d2.root_version_id, d2.id)::text)) AS version_group_key,
COUNT(*) AS total_versions
FROM leaudit_documents d2
JOIN leaudit_cross_review_task_documents td2
ON td2.document_id = d2.id
@@ -557,7 +563,8 @@ class CrossReviewServiceImpl(ICrossReviewService):
AND td2.task_id = :task_id
WHERE d2.deleted_at IS NULL
GROUP BY d2.version_group_key
) vc ON vc.version_group_key = d.version_group_key
, COALESCE(d2.root_version_id, d2.id)
) vc ON vc.version_group_key = COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text))
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS total_evaluation_points,
@@ -610,7 +617,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
AND p.status = 'approved'
AND p.delete_time IS NULL
) pd ON TRUE
WHERE {whereSql}
WHERE {latestWhereSql}
ORDER BY d.created_at DESC, d.id DESC
LIMIT :limit OFFSET :offset
"""
@@ -619,47 +626,208 @@ class CrossReviewServiceImpl(ICrossReviewService):
)
).mappings().all()
items = [
CrossReviewTaskDocumentVO(
documentId=int(row["document_id"]),
name=str(row["name"] or ""),
documentNumber=row.get("document_number"),
typeId=self._to_int(row.get("type_id")),
typeName=row.get("type_name"),
processingStatus=row.get("processing_status"),
versionNo=int(row.get("version_no") or 1),
isLatestVersion=bool(row.get("is_latest_version")),
versionGroupKey=str(row.get("version_group_key") or ""),
totalVersions=int(row.get("total_versions") or 1),
auditStatus=int(row.get("audit_status") or 0),
createdAt=row.get("created_at"),
fileSize=int(row.get("file_size") or 0),
path=str(row.get("path") or ""),
uploadTime=row.get("upload_time"),
fileExt=str(row.get("file_ext") or "") or None,
totalEvaluationPoints=int(row.get("total_evaluation_points") or 0),
passCount=int(row.get("pass_count") or 0),
warningCount=int(row.get("warning_count") or 0),
errorCount=int(row.get("error_count") or 0),
manualCount=int(row.get("manual_count") or 0),
issueCount=int(row.get("issue_count") or 0),
warningMessages=self._parse_text_array(row.get("warning_messages")),
errorMessages=self._parse_text_array(row.get("error_messages")),
issueMessages=self._parse_text_array(row.get("issue_messages")),
manualMessages=self._parse_text_array(row.get("manual_messages")),
finalScore=float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0),
fullScore=float(row.get("full_score") or 0),
scoreSummary=self._build_score_summary(
float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0),
float(row.get("full_score") or 0),
),
scorePercent=self._build_score_percent(
float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0),
float(row.get("full_score") or 0),
),
latestDocumentIds = [int(row["document_id"]) for row in rows]
historyByGroup: dict[str, list[CrossReviewTaskHistoryVersionVO]] = {}
if latestDocumentIds:
historyRows = (
await session.execute(
text(
"""
WITH target_groups AS (
SELECT DISTINCT COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS version_group_key
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE td.task_id = :task_id
AND td.delete_time IS NULL
AND d.id = ANY(:document_ids)
)
SELECT
d.id AS document_id,
COALESCE(d.normalized_name, '') AS name,
CAST(d.biz_document_id AS TEXT) AS document_number,
d.type_id,
COALESCE(
CASE
WHEN LOWER(COALESCE(ar.status, '')) IN ('pending', 'queued', 'running', 'retrying')
THEN ar.status
ELSE d.processing_status
END,
d.processing_status,
'waiting'
) AS processing_status,
d.version_no,
d.is_latest_version,
COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS version_group_key,
td.audit_status,
d.created_at,
COALESCE(dt.name, '') AS type_name,
COALESCE(df.file_size, 0) AS file_size,
COALESCE(df.file_path, '') AS path,
df.file_upload_time AS upload_time,
COALESCE(df.file_ext, '') AS file_ext,
COALESCE(es.total_evaluation_points, 0) AS total_evaluation_points,
COALESCE(es.pass_count, 0) AS pass_count,
COALESCE(es.warning_count, 0) AS warning_count,
COALESCE(es.error_count, 0) AS error_count,
COALESCE(es.manual_count, 0) AS manual_count,
COALESCE(es.issue_count, 0) AS issue_count,
COALESCE(es.warning_messages, ARRAY[]::text[]) AS warning_messages,
COALESCE(es.error_messages, ARRAY[]::text[]) AS error_messages,
COALESCE(es.issue_messages, ARRAY[]::text[]) AS issue_messages,
COALESCE(es.manual_messages, ARRAY[]::text[]) AS manual_messages,
COALESCE(es.final_score, 0) AS final_score,
COALESCE(es.full_score, 0) AS full_score,
COALESCE(pd.approved_delta, 0) AS approved_delta
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
JOIN target_groups tg
ON tg.version_group_key = COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text))
LEFT JOIN leaudit_audit_runs ar
ON ar.id = d.current_run_id
LEFT JOIN leaudit_document_types dt
ON dt.id = d.type_id
LEFT JOIN LATERAL (
SELECT file_size, local_path AS file_path, created_at AS file_upload_time,
COALESCE(file_ext, '') AS file_ext
FROM leaudit_document_files
WHERE document_id = d.id
ORDER BY id ASC
LIMIT 1
) df ON TRUE
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS total_evaluation_points,
COUNT(*) FILTER (WHERE rr.passed IS TRUE)::int AS pass_count,
COUNT(*) FILTER (WHERE rr.passed IS FALSE AND rr.risk = 'high')::int AS error_count,
COUNT(*) FILTER (WHERE rr.passed IS FALSE AND rr.risk IN ('low', 'medium'))::int AS warning_count,
0::int AS manual_count,
COUNT(*) FILTER (WHERE rr.passed IS FALSE)::int AS issue_count,
ARRAY_AGG(rr.fail_message ORDER BY rr.id) FILTER (
WHERE rr.passed IS FALSE AND rr.risk = 'high' AND rr.fail_message IS NOT NULL AND rr.fail_message != ''
) AS error_messages,
ARRAY_AGG(rr.fail_message ORDER BY rr.id) FILTER (
WHERE rr.passed IS FALSE AND rr.risk IN ('low', 'medium') AND rr.fail_message IS NOT NULL AND rr.fail_message != ''
) AS warning_messages,
ARRAY_AGG(rr.fail_message ORDER BY rr.id) FILTER (
WHERE rr.passed IS FALSE AND rr.fail_message IS NOT NULL AND rr.fail_message != ''
) AS issue_messages,
ARRAY[]::text[] AS manual_messages,
COALESCE(SUM(rr.score) FILTER (WHERE rr.passed IS TRUE), 0) AS final_score,
COALESCE(SUM(rr.score), 0) AS full_score
FROM leaudit_rule_results rr
WHERE rr.document_id = d.id
AND rr.run_id = d.current_run_id
) es ON TRUE
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(proposed_score_delta), 0) AS approved_delta
FROM leaudit_cross_review_proposals p
WHERE p.document_id = d.id
AND p.status = 'approved'
AND p.delete_time IS NULL
) pd ON TRUE
WHERE td.task_id = :task_id
AND td.delete_time IS NULL
AND d.deleted_at IS NULL
ORDER BY COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)), d.version_no DESC, d.id DESC
"""
).bindparams(bindparam("document_ids", expanding=False)),
{"task_id": TaskId, "document_ids": latestDocumentIds},
)
).mappings().all()
groupedRows: dict[str, list[dict]] = {}
for row in historyRows:
groupedRows.setdefault(str(row.get("version_group_key") or ""), []).append(dict(row))
for groupKey, groupRows in groupedRows.items():
orderedRows = sorted(
groupRows,
key=lambda item: (int(item.get("version_no") or 1), int(item.get("document_id") or 0)),
)
localVersionMap = {
int(item["document_id"]): index + 1 for index, item in enumerate(orderedRows)
}
historyItems: list[CrossReviewTaskHistoryVersionVO] = []
for item in reversed(orderedRows[:-1]):
finalScore = float(item.get("final_score") or 0) + float(item.get("approved_delta") or 0)
fullScore = float(item.get("full_score") or 0)
historyItems.append(
CrossReviewTaskHistoryVersionVO(
documentId=int(item["document_id"]),
name=str(item.get("name") or ""),
documentNumber=item.get("document_number"),
typeId=self._to_int(item.get("type_id")),
typeName=item.get("type_name"),
processingStatus=item.get("processing_status"),
versionNo=int(localVersionMap.get(int(item["document_id"]), 1)),
auditStatus=int(item.get("audit_status") or 0),
createdAt=item.get("created_at"),
fileSize=int(item.get("file_size") or 0),
path=str(item.get("path") or ""),
uploadTime=item.get("upload_time"),
fileExt=str(item.get("file_ext") or "") or None,
totalEvaluationPoints=int(item.get("total_evaluation_points") or 0),
passCount=int(item.get("pass_count") or 0),
warningCount=int(item.get("warning_count") or 0),
errorCount=int(item.get("error_count") or 0),
manualCount=int(item.get("manual_count") or 0),
issueCount=int(item.get("issue_count") or 0),
warningMessages=self._parse_text_array(item.get("warning_messages")),
errorMessages=self._parse_text_array(item.get("error_messages")),
issueMessages=self._parse_text_array(item.get("issue_messages")),
manualMessages=self._parse_text_array(item.get("manual_messages")),
finalScore=finalScore,
fullScore=fullScore,
scoreSummary=self._build_score_summary(finalScore, fullScore),
scorePercent=self._build_score_percent(finalScore, fullScore),
)
)
historyByGroup[groupKey] = historyItems
items: list[CrossReviewTaskDocumentVO] = []
for row in rows:
groupKey = str(row.get("version_group_key") or "")
historyVersions = historyByGroup.get(groupKey, [])
finalScore = float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0)
fullScore = float(row.get("full_score") or 0)
versionNo = int(row.get("total_versions") or 1)
items.append(
CrossReviewTaskDocumentVO(
documentId=int(row["document_id"]),
name=str(row["name"] or ""),
documentNumber=row.get("document_number"),
typeId=self._to_int(row.get("type_id")),
typeName=row.get("type_name"),
processingStatus=row.get("processing_status"),
versionNo=versionNo,
isLatestVersion=True,
versionGroupKey=groupKey,
totalVersions=int(row.get("total_versions") or 1),
auditStatus=int(row.get("audit_status") or 0),
createdAt=row.get("created_at"),
fileSize=int(row.get("file_size") or 0),
path=str(row.get("path") or ""),
uploadTime=row.get("upload_time"),
fileExt=str(row.get("file_ext") or "") or None,
totalEvaluationPoints=int(row.get("total_evaluation_points") or 0),
passCount=int(row.get("pass_count") or 0),
warningCount=int(row.get("warning_count") or 0),
errorCount=int(row.get("error_count") or 0),
manualCount=int(row.get("manual_count") or 0),
issueCount=int(row.get("issue_count") or 0),
warningMessages=self._parse_text_array(row.get("warning_messages")),
errorMessages=self._parse_text_array(row.get("error_messages")),
issueMessages=self._parse_text_array(row.get("issue_messages")),
manualMessages=self._parse_text_array(row.get("manual_messages")),
finalScore=finalScore,
fullScore=fullScore,
scoreSummary=self._build_score_summary(finalScore, fullScore),
scorePercent=self._build_score_percent(finalScore, fullScore),
historyVersions=historyVersions,
)
)
for row in rows
]
return CrossReviewTaskDocumentPageVO(
taskId=TaskId,
total=total,
@@ -1242,6 +1410,84 @@ class CrossReviewServiceImpl(ICrossReviewService):
processingStatus=uploadResult.processingStatus,
)
async def AppendTaskDocumentAttachments(
self,
CurrentUserId: int,
TaskId: int,
DocumentId: int,
Files: list[tuple[str, bytes, str | None]],
Remark: str | None = None,
) -> CrossReviewTaskDocumentAppendVO:
"""为交叉评查任务文档追加附件,并生成同版本链新版本。"""
async with GetAsyncSession() as session:
await self._ensure_tables_ready(session)
permission = await self.CanConfirmTaskDocument(CurrentUserId, TaskId)
if not permission.canConfirm:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, permission.reason)
await self._ensure_task_document(session, TaskId, DocumentId)
appendResult = await self.DocumentService.AppendAttachments(
CurrentUserId=CurrentUserId,
Id=DocumentId,
Files=Files,
MergeMode="new",
Remark=Remark,
)
async with GetAsyncSession() as session:
await self._ensure_tables_ready(session)
await self._reset_transaction_for_write(session)
async with session.begin():
exists = bool(
await session.scalar(
text(
"""
SELECT 1
FROM leaudit_cross_review_task_documents
WHERE task_id = :task_id
AND document_id = :document_id
AND delete_time IS NULL
LIMIT 1
"""
),
{"task_id": TaskId, "document_id": appendResult.documentId},
)
)
if not exists:
await session.execute(
text(
"""
INSERT INTO leaudit_cross_review_task_documents
(task_id, document_id, audit_status)
VALUES
(:task_id, :document_id, 0)
"""
),
{"task_id": TaskId, "document_id": appendResult.documentId},
)
await session.execute(
text(
"""
UPDATE leaudit_cross_review_tasks
SET status = 'in_progress',
update_time = NOW()
WHERE id = :task_id
AND delete_time IS NULL
"""
),
{"task_id": TaskId},
)
return CrossReviewTaskDocumentAppendVO(
taskId=TaskId,
originalDocumentId=DocumentId,
documentId=appendResult.documentId,
versionNo=appendResult.versionNo,
versionGroupKey=appendResult.versionGroupKey,
auditStatus=0,
processingStatus=appendResult.processingStatus,
)
async def _build_document_proposals_page(
self,
session,
@@ -11,12 +11,17 @@ from datetime import date as date_type, datetime
import hashlib
import mimetypes
import re
import tempfile
import time
import unicodedata
import uuid
from copy import deepcopy
from pathlib import Path
from sqlalchemy import text
import fitz
from leaudit.converters import doc2pdf
from sqlalchemy import bindparam, text
from docx import Document as DocxDocument
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
@@ -49,6 +54,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.reviewPointVo import (
ReviewPointsAggregateVO,
)
from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile
from fastapi_modules.fastapi_leaudit.leaudit_bridge.fileSourceResolver import FileSourceResolver
from fastapi_modules.fastapi_leaudit.services import IAuditService, IDocumentService, IOssService
from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import AuditServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
@@ -313,7 +319,12 @@ class DocumentServiceImpl(IDocumentService):
documentColumns = await self._loadDocumentColumns(Session)
currentUser = await self._getCurrentUserContext(CurrentUserId)
filters = ["d.is_latest_version = true", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
filters = [
"d.is_latest_version = true",
"d.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'primary'",
]
params: dict[str, object] = {"limit": page_size, "offset": offset}
filters.extend(
self._buildDocumentScopeFilters(
@@ -516,6 +527,7 @@ class DocumentServiceImpl(IDocumentService):
f.file_name,
f.file_ext,
f.file_size,
f.oss_url,
ar.status AS run_status,
ar.result_status,
ar.total_score,
@@ -555,6 +567,7 @@ class DocumentServiceImpl(IDocumentService):
fileName=row["file_name"],
fileExt=row["file_ext"],
fileSize=int(row["file_size"]) if row["file_size"] is not None else None,
ossUrl=row["oss_url"],
processingStatus=row["processing_status"],
runStatus=row["run_status"],
resultStatus=row["result_status"],
@@ -1049,11 +1062,687 @@ class DocumentServiceImpl(IDocumentService):
await Session.commit()
async def _load_active_primary_file_row(
self,
Session,
*,
DocumentId: int,
) -> dict[str, Any]:
row = (
await Session.execute(
text(
"""
SELECT
id,
file_name,
file_ext,
mime_type,
file_role,
oss_url,
local_path
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'primary'
ORDER BY id DESC
LIMIT 1
"""
),
{"document_id": DocumentId},
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少主文件")
return dict(row)
def _normalize_document_source_kind(self, file_name: str, file_ext: str | None, mime_type: str | None) -> str:
suffix = (f".{str(file_ext).lstrip('.')}" if file_ext else Path(file_name).suffix).lower()
normalizedMime = (mime_type or "").lower()
if suffix == ".pdf" or normalizedMime == "application/pdf":
return "pdf"
if suffix == ".docx" or normalizedMime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return "docx"
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前仅支持 PDF 或 DOCX 主文档追加附件")
def _normalize_attachment_source_kind(self, file_name: str, file_ext: str | None, mime_type: str | None) -> str:
suffix = (f".{str(file_ext).lstrip('.')}" if file_ext else Path(file_name).suffix).lower()
normalizedMime = (mime_type or "").lower()
if suffix == ".pdf" or normalizedMime == "application/pdf":
return "pdf"
if suffix == ".docx" or normalizedMime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return "docx"
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"附件 {file_name} 仅支持 PDF 或 DOCX 格式")
def _validate_attachment_matrix(
self,
*,
MainSourceKind: str,
Files: list[tuple[str, bytes, str | None]],
) -> None:
for fileName, _, contentType in Files:
attachmentKind = self._normalize_attachment_source_kind(fileName, Path(fileName).suffix.lstrip(".").lower() or None, contentType)
if MainSourceKind == "docx" and attachmentKind != "docx":
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
"源文档为 DOCX 时,仅允许追加 DOCX 附件,且合并结果仍为 DOCX",
)
def _merge_docx_paths_for_merge(
self,
*,
DocxPaths: list[str],
TempPaths: list[str],
) -> str:
if not DocxPaths:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "缺少可合并的 DOCX 文件")
mergedTemp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
mergedTemp.close()
TempPaths.append(mergedTemp.name)
baseDoc = DocxDocument(DocxPaths[0])
for attachmentPath in DocxPaths[1:]:
attachmentDoc = DocxDocument(attachmentPath)
if baseDoc.paragraphs:
baseDoc.add_page_break()
for element in attachmentDoc.element.body:
baseDoc.element.body.append(deepcopy(element))
baseDoc.save(mergedTemp.name)
return mergedTemp.name
async def _cloneActiveFilesToNewDocument(
self,
Session,
*,
SourceDocumentId: int,
TargetDocumentId: int,
CreatedBy: int | None,
IncludeAttachments: bool = True,
) -> None:
"""复制当前文档的主文件与现有附件到新版本文档。"""
fileRoles = ["primary", "attachment"] if IncludeAttachments else ["primary"]
sourceFiles = (
await Session.execute(
text(
"""
SELECT
id,
file_role,
file_name,
file_ext,
mime_type,
file_size,
sha256,
local_path,
oss_url,
storage_provider
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = ANY(:file_roles)
ORDER BY
CASE WHEN file_role = 'primary' THEN 0 ELSE 1 END,
id ASC
"""
).bindparams(bindparam("file_roles", expanding=False)),
{"document_id": SourceDocumentId, "file_roles": fileRoles},
)
).mappings().all()
if not sourceFiles:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "源文档缺少可复制的有效文件")
for row in sourceFiles:
clonedFile = LeauditDocumentFile(
documentId=TargetDocumentId,
fileRole=str(row["file_role"] or "attachment"),
fileName=str(row["file_name"] or ""),
fileExt=row["file_ext"],
mimeType=row["mime_type"],
fileSize=int(row["file_size"]) if row["file_size"] is not None else None,
sha256=row["sha256"],
localPath=row["local_path"],
ossUrl=row["oss_url"],
storageProvider=row["storage_provider"],
isActive=True,
createdBy=CreatedBy,
)
Session.add(clonedFile)
await Session.flush()
async def _buildMergedPdfBytesForDocument(
self,
Session,
*,
DocumentId: int,
) -> tuple[bytes, str]:
"""读取当前主文件与附件,生成新的主 PDF 内容。"""
primaryFile = (
await Session.execute(
text(
"""
SELECT id
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'primary'
ORDER BY id DESC
LIMIT 1
"""
),
{"document_id": DocumentId},
)
).scalar_one_or_none()
if primaryFile is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少主文件,无法生成合并文件")
attachmentRows = (
await Session.execute(
text(
"""
SELECT id
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'attachment'
ORDER BY id ASC
"""
),
{"document_id": DocumentId},
)
).scalars().all()
if not attachmentRows:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档没有附件,无法生成合并文件")
resolver = FileSourceResolver()
primaryModel = await Session.get(LeauditDocumentFile, int(primaryFile))
if primaryModel is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "主文件记录不存在,无法生成合并文件")
primaryPayload = await resolver.ResolvePayload(primaryModel)
attachmentModels: list[LeauditDocumentFile] = []
for attachmentId in attachmentRows:
attachmentModel = await Session.get(LeauditDocumentFile, int(attachmentId))
if attachmentModel is not None:
attachmentModels.append(attachmentModel)
attachmentPayloads = await resolver.ResolvePayloads(attachmentModels)
tempPaths: list[str] = []
try:
mainLocalPath = self._write_temp_file_for_merge(
FileName=primaryPayload.fileName,
Content=primaryPayload.fileContent,
TempPaths=tempPaths,
)
mainPdfPath = self._convert_source_to_pdf(
SourcePath=mainLocalPath,
TempPaths=tempPaths,
)
pdfPaths = [mainPdfPath]
for attachmentPayload in attachmentPayloads:
attachmentLocalPath = self._write_temp_file_for_merge(
FileName=attachmentPayload.fileName,
Content=attachmentPayload.fileContent,
TempPaths=tempPaths,
)
attachmentPdfPath = self._convert_source_to_pdf(
SourcePath=attachmentLocalPath,
TempPaths=tempPaths,
)
pdfPaths.append(attachmentPdfPath)
mergedPdfPath = self._merge_pdf_paths_for_merge(
PdfPaths=pdfPaths,
TempPaths=tempPaths,
)
mergedBytes = Path(mergedPdfPath).read_bytes()
mergedName = f"{Path(primaryPayload.fileName).stem}.pdf"
return mergedBytes, mergedName
except LeauditException:
raise
except Exception as error:
raise LeauditException(
StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR,
f"生成合并PDF失败: {error}",
) from error
finally:
for tempPath in reversed(tempPaths):
try:
pathObj = Path(tempPath)
if pathObj.is_file():
pathObj.unlink(missing_ok=True)
except Exception:
pass
async def _buildMergedDocxBytesForDocument(
self,
Session,
*,
DocumentId: int,
) -> tuple[bytes, str]:
"""读取当前主文件与附件,生成新的主 DOCX 内容。"""
primaryFile = await self._load_active_primary_file_row(Session, DocumentId=DocumentId)
if self._normalize_document_source_kind(
str(primaryFile["file_name"] or ""),
primaryFile.get("file_ext"),
primaryFile.get("mime_type"),
) != "docx":
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "仅 DOCX 主文档支持生成 DOCX 合并结果")
attachmentRows = (
await Session.execute(
text(
"""
SELECT id
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'attachment'
ORDER BY id ASC
"""
),
{"document_id": DocumentId},
)
).scalars().all()
if not attachmentRows:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档没有附件,无法生成合并文件")
resolver = FileSourceResolver()
primaryModel = await Session.get(LeauditDocumentFile, int(primaryFile["id"]))
if primaryModel is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "主文件记录不存在,无法生成合并文件")
primaryPayload = await resolver.ResolvePayload(primaryModel)
attachmentModels: list[LeauditDocumentFile] = []
for attachmentId in attachmentRows:
attachmentModel = await Session.get(LeauditDocumentFile, int(attachmentId))
if attachmentModel is not None:
attachmentModels.append(attachmentModel)
attachmentPayloads = await resolver.ResolvePayloads(attachmentModels)
tempPaths: list[str] = []
try:
docxPaths = [
self._write_temp_file_for_merge(
FileName=primaryPayload.fileName,
Content=primaryPayload.fileContent,
TempPaths=tempPaths,
)
]
for attachmentPayload in attachmentPayloads:
attachmentKind = self._normalize_attachment_source_kind(
attachmentPayload.fileName,
Path(attachmentPayload.fileName).suffix.lstrip(".").lower() or None,
None,
)
if attachmentKind != "docx":
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"DOCX 主文档仅允许追加 DOCX 附件,当前附件 {attachmentPayload.fileName} 不符合要求",
)
docxPaths.append(
self._write_temp_file_for_merge(
FileName=attachmentPayload.fileName,
Content=attachmentPayload.fileContent,
TempPaths=tempPaths,
)
)
mergedDocxPath = self._merge_docx_paths_for_merge(
DocxPaths=docxPaths,
TempPaths=tempPaths,
)
mergedBytes = Path(mergedDocxPath).read_bytes()
mergedName = f"{Path(primaryPayload.fileName).stem}.docx"
return mergedBytes, mergedName
finally:
for tempPath in reversed(tempPaths):
try:
pathObj = Path(tempPath)
if pathObj.is_file():
pathObj.unlink(missing_ok=True)
except Exception:
pass
def _write_temp_file_for_merge(
self,
*,
FileName: str,
Content: bytes,
TempPaths: list[str],
) -> str:
"""为持久化合并流程写入临时文件。"""
suffix = Path(FileName).suffix or ".bin"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tempFile:
tempFile.write(Content)
tempPath = tempFile.name
TempPaths.append(tempPath)
return tempPath
def _convert_source_to_pdf(
self,
*,
SourcePath: str,
TempPaths: list[str],
) -> str:
"""将源文件转换为 PDF。"""
source = Path(SourcePath)
if source.suffix.lower() == ".pdf":
return str(source)
pdfTemp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
pdfTemp.close()
TempPaths.append(pdfTemp.name)
doc2pdf.convert(source, pdfTemp.name, soffice="auto", pdfa=False, force=True, verify=False)
return pdfTemp.name
def _merge_pdf_paths_for_merge(
self,
*,
PdfPaths: list[str],
TempPaths: list[str],
) -> str:
"""合并多个 PDF 为一个临时 PDF。"""
mergedTemp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
mergedTemp.close()
TempPaths.append(mergedTemp.name)
output = fitz.open()
try:
for pdfPath in PdfPaths:
source = fitz.open(pdfPath)
try:
output.insert_pdf(source)
finally:
source.close()
output.save(mergedTemp.name)
finally:
output.close()
return mergedTemp.name
async def _persistMergedPdfForDocument(
self,
Session,
*,
DocumentId: int,
TypeCode: str,
Region: str,
VersionNo: int,
CreatedBy: int | None,
FileRole: str = "primary",
) -> None:
"""为当前文档生成并替换主 PDF 文件。"""
mergedBytes, mergedName = await self._buildMergedPdfBytesForDocument(
Session,
DocumentId=DocumentId,
)
mergedSha256 = hashlib.sha256(mergedBytes).hexdigest()
mergedSize = len(mergedBytes)
uploadedAt = datetime.now()
versionLabel = f"v{VersionNo}"
objectKey = OssPathUtils.BuildBusinessDocKey(
Region=Region,
TypeCode=TypeCode,
DocumentId=DocumentId,
Version=versionLabel,
FileRole=FileRole,
FileName=mergedName,
Year=uploadedAt.year,
Month=uploadedAt.month,
)
ossUrl = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=mergedBytes,
ContentType="application/pdf",
)
if FileRole == "primary":
await Session.execute(
text(
"""
UPDATE leaudit_document_files
SET is_active = false
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role IN ('primary', 'merged_pdf', 'merged_docx')
"""
),
{"document_id": DocumentId},
)
else:
await Session.execute(
text(
"""
UPDATE leaudit_document_files
SET is_active = false
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = :file_role
"""
),
{"document_id": DocumentId, "file_role": FileRole},
)
Session.add(
LeauditDocumentFile(
documentId=DocumentId,
fileRole=FileRole,
fileName=mergedName,
fileExt="pdf",
mimeType="application/pdf",
fileSize=mergedSize,
sha256=mergedSha256,
localPath=None,
ossUrl=ossUrl,
storageProvider="minio",
isActive=True,
createdBy=CreatedBy,
)
)
await Session.flush()
async def _persistMergedDocxForDocument(
self,
Session,
*,
DocumentId: int,
TypeCode: str,
Region: str,
VersionNo: int,
CreatedBy: int | None,
) -> None:
"""为当前文档生成并替换主 DOCX 文件。"""
mergedBytes, mergedName = await self._buildMergedDocxBytesForDocument(
Session,
DocumentId=DocumentId,
)
mergedSha256 = hashlib.sha256(mergedBytes).hexdigest()
mergedSize = len(mergedBytes)
uploadedAt = datetime.now()
versionLabel = f"v{VersionNo}"
objectKey = OssPathUtils.BuildBusinessDocKey(
Region=Region,
TypeCode=TypeCode,
DocumentId=DocumentId,
Version=versionLabel,
FileRole="primary",
FileName=mergedName,
Year=uploadedAt.year,
Month=uploadedAt.month,
)
ossUrl = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=mergedBytes,
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
await Session.execute(
text(
"""
UPDATE leaudit_document_files
SET is_active = false
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role IN ('primary', 'merged_pdf', 'merged_docx')
"""
),
{"document_id": DocumentId},
)
Session.add(
LeauditDocumentFile(
documentId=DocumentId,
fileRole="primary",
fileName=mergedName,
fileExt="docx",
mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileSize=mergedSize,
sha256=mergedSha256,
localPath=None,
ossUrl=ossUrl,
storageProvider="minio",
isActive=True,
createdBy=CreatedBy,
)
)
await Session.flush()
async def _createNextVersionFromExistingDocument(
self,
Session,
*,
SourceDocumentId: int,
CreatedBy: int,
Remark: str | None = None,
) -> tuple[LeauditDocument, str, str]:
"""基于现有文档创建一个新版本,并复制当前主文件/附件。"""
documentColumns = await self._loadDocumentColumns(Session)
optionalSelects = [
"d.document_number AS document_number" if "document_number" in documentColumns else "NULL::text AS document_number",
"d.remark AS remark" if "remark" in documentColumns else "NULL::text AS remark",
"d.is_test_document AS is_test_document" if "is_test_document" in documentColumns else "FALSE AS is_test_document",
"d.audit_status AS audit_status" if "audit_status" in documentColumns else "NULL::integer AS audit_status",
]
sourceRow = (
await Session.execute(
text(
"""
SELECT
d.id,
d.biz_document_id,
d.type_id,
d.group_id,
d.region,
d.processing_status,
d.version_group_key,
d.version_no,
d.previous_version_id,
d.root_version_id,
d.is_latest_version,
d.normalized_name,
d.review_scope,
"""
+ ",\n ".join(optionalSelects)
+ """
,
dt.code AS type_code
FROM leaudit_documents d
LEFT JOIN leaudit_document_types dt
ON dt.id = d.type_id
WHERE d.id = :document_id
AND d.deleted_at IS NULL
LIMIT 1
"""
),
{"document_id": SourceDocumentId},
)
).mappings().first()
if not sourceRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
resolvedTypeCode = str(sourceRow["type_code"] or "").strip()
if not resolvedTypeCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少文档类型编码,无法创建新版本")
previousDocument = await Session.get(LeauditDocument, SourceDocumentId)
if previousDocument is None:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
previousDocument.isLatestVersion = False
documentFields: dict[str, object] = {
"bizDocumentId": time.time_ns(),
"typeId": int(sourceRow["type_id"]) if sourceRow["type_id"] is not None else None,
"groupId": int(sourceRow["group_id"]) if sourceRow["group_id"] is not None else None,
"region": str(sourceRow["region"] or "default").strip() or "default",
"processingStatus": "waiting",
"versionGroupKey": str(sourceRow["version_group_key"] or uuid.uuid4().hex),
"versionNo": int(sourceRow["version_no"] or 1) + 1,
"previousVersionId": SourceDocumentId,
"rootVersionId": int(sourceRow["root_version_id"] or SourceDocumentId),
"isLatestVersion": True,
"normalizedName": sourceRow["normalized_name"],
"reviewScope": str(sourceRow["review_scope"] or "standard"),
}
newDocument = await LeauditDocument.create_new(Session, **documentFields)
if newDocument.rootVersionId is None:
newDocument.rootVersionId = newDocument.Id
assignments: list[str] = []
params: dict[str, object] = {"id": int(newDocument.Id)}
if "document_number" in documentColumns:
assignments.append("document_number = :document_number")
params["document_number"] = sourceRow["document_number"]
if "remark" in documentColumns:
assignments.append("remark = :remark")
params["remark"] = Remark.strip() if Remark and Remark.strip() else sourceRow["remark"]
if "is_test_document" in documentColumns:
assignments.append("is_test_document = :is_test_document")
params["is_test_document"] = bool(sourceRow["is_test_document"])
if "audit_status" in documentColumns:
assignments.append("audit_status = :audit_status")
params["audit_status"] = int(sourceRow["audit_status"]) if sourceRow["audit_status"] is not None else None
if assignments:
assignments.append("updated_at = NOW()")
await Session.execute(
text(f"UPDATE leaudit_documents SET {', '.join(assignments)} WHERE id = :id"),
params,
)
await self._cloneActiveFilesToNewDocument(
Session,
SourceDocumentId=SourceDocumentId,
TargetDocumentId=newDocument.Id,
CreatedBy=CreatedBy,
IncludeAttachments=False,
)
await Session.flush()
return newDocument, resolvedTypeCode, str(sourceRow["region"] or "default").strip() or "default"
def _normalizeAttachmentMergeMode(self, MergeMode: str | None) -> str:
"""标准化附件合并模式。"""
normalizedMode = (MergeMode or "new").strip().lower()
if normalizedMode not in {"overwrite", "new"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "mergeMode 仅支持 overwrite 或 new")
return normalizedMode
async def DeleteDocument(self, CurrentUserId: int, Id: int) -> None:
"""软删除文档,并执行数据隔离校验。"""
async with GetAsyncSession() as Session:
currentUser = await self._getCurrentUserContext(CurrentUserId)
filters = ["d.id = :id", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
filters = [
"d.id = :id",
"d.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'primary'",
]
params: dict[str, object] = {"id": Id}
filters.extend(
self._buildDocumentScopeFilters(
@@ -1141,10 +1830,13 @@ class DocumentServiceImpl(IDocumentService):
CurrentUserId: int,
Id: int,
Files: list[tuple[str, bytes, str | None]],
MergeMode: str = "overwrite",
Remark: str | None = None,
) -> DocumentDetailVO:
"""为现有文档追加附件,并执行数据隔离校验。"""
if not Files:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "至少上传一个附件文件")
normalizedMergeMode = self._normalizeAttachmentMergeMode(MergeMode)
async with GetAsyncSession() as Session:
await self._ensureDocumentGroupColumn(Session)
@@ -1182,18 +1874,72 @@ class DocumentServiceImpl(IDocumentService):
if not resolvedTypeCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少文档类型编码,无法追加附件")
primaryFileRow = await self._load_active_primary_file_row(Session, DocumentId=Id)
mainSourceKind = self._normalize_document_source_kind(
str(primaryFileRow["file_name"] or ""),
primaryFileRow.get("file_ext"),
primaryFileRow.get("mime_type"),
)
self._validate_attachment_matrix(
MainSourceKind=mainSourceKind,
Files=Files,
)
targetDocumentId = int(documentMeta["document_id"])
targetVersionNo = int(documentMeta["version_no"] or 1)
normalizedRegion = str(documentMeta["region"] or "default").strip() or "default"
if normalizedMergeMode == "new":
newDocument, resolvedTypeCode, normalizedRegion = await self._createNextVersionFromExistingDocument(
Session,
SourceDocumentId=Id,
CreatedBy=CurrentUserId,
Remark=Remark,
)
targetDocumentId = int(newDocument.Id)
targetVersionNo = int(newDocument.versionNo or (int(documentMeta["version_no"] or 1) + 1))
await self._appendAttachmentFiles(
Session=Session,
DocumentId=int(documentMeta["document_id"]),
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=int(documentMeta["version_no"] or 1),
VersionNo=targetVersionNo,
Files=Files,
CreatedBy=CurrentUserId,
)
refreshed = await self._getDocumentDetail(Session, Id, CurrentUserId, currentUser, documentColumns)
if mainSourceKind == "docx":
await self._persistMergedDocxForDocument(
Session,
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=targetVersionNo,
CreatedBy=CurrentUserId,
)
await self._persistMergedPdfForDocument(
Session,
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=targetVersionNo,
CreatedBy=CurrentUserId,
FileRole="merged_pdf",
)
else:
await self._persistMergedPdfForDocument(
Session,
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=targetVersionNo,
CreatedBy=CurrentUserId,
)
await Session.commit()
refreshed = await self._getDocumentDetail(Session, targetDocumentId, CurrentUserId, currentUser, documentColumns)
if not refreshed:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
return refreshed
@@ -1949,7 +2695,12 @@ class DocumentServiceImpl(IDocumentService):
) -> DocumentDetailVO | None:
"""查询单文档详情,并附带历史版本。"""
params: dict[str, object] = {"id": DocumentId}
filters = ["d.id = :id", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
filters = [
"d.id = :id",
"d.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'primary'",
]
if not BypassScopeCheck:
filters.extend(
self._buildDocumentScopeFilters(
@@ -2065,6 +2816,7 @@ class DocumentServiceImpl(IDocumentService):
f.id AS file_id,
f.file_name,
f.file_ext,
f.oss_url,
ar.status AS run_status,
ar.result_status
FROM leaudit_documents d
@@ -2090,6 +2842,7 @@ class DocumentServiceImpl(IDocumentService):
versionNo=int(row["version_no"]),
fileName=row["file_name"],
fileExt=row["file_ext"],
ossUrl=row["oss_url"],
processingStatus=row["processing_status"],
runStatus=row["run_status"],
resultStatus=row["result_status"],
@@ -2913,8 +3666,9 @@ class DocumentServiceImpl(IDocumentService):
ORDER BY
CASE file_role
WHEN 'converted_pdf' THEN 0
WHEN 'merged_pdf' THEN 1
WHEN 'primary' THEN 2
WHEN 'primary' THEN 1
WHEN 'merged_docx' THEN 2
WHEN 'merged_pdf' THEN 3
ELSE 9
END,
id DESC
@@ -33,11 +33,10 @@ from fastapi_modules.fastapi_leaudit.domian.vo.ragChatVo import (
from fastapi_modules.fastapi_leaudit.rag_engine.config import (
RAG_CONFIG,
build_openai_chat_completions_url,
build_openai_embeddings_url,
)
from fastapi_modules.fastapi_leaudit.rag_engine.chroma_client import get_chroma
from fastapi_modules.fastapi_leaudit.rag_engine.generator import generate_stream
from fastapi_modules.fastapi_leaudit.rag_engine.question_chains import generate_followups
from fastapi_modules.fastapi_leaudit.rag_engine.retriever import RagRetriever
from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService
@@ -54,6 +53,9 @@ class RagChatServiceImpl(IRagChatService):
_task_locks: dict[str, asyncio.Lock] = {}
_title_tasks: dict[str, asyncio.Task] = {}
def __init__(self, retriever: RagRetriever | None = None) -> None:
self.retriever = retriever or RagRetriever()
async def GetApps(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppListVO:
apps = await self._load_apps(UserArea, UserRole, only_default=False)
return RagChatAppListVO(data=apps, total=len(apps))
@@ -592,121 +594,11 @@ class RagChatServiceImpl(IRagChatService):
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权访问该会话")
async def _retrieve_context(self, dataset_id: int | None, query: str) -> tuple[list[dict], str]:
if not dataset_id:
return [], ""
async with GetAsyncSession() as session:
dataset = (
await session.execute(
text(
"""
SELECT id, name, collection_name, retrieval_model, embedding_model
FROM rag_dataset
WHERE id = :dataset_id AND deleted_at IS NULL
LIMIT 1
"""
),
{"dataset_id": dataset_id},
)
).mappings().first()
if not dataset:
return [], ""
retrieval_model = dataset.get("retrieval_model") or {}
top_k = int(retrieval_model.get("top_k") or 5)
score_threshold = None
if retrieval_model.get("score_threshold_enabled"):
try:
score_threshold = float(retrieval_model.get("score_threshold"))
except (TypeError, ValueError):
score_threshold = None
try:
query_embedding = await self._embed_texts([query], dataset.get("embedding_model") or "")
collection = get_chroma().get_or_create_collection(dataset["collection_name"])
result = collection.query(
query_embeddings=query_embedding,
n_results=max(top_k, 1),
include=["documents", "metadatas", "distances"],
)
ids = (result.get("ids") or [[]])[0] if result.get("ids") else []
docs = (result.get("documents") or [[]])[0]
metas = (result.get("metadatas") or [[]])[0]
distances = (result.get("distances") or [[]])[0]
chunks: list[dict] = []
for idx, doc in enumerate(docs):
meta = metas[idx] if idx < len(metas) else {}
dist = float(distances[idx]) if idx < len(distances) and distances[idx] is not None else 1.0
score = 1.0 / (1.0 + max(dist, 0.0))
if score_threshold is not None and score < score_threshold:
continue
chunks.append(
{
"id": str(ids[idx] if idx < len(ids) else meta.get("id") or idx),
"text": doc,
"source": meta.get("source") or meta.get("document_name") or dataset.get("name") or "",
"score": score,
"chunk_index": int(meta.get("chunk_index") or idx),
"document_name": meta.get("document_name") or meta.get("source") or "",
"document_id": meta.get("document_id"),
"page": meta.get("page"),
}
)
chunks = await self._hydrate_document_hits(dataset_id, chunks)
if chunks:
return chunks[:top_k], dataset.get("name") or ""
except Exception:
pass
try:
chunks = await self._keyword_retrieve_context(
dataset_id=dataset_id,
collection_name=str(dataset["collection_name"]),
dataset_name=str(dataset.get("name") or ""),
query=query,
top_k=top_k,
score_threshold=score_threshold,
)
return chunks[:top_k], dataset.get("name") or ""
except Exception:
return [], dataset.get("name") or ""
result = await self.retriever.retrieve(query=query, dataset_id=dataset_id)
return result.chunks, result.dataset_name
async def _embed_texts(self, texts: list[str], model_name: str) -> list[list[float]]:
embed_url = (RAG_CONFIG.get("EMBED_URL") or "").strip() or build_openai_embeddings_url(RAG_CONFIG["LLM_BASE_URL"])
embed_key = (RAG_CONFIG.get("EMBED_KEY") or "").strip() or RAG_CONFIG["LLM_API_KEY"]
embed_model = model_name or (RAG_CONFIG.get("EMBED_MODEL") or "").strip() or "text-embedding-v4"
batch_size = max(1, int(RAG_CONFIG.get("EMBED_BATCH_SIZE") or 10))
if not embed_url or not embed_key:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "未配置可用的向量化服务")
embeddings: list[list[float]] = []
async with httpx.AsyncClient(timeout=120.0) as client:
for start in range(0, len(texts), batch_size):
batch_texts = texts[start:start + batch_size]
try:
response = await client.post(
embed_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {embed_key}",
},
json={"model": embed_model, "input": batch_texts},
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
error_message = exc.response.text.strip() or f"{exc.response.status_code} {exc.response.reason_phrase}"
raise LeauditException(
StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR,
f"向量化服务调用失败: {error_message[:300]}",
) from exc
payload = response.json()
rows = payload.get("data") or []
batch_embeddings = [row.get("embedding") for row in rows if isinstance(row, dict) and row.get("embedding")]
if len(batch_embeddings) != len(batch_texts):
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "向量化结果数量异常")
embeddings.extend(batch_embeddings)
if len(embeddings) != len(texts):
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "向量化结果数量异常")
return embeddings
return await self.retriever._embed_texts(texts, model_name)
async def _start_message_task(
self,
@@ -1219,220 +1111,42 @@ class RagChatServiceImpl(IRagChatService):
top_k: int,
score_threshold: float | None,
) -> list[dict]:
collection = get_chroma().get_or_create_collection(collection_name)
raw = collection.get(include=["documents", "metadatas"])
ids = raw.get("ids") or []
docs = raw.get("documents") or []
metas = raw.get("metadatas") or []
terms = self._build_keyword_terms(query)
if not terms:
return []
scored_chunks: list[dict] = []
for idx, chunk_id in enumerate(ids):
doc = docs[idx] if idx < len(docs) else ""
meta = metas[idx] if idx < len(metas) and isinstance(metas[idx], dict) else {}
score = self._score_keyword_chunk(
query=query,
terms=terms,
content=doc or "",
document_name=str(meta.get("document_name") or meta.get("source") or ""),
)
if score <= 0:
continue
if score_threshold is not None and score < score_threshold:
continue
scored_chunks.append(
{
"id": str(chunk_id),
"text": doc or "",
"source": meta.get("source") or meta.get("document_name") or dataset_name,
"score": score,
"chunk_index": int(meta.get("chunk_index") or idx),
"document_name": meta.get("document_name") or meta.get("source") or "",
"document_id": meta.get("document_id"),
"page": meta.get("page"),
}
)
scored_chunks.sort(key=lambda item: (-float(item.get("score") or 0.0), int(item.get("chunk_index") or 0)))
hydrated = await self._hydrate_document_hits(dataset_id, scored_chunks[: max(top_k * 3, top_k)])
return hydrated[:top_k]
chunks = await self.retriever._keyword_retrieve_context(
dataset_id=dataset_id,
collection_name=collection_name,
dataset_name=dataset_name,
query=query,
top_k=top_k,
score_threshold=score_threshold,
source_names=None,
)
return chunks[:top_k]
def _build_keyword_terms(self, query: str) -> list[str]:
normalized = self._normalize_keyword_query(query)
spans = [item.strip() for item in re.findall(r"[\u4e00-\u9fffA-Za-z0-9]+", normalized) if item.strip()]
if not spans:
return []
stop_terms = {
"什么",
"请问",
"一下",
"有关",
"关于",
"如何",
"哪些",
"怎么",
"是否",
"规定",
"办法",
"条例",
"法律",
}
terms: list[str] = []
for span in spans:
if span in stop_terms:
continue
terms.append(span)
if re.fullmatch(r"[\u4e00-\u9fff]+", span):
for size in (2, 3, 4):
if len(span) > size:
for start in range(0, len(span) - size + 1):
token = span[start:start + size]
if token not in stop_terms:
terms.append(token)
unique_terms: list[str] = []
seen: set[str] = set()
for term in sorted(terms, key=len, reverse=True):
if term and term not in seen:
unique_terms.append(term)
seen.add(term)
return unique_terms[:20]
return self.retriever._build_keyword_terms(query)
def _normalize_keyword_query(self, query: str) -> str:
normalized = (query or "").strip().lower()
patterns = [
"是什么",
"什么是",
"有哪些",
"有什么",
"是什么?",
"是什么?",
"请问",
"介绍一下",
"解释一下",
"帮我分析",
"帮我看看",
]
for pattern in patterns:
normalized = normalized.replace(pattern, " ")
return re.sub(r"\s+", " ", normalized).strip()
return self.retriever._normalize_keyword_query(query)
def _score_keyword_chunk(self, *, query: str, terms: list[str], content: str, document_name: str) -> float:
haystack = f"{document_name}\n{content}".lower()
if not haystack:
return 0.0
exact_query = self._normalize_keyword_query(query)
if exact_query and exact_query in haystack:
return 0.98
matched_weight = 0.0
total_weight = 0.0
name_bonus = 0.0
for term in terms:
weight = float(max(len(term), 1) ** 2)
total_weight += weight
if term.lower() in haystack:
matched_weight += weight
if term.lower() in document_name.lower():
name_bonus += min(0.15, 0.03 * len(term))
if total_weight <= 0:
return 0.0
score = (matched_weight / total_weight) + name_bonus
return round(min(score, 0.99), 6)
return self.retriever._score_keyword_chunk(
query=query,
terms=terms,
content=content,
document_name=document_name,
)
def _format_sse(self, payload: dict) -> bytes:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
def _build_sources(self, context_chunks: list[dict], dataset_name: str) -> list[dict]:
return [
{
"position": index + 1,
"dataset_id": str(chunk.get("dataset_id") or ""),
"dataset_name": dataset_name,
"document_id": str(chunk.get("document_id") or ""),
"document_name": chunk.get("document_name") or chunk.get("source", ""),
"data_source_type": "upload_file",
"segment_id": chunk.get("id", ""),
"retriever_from": "rag",
"score": round(chunk.get("score", 0.0), 4),
"hit_count": chunk.get("hit_count", 0),
"word_count": len(chunk.get("text", "")),
"segment_position": index + 1,
"index_node_hash": "",
"content": chunk.get("text", "")[:500],
"page": None,
}
for index, chunk in enumerate(context_chunks)
]
build_sources = getattr(self.retriever, "build_sources", None)
if callable(build_sources):
return build_sources(context_chunks, dataset_name)
return RagRetriever(hydrate_documents=False).build_sources(context_chunks, dataset_name)
async def _hydrate_document_hits(self, dataset_id: int, chunks: list[dict]) -> list[dict]:
source_names = sorted(
{
str(chunk.get("document_name") or chunk.get("source") or "").strip()
for chunk in chunks
if str(chunk.get("document_name") or chunk.get("source") or "").strip()
}
)
if not source_names:
return chunks
async with GetAsyncSession() as session:
rows = (
await session.execute(
text(
"""
SELECT id, original_name, enabled, hit_count
FROM rag_document
WHERE dataset_id = :dataset_id
AND deleted_at IS NULL
AND original_name = ANY(:source_names)
"""
),
{
"dataset_id": dataset_id,
"source_names": source_names,
},
)
).mappings().all()
document_map = {str(row["original_name"]): row for row in rows}
visible_chunks: list[dict] = []
hit_document_ids: list[int] = []
for chunk in chunks:
source_name = str(chunk.get("document_name") or chunk.get("source") or "").strip()
document = document_map.get(source_name)
if document and not bool(document.get("enabled")):
continue
if document:
chunk["document_id"] = document["id"]
chunk["dataset_id"] = dataset_id
chunk["document_name"] = document["original_name"]
chunk["hit_count"] = document.get("hit_count") or 0
hit_document_ids.append(int(document["id"]))
visible_chunks.append(chunk)
if hit_document_ids:
async with GetAsyncSession() as session:
async with session.begin():
await session.execute(
text(
"""
UPDATE rag_document
SET hit_count = hit_count + 1,
updated_at = NOW()
WHERE id = ANY(:document_ids)
"""
),
{"document_ids": sorted(set(hit_document_ids))},
)
return visible_chunks
return await self.retriever._hydrate_document_hits(dataset_id, chunks)
def _parse_sse_event(self, chunk: str) -> dict | None:
data_lines: list[str] = []
@@ -119,6 +119,41 @@ class RbacAdminServiceImpl(IRbacAdminService):
"is_cache": True,
"meta": {"group": "cross-review"},
},
{
"route_path": "/contract-template",
"route_name": "contract-template",
"component": "contract-template",
"route_title": "合同管理",
"icon": "ri-file-search-line",
"sort_order": 50,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
},
{
"route_path": "/contract-template/search",
"route_name": "contract-template-search",
"component": "contract-template.search",
"route_title": "模板搜索",
"icon": "ri-search-line",
"sort_order": 1,
"parent_path": "/contract-template",
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
},
{
"route_path": "/contract-template/list",
"route_name": "contract-template-list",
"component": "contract-template.list",
"route_title": "模板列表",
"icon": "ri-folder-line",
"sort_order": 2,
"parent_path": "/contract-template",
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
},
{
"route_path": "/cross-checking/upload",
"route_name": "cross-checking-upload",
@@ -225,6 +260,9 @@ class RbacAdminServiceImpl(IRbacAdminService):
{"permission_key": "contract_template:list:read", "display_name": "查看合同模板列表", "module": "contract_template", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates", "route_path": "/contract-template/list"},
{"permission_key": "contract_template:search:read", "display_name": "搜索合同模板", "module": "contract_template", "resource": "search", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates/search", "route_path": "/contract-template/search"},
{"permission_key": "contract_template:detail:read", "display_name": "查看合同模板详情", "module": "contract_template", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"},
{"permission_key": "contract_template:create:write", "display_name": "上传合同模板", "module": "contract_template", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/contract-templates", "route_path": "/contract-template/list"},
{"permission_key": "contract_template:update:write", "display_name": "更新合同模板", "module": "contract_template", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"},
{"permission_key": "contract_template:delete:delete", "display_name": "删除合同模板", "module": "contract_template", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/contract-templates/{id}", "route_path": "/contract-template/list"},
{"permission_key": "evaluation_group:list:read", "display_name": "评查点分组列表", "module": "evaluation_group", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"},
{"permission_key": "evaluation_group:create:write", "display_name": "创建评查点分组", "module": "evaluation_group", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-point-groups", "route_path": "/rule-groups"},
{"permission_key": "evaluation_group:update:write", "display_name": "更新评查点分组与绑定", "module": "evaluation_group", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-point-groups/{id}", "route_path": "/rule-groups"},
+61
View File
@@ -1392,6 +1392,67 @@ rules:
references_laws:
- 《中华人民共和国行政处罚法》第五十九条
type: deterministic
- rule_id: JZ-JD-005
name: 案由及裁量标准适用准确性
desc: 结合处罚决定书认定依据、处罚依据、罚款项目和罚款金额,检索案由与裁量标准,判断处罚种类和罚款幅度是否适用准确。
risk: medium
score: 10
scope:
- 处罚决定书
rag:
collection: general_legal_kb
top_k: 5
source_names:
- 广东省烟草专卖行政处罚裁量执行标准-rag.md
- 案由_行政处罚与反走私管理治理办法.md
query_template: |
认定依据:{{处罚决定书.认定依据}}
处罚依据:{{处罚决定书.处罚依据}}
罚款项目:{{处罚决定书.罚款项目}}
罚款基数:{{处罚决定书.罚款基数}}
罚款比例:{{处罚决定书.罚款比例}}
罚款总额:{{处罚决定书.罚款总额}}
问题:检索对应案由、裁量档次、处罚种类和罚款幅度
inject_as: rag_context
resources_as: rag_resources
stages:
- id: '1'
check: required
fields:
- 处罚决定书.认定依据
- 处罚决定书.处罚依据
- 处罚决定书.罚款项目
- 处罚决定书.罚款基数
- 处罚决定书.罚款比例
- 处罚决定书.罚款总额
- id: '2'
check: ai
prompt: |
请结合检索到的法律知识和卷宗处罚决定书字段,判断案由、裁量档次、处罚种类和罚款幅度是否适用准确。
【检索依据】
{{rag_context}}
【处罚决定书字段】
认定依据:{{处罚决定书.认定依据}}
处罚依据:{{处罚决定书.处罚依据}}
罚款项目:{{处罚决定书.罚款项目}}
罚款基数:{{处罚决定书.罚款基数}}
罚款比例:{{处罚决定书.罚款比例}}
罚款总额:{{处罚决定书.罚款总额}}
【判断要求】
1. 判断违法事实对应案由是否准确;
2. 判断处罚依据是否能支撑对应处罚种类;
3. 判断罚款基数、比例、总额是否落在裁量标准允许幅度内;
4. 若检索依据不足以确认,应给出 warn,不要编造依据。
logic: 1 AND 2
messages:
pass: 案由、裁量档次、处罚种类和罚款幅度适用准确。
fail: 案由、裁量档次、处罚种类或罚款幅度可能适用不准确,请核对。
references_laws:
- 《中华人民共和国行政处罚法》第五十九条
type: ai_rule
- group: JZG-SD
rules:
- rule_id: JZ-SD-001
@@ -164,12 +164,14 @@ def resolve_pdf_path(template: LegacyTemplate, object_keys: set[str]) -> str:
def build_new_object_keys(template: LegacyTemplate, docx_path: str, pdf_path: str) -> tuple[str, str]:
docx_key = OssPathUtils.BuildContractTemplateKey(
Region="省级",
CategoryName=template.category_name,
TemplateCode=template.template_code,
FileRole="source",
FileName=Path(docx_path).name,
)
pdf_key = OssPathUtils.BuildContractTemplateKey(
Region="省级",
CategoryName=template.category_name,
TemplateCode=template.template_code,
FileRole="preview",
+133 -15
View File
@@ -3,9 +3,9 @@ BEGIN;
-- ============================================================================
-- LeAudit Platform Contract Template Schema
-- 目标:
-- 1. 在主库 leaudit_platform 创建合同模板分类表
-- 2. 在主库 leaudit_platform 创建合同模板表
-- 3. 补齐索引与基础约束
-- 1. 在主库 leaudit_platform 创建 / 升级合同模板分类表
-- 2. 在主库 leaudit_platform 创建 / 升级合同模板
-- 3. 补齐地区字段、审计字段、软删除字段、索引与 updated_at 触发器
-- 说明:
-- - 本脚本不依赖旧库 docauditai
-- - 幂等脚本,可重复执行
@@ -17,50 +17,168 @@ CREATE TABLE IF NOT EXISTS public.contract_categories (
icon VARCHAR(100) NULL,
description TEXT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_by BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_by BIGINT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_categories_name
ON public.contract_categories(name);
ALTER TABLE public.contract_categories
ADD COLUMN IF NOT EXISTS icon VARCHAR(100),
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS public.contract_templates (
id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
category_id INTEGER NOT NULL REFERENCES public.contract_categories(id) ON DELETE RESTRICT,
region VARCHAR(50) NOT NULL DEFAULT '省级',
description TEXT NULL,
file_path VARCHAR(500) NULL,
pdf_file_path VARCHAR(500) NULL,
file_format VARCHAR(10) NOT NULL,
original_file_name VARCHAR(500) NOT NULL DEFAULT '',
mime_type VARCHAR(200) NULL,
file_size BIGINT NOT NULL DEFAULT 0,
pdf_file_size BIGINT NULL,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
created_by BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by BIGINT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
pdf_file_path VARCHAR(500) NULL
deleted_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contract_templates_code
ON public.contract_templates(template_code);
ALTER TABLE public.contract_templates
ADD COLUMN IF NOT EXISTS region VARCHAR(50) NOT NULL DEFAULT '省级',
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS file_path VARCHAR(500),
ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500),
ADD COLUMN IF NOT EXISTS file_format VARCHAR(10),
ADD COLUMN IF NOT EXISTS original_file_name VARCHAR(500) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS mime_type VARCHAR(200),
ADD COLUMN IF NOT EXISTS file_size BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS pdf_file_size BIGINT,
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS created_by BIGINT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_by BIGINT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_contract_templates_category_id
ON public.contract_templates(category_id);
UPDATE public.contract_templates
SET region = '省级'
WHERE region IS NULL OR BTRIM(region) = '';
CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_at
ON public.contract_templates(updated_at DESC);
UPDATE public.contract_templates
SET original_file_name = COALESCE(NULLIF(original_file_name, ''), title || CASE
WHEN file_format IS NOT NULL AND BTRIM(file_format) <> '' THEN '.' || LOWER(file_format)
ELSE ''
END)
WHERE original_file_name IS NULL OR BTRIM(original_file_name) = '';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'idx_contract_categories_name'
) THEN
EXECUTE 'DROP INDEX IF EXISTS public.idx_contract_categories_name';
END IF;
IF EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'idx_contract_templates_code'
) THEN
EXECUTE 'DROP INDEX IF EXISTS public.idx_contract_templates_code';
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_categories_name_active
ON public.contract_categories(name)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_categories_sort_active
ON public.contract_categories(sort_order)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_region_code_active
ON public.contract_templates(region, template_code)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_templates_region_active
ON public.contract_templates(region)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_templates_category_active
ON public.contract_templates(category_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_contract_templates_updated_active
ON public.contract_templates(updated_at DESC)
WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_contract_templates_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
DECLARE
t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY['contract_categories', 'contract_templates']
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I', t, t);
EXECUTE format(
'CREATE TRIGGER trg_%s_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION update_contract_templates_updated_at()',
t, t
);
END LOOP;
END;
$$;
COMMENT ON TABLE public.contract_categories IS '合同模板分类表';
COMMENT ON COLUMN public.contract_categories.name IS '分类名称';
COMMENT ON COLUMN public.contract_categories.icon IS '分类图标';
COMMENT ON COLUMN public.contract_categories.description IS '分类描述';
COMMENT ON COLUMN public.contract_categories.sort_order IS '排序值';
COMMENT ON COLUMN public.contract_categories.created_by IS '创建人';
COMMENT ON COLUMN public.contract_categories.updated_by IS '更新人';
COMMENT ON COLUMN public.contract_categories.deleted_at IS '软删除时间';
COMMENT ON TABLE public.contract_templates IS '合同模板主表';
COMMENT ON COLUMN public.contract_templates.template_code IS '模板编码';
COMMENT ON COLUMN public.contract_templates.title IS '模板标题';
COMMENT ON COLUMN public.contract_templates.category_id IS '所属分类ID';
COMMENT ON COLUMN public.contract_templates.region IS '所属地区,省级模板使用“省级”';
COMMENT ON COLUMN public.contract_templates.description IS '模板描述';
COMMENT ON COLUMN public.contract_templates.file_path IS '源模板文件路径';
COMMENT ON COLUMN public.contract_templates.file_format IS '文件格式';
COMMENT ON COLUMN public.contract_templates.is_featured IS '是否推荐模板';
COMMENT ON COLUMN public.contract_templates.pdf_file_path IS 'PDF预览文件路径';
COMMENT ON COLUMN public.contract_templates.file_format IS '文件格式';
COMMENT ON COLUMN public.contract_templates.original_file_name IS '原始上传文件名';
COMMENT ON COLUMN public.contract_templates.mime_type IS '文件 MIME 类型';
COMMENT ON COLUMN public.contract_templates.file_size IS '主文件大小(字节)';
COMMENT ON COLUMN public.contract_templates.pdf_file_size IS '预览 PDF 文件大小(字节)';
COMMENT ON COLUMN public.contract_templates.is_featured IS '是否推荐模板';
COMMENT ON COLUMN public.contract_templates.created_by IS '创建人';
COMMENT ON COLUMN public.contract_templates.updated_by IS '更新人';
COMMENT ON COLUMN public.contract_templates.deleted_at IS '软删除时间';
COMMIT;
@@ -3,8 +3,8 @@ BEGIN;
-- ============================================================================
-- LeAudit Platform Contract Template RBAC Seed
-- 目标:
-- 1. 补齐合同模板读权限
-- 2. 给 super_admin / provincial_admin / admin 分配模板读权限
-- 1. 补齐合同模板读写删权限
-- 2. 给角色分配模板权限,其中上传/更新/删除仅开放给地区管理员 admin
-- 说明:
-- - 依赖 user_rbac_schema_patch.sql
-- - 依赖合同模板前端路由已存在于 sys_routes
@@ -62,7 +62,10 @@ FROM (
VALUES
('contract_template:list:read', 'contract_template', 'list', 'read', '查看合同模板列表', '查看合同模板列表', '/contract-template/list', 310, '/api/v3/contract-templates', 'GET'),
('contract_template:search:read', 'contract_template', 'search', 'read', '搜索合同模板', '搜索合同模板', '/contract-template/search', 311, '/api/v3/contract-templates/search','GET'),
('contract_template:detail:read', 'contract_template', 'detail', 'read', '查看合同模板详情', '查看合同模板详情', '/contract-template/list', 312, '/api/v3/contract-templates/{id}', 'GET')
('contract_template:detail:read', 'contract_template', 'detail', 'read', '查看合同模板详情', '查看合同模板详情', '/contract-template/list', 312, '/api/v3/contract-templates/{id}', 'GET'),
('contract_template:create:write', 'contract_template', 'create', 'write', '上传合同模板', '上传合同模板', '/contract-template/list', 313, '/api/v3/contract-templates', 'POST'),
('contract_template:update:write', 'contract_template', 'update', 'write', '更新合同模板', '更新合同模板', '/contract-template/list', 314, '/api/v3/contract-templates/{id}', 'PUT'),
('contract_template:delete:delete', 'contract_template', 'delete', 'delete', '删除合同模板', '删除合同模板', '/contract-template/list', 315, '/api/v3/contract-templates/{id}', 'DELETE')
) AS seed(
permission_key,
module,
@@ -112,7 +115,10 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
('admin', 'contract_template:list:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:search:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:detail:read', 'GRANT', 'DEPT')
('admin', 'contract_template:detail:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:create:write', 'GRANT', 'DEPT'),
('admin', 'contract_template:update:write', 'GRANT', 'DEPT'),
('admin', 'contract_template:delete:delete', 'GRANT', 'DEPT')
)
INSERT INTO role_permissions (
role_id,
@@ -137,4 +143,15 @@ ON CONFLICT (role_id, permission_id) DO UPDATE SET
data_scope = EXCLUDED.data_scope,
updated_at = NOW();
DELETE FROM role_permissions rp
USING roles r, permissions p
WHERE rp.role_id = r.id
AND rp.permission_id = p.id
AND r.role_key IN ('super_admin', 'provincial_admin')
AND p.permission_key IN (
'contract_template:create:write',
'contract_template:update:write',
'contract_template:delete:delete'
);
COMMIT;
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import tempfile
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
from leaudit.dsl.schema import Metadata, Rule, RuleAuthoringGroup, RulesFile, Stage
from leaudit.engine.models import EvaluationResult
from leaudit.extraction.bundle import bundle_from_single
from leaudit.extraction.models import ExtractionResult, FieldValue
from leaudit.ocr.models import OcrResult, Page
from fastapi_modules.fastapi_leaudit.leaudit_bridge.pipeline import LauditPipeline
class FakeOcrClient:
async def ocr(self, file_path):
return OcrResult(
pages=[Page(page_num=1, text="处罚决定书")],
full_text="处罚决定书",
)
class FakeStorage:
async def update_document_status(self, document_id, status):
return None
async def save_ocr_result(self, document_id, ocr_result):
return None
async def save_extraction_result(self, document_id, extraction_bundle):
return None
async def save_evaluation_results(self, document_id, rules_file, evaluation_result, extraction_bundle):
return None
class FakeRetriever:
pass
def _rules_file() -> RulesFile:
return RulesFile(
metadata=Metadata(
type_id="test.rag_bridge",
name="RAG bridge test",
version="1.0",
last_updated="2026-05-21",
),
rules=[
RuleAuthoringGroup(
group="测试",
rules=[
Rule(
rule_id="R-RAG",
name="RAG 规则",
risk="medium",
score=1,
stages=[Stage(id="1", check="required", field="处罚决定书.处罚依据")],
logic="1",
messages={"pass": "ok", "fail": "missing"},
)
],
)
],
)
class LeauditRagBridgeTest(unittest.IsolatedAsyncioTestCase):
async def test_pipeline_passes_injected_rag_retriever_to_evaluation(self):
captured = {}
retriever = FakeRetriever()
field_value = FieldValue(value="依据", confidence=0.9)
object.__setattr__(field_value, "position", None)
extraction = bundle_from_single(
ExtractionResult(
fields={"处罚决定书.处罚依据": field_value},
source_text="处罚决定书",
)
)
async def fake_dispatch_extract(*args, **kwargs):
return extraction
async def fake_determine_phase(*args, **kwargs):
return "executed"
async def fake_evaluate_extraction(*args, **kwargs):
captured["retriever"] = kwargs.get("retriever")
return EvaluationResult()
class TestPipeline(LauditPipeline):
async def _extract_and_save_case_number(self, document_id, ocr_result):
return None
with tempfile.NamedTemporaryFile() as tmp:
pipeline = TestPipeline(
ocr_client=FakeOcrClient(),
storage_adapter=FakeStorage(),
rag_retriever=retriever,
)
with patch(
"fastapi_modules.fastapi_leaudit.leaudit_bridge.pipeline.dispatch_extract",
fake_dispatch_extract,
), patch(
"fastapi_modules.fastapi_leaudit.leaudit_bridge.pipeline.determine_phase",
fake_determine_phase,
), patch(
"fastapi_modules.fastapi_leaudit.leaudit_bridge.pipeline.evaluate_extraction",
fake_evaluate_extraction,
), patch.dict(
sys.modules,
{
"leaudit.extraction.coordinate_resolver": types.SimpleNamespace(
resolve_bundle_positions=lambda *args, **kwargs: None
)
},
):
await pipeline.run(1, Path(tmp.name), _rules_file())
self.assertIs(captured["retriever"], retriever)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import unittest
from fastapi_modules.fastapi_leaudit.rag_engine.retriever import RagRetriever
class FakeCollection:
def query(self, **kwargs):
return {
"ids": [["seg-1", "seg-2"]],
"documents": [["烟草处罚裁量标准内容", "其他来源内容"]],
"metadatas": [[
{
"document_name": "广东省烟草专卖行政处罚裁量执行标准-rag.md",
"chunk_index": 0,
},
{
"document_name": "其他.md",
"chunk_index": 1,
},
]],
"distances": [[0.0, 0.2]],
}
class FallbackCollection:
def query(self, **kwargs):
raise RuntimeError("vector unavailable")
def get(self, **kwargs):
return {
"ids": ["seg-fallback"],
"documents": ["未在当地烟草专卖批发企业进货,对应裁量档次内容"],
"metadatas": [
{
"document_name": "案由_行政处罚与反走私管理治理办法.md",
"chunk_index": 3,
}
],
}
class FakeChroma:
def __init__(self):
self.collection_name = None
def get_or_create_collection(self, name):
self.collection_name = name
return FakeCollection()
class FallbackChroma:
def get_or_create_collection(self, name):
return FallbackCollection()
class RagRetrieverTest(unittest.IsolatedAsyncioTestCase):
async def test_retrieve_from_collection_filters_sources_and_builds_resources(self):
chroma = FakeChroma()
retriever = RagRetriever(
chroma_client=chroma,
embed_texts=lambda texts, model_name="": [[0.1, 0.2] for _ in texts],
hydrate_documents=False,
)
result = await retriever.retrieve(
query="罚款幅度",
collection_name="general_legal_kb",
top_k=5,
source_names=["广东省烟草专卖行政处罚裁量执行标准-rag.md"],
)
self.assertEqual(chroma.collection_name, "general_legal_kb")
self.assertIn("烟草处罚裁量标准内容", result.rag_context)
self.assertNotIn("其他来源内容", result.rag_context)
self.assertEqual(len(result.rag_resources), 1)
self.assertEqual(
result.rag_resources[0]["document_name"],
"广东省烟草专卖行政处罚裁量执行标准-rag.md",
)
async def test_retrieve_uses_keyword_fallback_when_vector_search_fails(self):
retriever = RagRetriever(
chroma_client=FallbackChroma(),
embed_texts=lambda texts, model_name="": [[0.1, 0.2] for _ in texts],
hydrate_documents=False,
)
result = await retriever.retrieve(
query="未在当地烟草专卖批发企业进货",
collection_name="general_legal_kb",
source_names=["案由_行政处罚与反走私管理治理办法.md"],
)
self.assertIn("对应裁量档次内容", result.rag_context)
self.assertEqual(len(result.chunks), 1)
self.assertEqual(result.rag_resources[0]["segment_id"], "seg-fallback")
if __name__ == "__main__":
unittest.main()