feat: 新增配送合同附件安全上传功能

- 增加合同专用 PDF 上传、鉴权预览和失败清理接口
- 支持草稿附件替换移除、并发保护和启用完整性校验
- 修复循环模板引用导致文件选择器无法打开的问题
- 补充专项测试、中文项目文档和操作日志
This commit is contained in:
czl231
2026-08-12 21:38:49 +08:00
parent deea31d79e
commit 99324acc89
16 changed files with 1379 additions and 16 deletions

View File

@@ -0,0 +1,102 @@
# 操作日志:配送合同附件上传
操作时间2026-08-12 20:42:47
操作类型:扩展
影响模块:平台总后台配送合同、后端合同业务、受控文件存储
## 操作前状态
- 合同表单将 `file_uri` 渲染为普通“附件地址”文本框,允许手工填写任意字符串。
- 合同接口仅校验 URI 长度,没有专用 PDF 上传、预览或清理能力。
- 通用 `/upload/file` 支持多种图片、PDF 和视频,不能直接收紧而不影响其他模块。
- 合同详情会过滤 `file_uri`,页面无法安全查看附件。
- 合同启用流程不检查签署附件。
## 具体操作
1. 新增合同专用 PDF 上传、临时清理和鉴权预览接口。
2. 使用 HMAC 签名收据绑定上传人与临时 URI有效期为 30 分钟。
3. 创建或更新合同时将临时文件移动到正式目录,并在失败时回滚文件。
4. 使用不透明附件版本和旧 URI 条件处理草稿并发更新。
5. 草稿替换或移除成功后删除旧文件,失败最多重试三次并记录日志。
6. 合同启用前验证受控 URI 和磁盘 PDF 文件。
7. 将前端文本框替换为支持点击和拖拽的 PDF 控件,保存时上传。
8. 新增详情预览、旧地址重新上传提示、移除二次确认和离开页面未保存检测。
9. 补充安全边界、签名凭证、路由注册等自动化测试。
## 操作后状态
- 合同草稿可无附件保存;启用前必须具备真实有效的受控 PDF。
- 单个附件最大 10 MiB仅允许 PDF。
- 草稿可替换和移除,非草稿只可鉴权预览。
- 页面不暴露内部 URI不保存原始文件名统一显示“合同附件.pdf”。
- 历史外部 URI 不删除,但不可预览且不能满足启用条件。
- 不修改数据库表和模型,不新增定时扫描任务。
## 代码变更
- `backend/api/internal/logic/platform/gasorder/contract_attachment.go`:新增 349 行,合同附件核心服务。
- `backend/api/internal/logic/platform/gasorder/contract_attachment_test.go`:新增 83 行,安全与边界测试。
- `backend/api/internal/logic/platform/gasorder/gasorder.go`:约新增 88 行、删除 7 行,接入创建、更新、并发和启用校验。
- `backend/api/internal/routers/platform.go`:新增 3 条专用路由。
- `backend/api/internal/routers/platform_contract_attachment_test.go`:新增 25 行,路由测试。
- `frontend/platform_admin/src/api/contract-attachment.ts`:新增 73 行,附件 API 客户端。
- `frontend/platform_admin/src/views/resource/use-contract-attachment.ts`:新增 147 行,附件页面状态与保存编排。
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:约新增 145 行、删除 1 行,上传控件和样式。
- `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`:约新增 81 行、删除 2 行,详情预览和保存集成。
- `frontend/platform_admin/src/api/resources.ts``ResourceDetailContent.vue`:字段类型、动作状态和详情隐藏调整。
## 行为变化
- 变更前:管理员手工输入附件地址;无受控上传和下载。
- 变更后:管理员选择 PDF保存时自动上传并绑定通过合同标识鉴权预览。
- 变更前:无附件也可启用合同。
- 变更后:无有效 PDF 时拒绝启用。
- 兼容性:原 `file_uri` 数据库字段、合同 JSON 字段和通用上传接口保持不变。
## 验证结果
- `go test ./...`:通过。
- `npm.cmd run type:check`:通过。
- `npm.cmd run build`:通过。
- `npm.cmd run contract:check`通过48 个资源。
- `npm.cmd run resource-pages:check`:通过,详情 46 类、新建 25 类、编辑 23 类。
- `git diff --check`:通过。
- 本地页面只读检查:应用可正常打开且无控制台错误;独立测试浏览器因未登录被重定向至登录页,因此未执行需要平台账号和测试合同数据的交互式保存、替换及预览验证。
- `npm.cmd run lint`:未通过;项目已有 2 个错误、165 个警告和 12 个提示,诊断集中在既有文件。对本次 6 个前端改动文件单独执行 Biome lint 无错误,仅有项目现存的模板变量识别类警告。
- 边界测试:伪装 PDF、错误 MIME、缺少结束标记、过期或篡改凭证、跨操作人凭证、路径穿越、外部 URL 和附件版本变化均覆盖。
## 风险评估
- 存储仍为本地文件系统;多实例部署必须共享 `HEQI_UPLOAD_DIR`,否则下载和启用校验可能找不到文件。
- 不运行孤儿文件定时扫描;浏览器关闭或最终删除失败时依赖应用日志由运维人工处理。
- JWT 密钥同时用于附件签名;密钥轮换会使尚未绑定的 30 分钟临时收据失效,已绑定附件不受影响。
- 原始文件名未持久化,这是“不改数据库”约束下的明确取舍。
- 项目已有前后端合同状态展示不一致,本次仅把启用动作可见状态修正为后端支持的草稿和终止状态,未扩展其他状态改动。
## 运行时异常修复补充2026-08-12
### 操作前状态
- `ResourceFieldForm.vue` 在字段 `v-for` 内为文件 input 使用同一个 `attachmentInput` 模板引用。
- Vue 将循环内模板引用解析为元素数组,点击上传区域调用 `.click()` 时抛出 `TypeError: attachmentInput.value?.click is not a function`
### 具体操作
- 新增 `ContractAttachmentField.vue`,将文件 input 和单一模板引用移出字段循环。
- 通用字段表单只负责渲染独立组件并转发选择、预览、移除和取消选择事件。
- 上传区域增加按钮语义、Tab 焦点、Enter/空格键入口和可见焦点样式。
- 操作按钮同时阻止点击与键盘事件冒泡,避免预览或移除时误打开选择器。
- 新增 `scripts/check-contract-attachment-control.mjs``contract-attachment:check` 命令。
### 验证结果
- `npm.cmd run contract-attachment:check`:通过。
- `npm.cmd run type:check`:通过。
- `npm.cmd run build`:通过。
- 独立测试浏览器没有后台登录会话,未执行真实文件选择器弹窗的浏览器交互验证。
### 风险评估
- 未新增测试框架或运行依赖,沿用项目现有静态契约检查方式。
- 修复仅调整前端控件边界,不修改后端、数据库、上传接口或合同保存载荷。

