From 61a61f916bc43b467339925861c129ae73af1cac Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Tue, 18 Aug 2026 21:52:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B0=94=E7=AB=99=E5=90=88?= =?UTF-8?q?=E5=90=8C=E9=99=84=E4=BB=B6=E5=8F=97=E6=8E=A7=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/internal/logic/gas/gasorder.go | 27 ++++++ backend/api/internal/routers/gas_business.go | 3 + backend/api/internal/routers/gas_test.go | 20 +++++ docs/06-气站管理系统需求.md | 2 + docs/操作日志_气站合同附件上传_20260818.md | 51 +++++++++++ docs/项目文档_气站合同附件上传_v1.0.md | 71 ++++++++++++++++ .../gas_admin/src/api/contract-attachment.ts | 10 +-- frontend/gas_admin/src/api/resources.ts | 2 +- .../src/views/resource/ResourceRecordPage.vue | 85 ++++++++++++++++++- 9 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 docs/操作日志_气站合同附件上传_20260818.md create mode 100644 docs/项目文档_气站合同附件上传_v1.0.md diff --git a/backend/api/internal/logic/gas/gasorder.go b/backend/api/internal/logic/gas/gasorder.go index 09b5bc3..5b635cb 100644 --- a/backend/api/internal/logic/gas/gasorder.go +++ b/backend/api/internal/logic/gas/gasorder.go @@ -73,6 +73,33 @@ func GetGasorderContract(ctx *gin.Context) { } } +// UploadGasorderContractAttachment 为当前气站账号提供受控 PDF 临时上传。 +func UploadGasorderContractAttachment(ctx *gin.Context) { + if _, ok := currentGas(ctx); !ok { + return + } + platformgasorder.UploadGasorderContractAttachment(ctx) +} + +// CleanupGasorderContractAttachment 清理当前气站账号尚未绑定的临时附件。 +func CleanupGasorderContractAttachment(ctx *gin.Context) { + if _, ok := currentGas(ctx); !ok { + return + } + platformgasorder.CleanupGasorderContractAttachment(ctx) +} + +// ServeGasorderContractAttachment 仅预览当前气站合同范围内的正式附件。 +func ServeGasorderContractAttachment(ctx *gin.Context) { + station, ok := currentGas(ctx) + if !ok { + return + } + if _, ok := scopedContract(ctx, ctx.Param("identity"), station.ID); ok { + platformgasorder.ServeGasorderContractAttachment(ctx) + } +} + func CreateGasorderContract(ctx *gin.Context) { station, ok := currentGas(ctx) if !ok { diff --git a/backend/api/internal/routers/gas_business.go b/backend/api/internal/routers/gas_business.go index a947d08..4e92324 100644 --- a/backend/api/internal/routers/gas_business.go +++ b/backend/api/internal/routers/gas_business.go @@ -65,8 +65,11 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { contract := group.Group("/gasorder_contract") contract.GET("", gaslogic.ListGasorderContract) contract.POST("", gaslogic.CreateGasorderContract) + contract.POST("/attachment/upload", gaslogic.UploadGasorderContractAttachment) + contract.POST("/attachment/cleanup", gaslogic.CleanupGasorderContractAttachment) contract.GET("/:identity", gaslogic.GetGasorderContract) contract.PUT("/:identity", gaslogic.UpdateGasorderContract) + contract.GET("/:identity/attachment", gaslogic.ServeGasorderContractAttachment) contract.POST("/:identity/activate", gaslogic.ActivateGasorderContract) contract.POST("/:identity/renew", gaslogic.RenewGasorderContract) contract.POST("/:identity/terminate", gaslogic.TerminateGasorderContract) diff --git a/backend/api/internal/routers/gas_test.go b/backend/api/internal/routers/gas_test.go index c22f839..73e6a9e 100644 --- a/backend/api/internal/routers/gas_test.go +++ b/backend/api/internal/routers/gas_test.go @@ -100,3 +100,23 @@ func TestGasAvatarRoutes(t *testing.T) { } } } + +// TestGasContractAttachmentRoutes 验证合同附件通过气站鉴权域提供专用接口。 +func TestGasContractAttachmentRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + RegisterGas("heqi", engine) + routes := map[string]bool{} + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = true + } + for _, path := range []string{ + "POST /heqi/gas/v1/gasorder_contract/attachment/upload", + "POST /heqi/gas/v1/gasorder_contract/attachment/cleanup", + "GET /heqi/gas/v1/gasorder_contract/:identity/attachment", + } { + if !routes[path] { + t.Fatalf("missing gas contract attachment route %s", path) + } + } +} diff --git a/docs/06-气站管理系统需求.md b/docs/06-气站管理系统需求.md index 85ed329..5fd04cd 100644 --- a/docs/06-气站管理系统需求.md +++ b/docs/06-气站管理系统需求.md @@ -100,6 +100,7 @@ - 启用仅适用于草稿合同;已终止合同不得直接启用,只能通过续签填写新的生效时间、到期时间和原因后恢复履约。 - 配送合同创建、草稿编辑和续签必须填写到期时间,且到期时间必须晚于生效时间;历史缺失值应提示补录,不得推测回填。 - 合同只能选择当前气站用户及当前气站配送点。 +- 合同附件仅支持单个不超过 10 MiB 的 PDF;创建和草稿编辑时通过受控接口上传、替换或移除,详情页通过气站鉴权和合同归属校验后预览,不向前端暴露内部存储地址。 - 合同气瓶支持绑定和受控解绑。 - 合同修订记录只读,不允许修改或删除。 - 合同修订记录不展示公共软删除时间等无业务意义的基础设施字段。 @@ -199,3 +200,4 @@ 9. 邀请二维码由配置 URL 和当前气站 identity 实时生成,不产生持久化记录。 10. 后端测试、路由权限测试、前端类型检查、契约检查和生产构建通过。 11. 20 类标准资源均可从列表进入独立详情页并刷新;11 类可新建资源和 8 类可编辑资源具备对应独立页面。 +12. 配送合同新建和草稿编辑页显示 PDF 选择控件而非附件地址文本框;附件预览、替换和移除均受当前气站数据范围约束。 diff --git a/docs/操作日志_气站合同附件上传_20260818.md b/docs/操作日志_气站合同附件上传_20260818.md new file mode 100644 index 0000000..ce92108 --- /dev/null +++ b/docs/操作日志_气站合同附件上传_20260818.md @@ -0,0 +1,51 @@ +# 操作日志:气站合同附件上传 + +操作时间:2026-08-18 21:43:42 +操作类型:扩展 +影响模块:气站管理端 5175、气站 API 合同附件路由、气站需求文档 + +## 操作前状态 + +- 5175 配送合同表单把 `file_uri` 渲染为“附件地址”文本输入框。 +- 气站前端已存在从 5173 复制的附件控件和状态逻辑,但标准记录页未接线,附件客户端默认地址仍误指向平台主管 `/heqi/platform/v1`。 +- 后端只有平台主管附件专用路由;气站合同创建和更新虽复用平台业务逻辑,但没有气站鉴权域的上传、清理与预览入口。 + +## 具体操作 + +1. 将气站合同字段声明为 `contract-file`,在标准记录页接入选择、拖拽、预览、替换、移除、失败清理和未保存提示。 +2. 将气站附件客户端默认 API 前缀修正为 `/heqi/gas/v1`。 +3. 在气站 API 新增上传、临时清理和预览路由;上传与清理验证当前气站账号,预览额外校验合同归属。 +4. 新增气站附件路由回归测试,并同步气站需求和项目维护文档。 + +## 操作后状态 + +- 5175 合同新建和草稿编辑页显示单 PDF 上传控件,不再允许手填内部附件地址。 +- 合同详情页可通过鉴权 Blob 预览附件;旧地址不可用时提示重新上传。 +- 气站 JWT 只访问气站域附件接口,跨气站合同在下载前被归属校验拦截。 +- 5173 平台总后台、合同数据库结构和公共合同接口保持兼容。 + +## 代码变更 + +- `backend/api/internal/logic/gas/gasorder.go`:新增 `UploadGasorderContractAttachment`、`CleanupGasorderContractAttachment`、`ServeGasorderContractAttachment`。 +- `backend/api/internal/routers/gas_business.go`:新增 3 条合同附件路由。 +- `backend/api/internal/routers/gas_test.go`:新增 `TestGasContractAttachmentRoutes`。 +- `frontend/gas_admin/src/api/contract-attachment.ts`:改用气站 API 前缀。 +- `frontend/gas_admin/src/api/resources.ts`:合同附件改为专用文件字段。 +- `frontend/gas_admin/src/views/resource/ResourceRecordPage.vue`:接入完整附件页面流程。 + +## 验证结果 + +- `go test ./...`:通过。 +- `go vet ./...`:通过。 +- `go build ./cmd/main/main.go`:通过。 +- `pnpm type:check`:通过。 +- `pnpm contract:check`:通过,20 个资源。 +- `pnpm build`:通过。 +- `pnpm lint`:命令成功,报告 190 条仓库既有警告;本次改动未产生 lint 错误。 +- 浏览器只读检查:5175 服务和登录页正常、控制台无错误;因自动化会话没有气站登录态,未提交账号或读取受保护合同。 + +## 风险评估 + +- 风险:上传和绑定仍复用平台主管包中的既有实现,操作人名称在气站账号场景可能为空,但操作人 identity、合同 identity、动作和结果仍会记录。 +- 缓解:气站路由受独立 JWT 中间件保护,入口验证有效气站账号,下载执行合同归属校验;签名收据继续绑定操作人 identity 并限制有效期。 +- 后续人工边界验收:合法 PDF、非 PDF、空文件、超过 10 MiB、重复保存、编辑替换、移除、跨气站合同和旧地址不可用。 diff --git a/docs/项目文档_气站合同附件上传_v1.0.md b/docs/项目文档_气站合同附件上传_v1.0.md new file mode 100644 index 0000000..9a22c09 --- /dev/null +++ b/docs/项目文档_气站合同附件上传_v1.0.md @@ -0,0 +1,71 @@ +# 项目文档:气站合同附件上传 v1.0 + +## 1. 项目概述 + +- 项目:物联网智能瓶阀平台气站管理端。 +- 本次功能:在气站管理端 5175 的配送合同新建、草稿编辑和详情页接入与平台总后台 5173 一致的受控 PDF 附件交互。 +- 技术栈:Vue 3、TypeScript、Arco Design、Gin、GORM。 +- 运行环境:前端 `http://localhost:5175`,后端 `/heqi/gas/v1`。 + +附件继续复用 `gasorder_contract.file_uri` 保存服务端受控地址。浏览器只接收附件元数据和签名收据,不读取或提交内部存储路径。 + +## 2. 目录结构说明 + +```text +platforms/ +├── frontend/gas_admin/src/ +│ ├── api/ +│ │ ├── contract-attachment.ts # 气站域附件上传、清理和预览客户端 +│ │ └── resources.ts # 将合同附件声明为专用文件字段 +│ └── views/resource/ +│ ├── ContractAttachmentField.vue # PDF 选择、拖拽、预览和移除控件 +│ ├── use-contract-attachment.ts # 上传收据、并发版本和失败清理状态 +│ └── ResourceRecordPage.vue # 标准记录页附件流程编排 +├── backend/api/internal/ +│ ├── logic/gas/gasorder.go # 气站范围包装与合同归属校验 +│ └── routers/ +│ ├── gas_business.go # 气站合同附件路由 +│ └── gas_test.go # 路由回归测试 +└── docs/ + └── 06-气站管理系统需求.md # 气站附件需求和验收口径 +``` + +## 3. 核心文件说明 + +### `frontend/gas_admin/src/views/resource/ResourceRecordPage.vue` + +- 新建和编辑页向字段组件传入合同附件状态。 +- 保存前上传本地 PDF,并把签名收据加入合同载荷。 +- 保存失败时清理临时文件;详情页显示附件可用状态和鉴权预览入口。 +- 未保存附件变化纳入离开页面确认。 + +### `backend/api/internal/logic/gas/gasorder.go` + +- 上传和清理入口先验证当前登录账号仍关联有效气站。 +- 下载入口额外校验合同属于当前气站,再复用现有受控 PDF 输出逻辑。 +- 合同创建和更新继续复用既有绑定、并发版本和旧文件清理事务。 + +### `frontend/gas_admin/src/api/contract-attachment.ts` + +- API 默认前缀改为 `/heqi/gas/v1`,不再误用平台主管接口。 +- 上传仅接受 PDF,预览返回 Blob,不暴露 `file_uri`。 + +## 4. 变更记录 + +- 将气站合同 `file_uri` 字段由普通文本输入改为“合同附件”专用控件。 +- 新增气站域上传、临时清理和鉴权预览路由。 +- 补齐新建、草稿编辑、详情预览、替换、移除和失败回滚流程。 +- 新增气站附件路由回归测试并同步需求验收项。 +- 未新增数据库表、字段或依赖,平台总后台 5173 行为保持不变。 + +## 5. 维护指南 + +- 调整大小或文件类型时,前端预检与后端真实内容校验必须同步,后端校验始终是安全边界。 +- 新增其他业务端附件能力时,应先做该业务端的数据范围校验,再复用受控存储能力,不得直接开放平台路由。 +- 修改气站路由后运行后端路由测试;修改表单后运行类型检查、契约检查和生产构建。 +- 人工验收至少覆盖:合法 PDF、新建保存、编辑替换、移除、无效类型、超限文件、跨气站下载和旧地址不可用提示。 + +## 6. 已知限制 + +- 当前仅支持单个 PDF,最大 10 MiB。 +- 自动化浏览器没有气站登录态,本次未对真实受保护合同执行上传或读取;完整交互仍需使用测试气站账号验收。 diff --git a/frontend/gas_admin/src/api/contract-attachment.ts b/frontend/gas_admin/src/api/contract-attachment.ts index e17b3d2..d52fa1b 100644 --- a/frontend/gas_admin/src/api/contract-attachment.ts +++ b/frontend/gas_admin/src/api/contract-attachment.ts @@ -4,9 +4,9 @@ */ import { getToken } from '@/utils/auth'; -const platformApiBaseURL = +const gasApiBaseURL = import.meta.env.VITE_API_BASE_URL || - 'http://localhost:12426/heqi/platform/v1'; + 'http://localhost:12426/heqi/gas/v1'; export type ContractAttachmentMetadata = { has_file: boolean; @@ -36,7 +36,7 @@ async function upload(file: File): Promise { const form = new FormData(); form.append('file', file); const response = await fetch( - `${platformApiBaseURL}/gasorder_contract/attachment/upload`, + `${gasApiBaseURL}/gasorder_contract/attachment/upload`, { method: 'POST', headers: authorizationHeaders(), body: form }, ); const payload = (await response.json()) as ApiEnvelope; @@ -49,7 +49,7 @@ async function upload(file: File): Promise { /** 使用受签名保护的清理凭证删除尚未绑定的临时文件。 */ async function cleanup(cleanupToken: string): Promise { const response = await fetch( - `${platformApiBaseURL}/gasorder_contract/attachment/cleanup`, + `${gasApiBaseURL}/gasorder_contract/attachment/cleanup`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authorizationHeaders() }, @@ -64,7 +64,7 @@ async function cleanup(cleanupToken: string): Promise { /** 获取鉴权 PDF Blob;内部存储 URI 始终不会暴露给浏览器。 */ async function load(identity: string): Promise { const response = await fetch( - `${platformApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`, + `${gasApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`, { headers: authorizationHeaders() }, ); if (!response.ok) throw new Error('合同附件不可用,请重新上传'); diff --git a/frontend/gas_admin/src/api/resources.ts b/frontend/gas_admin/src/api/resources.ts index 4d0c390..b7cfc6b 100644 --- a/frontend/gas_admin/src/api/resources.ts +++ b/frontend/gas_admin/src/api/resources.ts @@ -517,7 +517,7 @@ const gasOverrides: ResourceUiDefinition[] = [ { name: '重置密码', resource: '/user_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] }, ]), define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]), - define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true, listCopyable: true }), relation('user_account_identity', '/user_account', true, { label: '用户账户', listLabel: '用户账户', listRelationNameOnly: true, showIdentityCopy: true, readonlyRelationText: true }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '所属配送点', listLabel: '所属配送点', listRelationNameOnly: true, emptyText: '暂未指定', placeholder: '请选择默认配送点,可留空', showIdentityCopy: true }), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [ + define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true, listCopyable: true }), relation('user_account_identity', '/user_account', true, { label: '用户账户', listLabel: '用户账户', listRelationNameOnly: true, showIdentityCopy: true, readonlyRelationText: true }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '所属配送点', listLabel: '所属配送点', listRelationNameOnly: true, emptyText: '暂未指定', placeholder: '请选择默认配送点,可留空', showIdentityCopy: true }), f('title', { required: true }), f('terms'), f('file_uri', { label: '合同附件', type: 'contract-file' }), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [ { name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [0] } }, { name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } }, { name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } }, diff --git a/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue b/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue index cd34093..6ea789e 100644 --- a/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue @@ -1,6 +1,6 @@