View File

@@ -0,0 +1,134 @@
# 项目文档:配送合同附件上传 v1.0
## 1. 项目概述
本功能将平台总后台配送合同表单中的“附件地址”文本框升级为受控 PDF 上传控件。合同草稿允许不上传附件,但启用合同前必须存在真实、可读取的受控 PDF。功能不修改数据库结构继续复用 `gasorder_contract.file_uri`,并保持原有合同 JSON 接口和通用 `/upload/file` 接口兼容。
主要能力:
- 单文件 PDF 上传,最大 10 MiB
- 点击保存时上传并绑定,失败时立即清理临时文件;
- 草稿合同支持替换和移除,非草稿只能预览;
- 通过合同菜单权限鉴权预览,不暴露内部文件 URI
- 合同启用时验证受控路径和磁盘文件;
- 通过签名收据和附件版本标识防止伪造路径与并发覆盖;
- 上传、绑定、替换、移除、下载和清理写入应用日志。
技术栈Go、Gin、GORM、Vue 3、TypeScript、Arco Design、Vite。
运行要求:后端必须正常加载 JWT 密钥;文件目录由 `HEQI_UPLOAD_DIR` 指定,未指定时使用 `runtime/uploads`
## 2. 目录结构
```text
platforms/
├── backend/api/internal/
│ ├── logic/platform/gasorder/
│ │ ├── contract_attachment.go # 合同附件上传、签名、绑定、预览、清理和审计
│ │ ├── contract_attachment_test.go # PDF、签名、路径和版本边界测试
│ │ └── gasorder.go # 合同创建、草稿更新和启用校验接入
│ └── routers/
│ ├── platform.go # 合同附件专用路由
│ └── platform_contract_attachment_test.go # 路由注册测试
├── frontend/platform_admin/src/
│ ├── api/
│ │ ├── contract-attachment.ts # 上传、清理和预览 API 客户端
│ │ └── resources.ts # 合同附件字段类型和启用动作状态
│ └── views/resource/
│ ├── ContractAttachmentField.vue # 独立 PDF 选择、拖拽、键盘与操作按钮控件
│ ├── ResourceFieldForm.vue # PDF 选择、拖拽、替换和移除控件
│ ├── ResourceRecordPage.vue # 保存编排、预览与失败回滚
│ ├── ResourceDetailContent.vue # 隐藏原始附件元数据对象
│ └── use-contract-attachment.ts # 附件页面状态与上传缓存
└── docs/
├── 项目文档_配送合同附件上传_v1.0.md
└── 操作日志_配送合同附件上传_20260812.md
```
## 3. 核心设计
### 3.1 存储与数据库
- 不新增表和字段,不修改 `GasorderContract` 模型。
- 正式 URI 形如 `/uploads/contracts/YYYY/MM/DD/<随机标识>.pdf`
- 临时 URI 形如 `/uploads/contracts/temp/<随机标识>.pdf`
- 页面只显示固定名称“合同附件.pdf”下载名称为 `{合同编号}_合同附件.pdf`
- 文件输入框封装在独立 `ContractAttachmentField.vue` 中,不位于字段 `v-for` 的模板引用范围内上传区域支持鼠标、Enter、空格和拖拽操作。
### 3.2 保存流程
1. 用户选择或拖拽 PDF前端校验类型与大小不立即上传。
2. 点击保存后调用合同专用上传接口。
3. 服务端验证扩展名、声明 MIME、真实 MIME、PDF 文件头、结束标记和大小,将文件写入临时目录,并返回绑定收据及清理凭证。
4. 合同创建或草稿更新接口验证收据的 HMAC、有效期和操作人将临时文件移动至正式目录再写入 `file_uri`
5. 数据库保存失败时,服务端删除已移动文件;前端同时用清理凭证尝试清理仍在临时目录中的文件。
### 3.3 替换、移除与并发
- 仅草稿合同可通过普通更新接口修改附件。
- 详情接口返回不含 URI 的 `attachment` 元数据,包括存在性、可用性、是否需要重新上传及不透明版本标识。
- 替换或移除必须提交页面加载时的版本标识;后端同时使用旧 `file_uri` 作为更新条件。
- 版本冲突会拒绝覆盖,并删除本次新绑定文件。
- 保存成功后删除旧文件;删除在当前请求内最多重试三次,最终失败写日志并由运维人工处理。
- 不创建定时扫描任务,不查询合同表清理未引用文件。
### 3.4 权限与安全
- 所有专用接口位于 `/heqi/platform/v1/gasorder_contract` 下,复用 JWT 和 `gasorder_contract` 菜单权限。
- 下载接口只接收合同标识,并从合同记录读取 URI不接受任意路径。
- 路径解析验证专用前缀、规范化相对路径和根目录边界。
- 合同启用前同时验证 URI 属于受控目录且磁盘 PDF 存在。
- 预览响应设置 `application/pdf``nosniff``sandbox` 和私有禁缓存响应头。
- 历史外部地址保留在数据库中,但不允许预览,也不能满足启用校验。
## 4. API 说明
### 4.1 上传临时合同附件
`POST /heqi/platform/v1/gasorder_contract/attachment/upload`
- 请求:`multipart/form-data`,文件字段名为 `file`
- 响应:`receipt``cleanup_token``display_name``size`
### 4.2 清理临时合同附件
`POST /heqi/platform/v1/gasorder_contract/attachment/cleanup`
```json
{
"cleanup_token": "签名清理凭证"
}
```
清理接口只能删除当前操作人上传且仍位于临时目录的文件。
### 4.3 预览合同附件
`GET /heqi/platform/v1/gasorder_contract/:identity/attachment`
按合同标识鉴权返回 PDF不返回内部 URI。
### 4.4 合同创建与更新扩展字段
- 创建或替换:`attachment_receipt`
- 替换或移除:`attachment_version`
- 移除:`remove_attachment: true`
-`file_uri` 字段仍被后端兼容,但新版管理后台不再展示或提交裸 URI。
## 5. 维护指南
- 增加新的合同附件格式时,必须同步修改前后端校验、下载响应类型和测试,不能只调整文件选择器。
- 更换对象存储时,应保持受控 URI 语义和合同标识下载接口不变,在后端存储映射层替换实现。
- 排查孤儿文件时,根据 `contract_attachment` 应用日志人工核对;当前设计明确不运行自动扫描任务。
- 调整附件权限时,应扩展平台菜单授权模型,不应新增公开静态目录。
## 6. 变更记录
### v1.02026-08-12
- 新增单 PDF 合同附件上传和受控预览;
- 新增失败清理、草稿替换/移除和并发保护;
- 新增合同启用附件完整性校验;
- 保持数据库结构及通用上传接口不变;
- 已知限制:不保留原始文件名,不保留历史版本,不自动扫描孤儿文件。
- 修复循环内模板引用被解析为元素数组导致文件选择器无法打开的问题,并新增键盘可访问性与专项组件契约检查。