From b7293f78973aa245f43ec7bcfb01c5f45b417b33 Mon Sep 17 00:00:00 2001 From: chenbowen Date: Wed, 28 Jan 2026 15:29:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?1.=20=E6=81=A2=E5=A4=8D=20databus=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/config/GatewayWebClientConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java index 96ba5a07..24811a53 100644 --- a/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java +++ b/zt-module-databus/zt-module-databus-server/src/main/java/com/zt/plat/module/databus/framework/integration/gateway/config/GatewayWebClientConfiguration.java @@ -27,7 +27,7 @@ public class GatewayWebClientConfiguration { @Value("${databus.gateway.web-client.max-in-memory-size:20971520}") int maxInMemorySize, @Value("${databus.gateway.web-client.max-idle-time:45000}") long maxIdleTimeMillis, @Value("${databus.gateway.web-client.evict-in-background-interval:20000}") long evictInBackgroundMillis, - @Value("${databus.gateway.web-client.connection-pool-enabled:false}") boolean connectionPoolEnabled) { + @Value("${databus.gateway.web-client.connection-pool-enabled:true}") boolean connectionPoolEnabled) { this.maxInMemorySize = maxInMemorySize; this.maxIdleTimeMillis = maxIdleTimeMillis; this.evictInBackgroundMillis = evictInBackgroundMillis; From 1d79da591429d36accfb4042a69b5e9726cfc74b Mon Sep 17 00:00:00 2001 From: chenbowen Date: Wed, 28 Jan 2026 16:34:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?iwork=20=E7=94=A8=E5=8D=B0=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=20=E6=9C=AA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/iWork集成说明.md | 146 +++++++++---- docs/数据总线用户使用指南.md | 202 ++++++++++++++++++ sql/dm/system_iwork_seal_log_dm8.sql | 43 ++++ .../iwork/dto/IWorkWorkflowCreateReqDTO.java | 6 +- .../mq/iwork/IWorkBizCallbackMessage.java | 23 ++ .../iwork/IWorkBizCallbackResultMessage.java | 31 +++ .../iwork/IWorkIntegrationController.java | 37 ++++ .../iwork/vo/IWorkCallbackLogPageReqVO.java | 39 ++++ .../iwork/vo/IWorkCallbackLogRespVO.java | 24 +++ .../iwork/vo/IWorkFileCallbackReqVO.java | 16 +- .../iwork/vo/IWorkOaTokenReqVO.java | 2 +- .../iwork/vo/IWorkWorkflowCallbackReqVO.java | 26 +++ .../iwork/vo/IWorkWorkflowCreateReqVO.java | 6 +- .../dal/dataobject/iwork/IWorkSealLogDO.java | 72 +++++++ .../dal/mysql/iwork/IWorkSealLogMapper.java | 27 +++ .../iwork/config/IWorkProperties.java | 21 ++ .../mq/iwork/IWorkBizCallbackListener.java | 66 ++++++ .../mq/iwork/IWorkBizCallbackProducer.java | 18 ++ .../iwork/IWorkCallbackLogService.java | 21 ++ .../callback/IWorkBizCallbackHandler.java | 16 ++ .../iwork/enums/IWorkCallbackStatusEnum.java | 20 ++ .../impl/IWorkCallbackLogServiceImpl.java | 130 +++++++++++ .../impl/IWorkIntegrationServiceImpl.java | 28 +-- .../impl/IWorkCallbackLogServiceImplTest.java | 63 ++++++ .../template/enums/ErrorCodeConstants.java | 4 + .../contract/DemoContractController.java | 33 ++- .../vo/DemoContractIWorkCreateReqVO.java | 59 +++++ .../vo/DemoContractIWorkVoidReqVO.java | 36 ++++ .../rpc/config/RpcConfiguration.java | 7 +- .../contract/DemoContractIWorkService.java | 21 ++ .../DemoContractIWorkServiceImpl.java | 91 ++++++++ 模块功能清单.md | 18 ++ 32 files changed, 1277 insertions(+), 75 deletions(-) create mode 100644 docs/数据总线用户使用指南.md create mode 100644 sql/dm/system_iwork_seal_log_dm8.sql create mode 100644 zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackMessage.java create mode 100644 zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackResultMessage.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogPageReqVO.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogRespVO.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCallbackReqVO.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/dataobject/iwork/IWorkSealLogDO.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/mysql/iwork/IWorkSealLogMapper.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackListener.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackProducer.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkCallbackLogService.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/callback/IWorkBizCallbackHandler.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/enums/IWorkCallbackStatusEnum.java create mode 100644 zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImpl.java create mode 100644 zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImplTest.java create mode 100644 zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkCreateReqVO.java create mode 100644 zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkVoidReqVO.java create mode 100644 zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkService.java create mode 100644 zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkServiceImpl.java create mode 100644 模块功能清单.md diff --git a/docs/iWork集成说明.md b/docs/iWork集成说明.md index 2cb03e59..86b606f8 100644 --- a/docs/iWork集成说明.md +++ b/docs/iWork集成说明.md @@ -1,6 +1,6 @@ # iWork 统一集成使用说明 -本文档介绍如何在 System 模块中使用项目已实现的统一 iWork 流程发起能力(controller + service + properties)。内容包含:配置项、调用方式(内部 Java 调用 & 外部 HTTP 调用)、请求/响应示例、错误处理、缓存与 Token 生命周期、典型问题与排查步骤。 +本文档介绍如何在 System 模块中使用项目已实现的统一 iWork 流程发起能力(controller + service + properties)。内容包含:配置项、调用方式(内部 Java 调用 & 外部 HTTP 调用)、请求/响应示例、错误处理、缓存与 Token 生命周期、业务回调分发与重试、流程日志查询,以及典型问题与排查步骤。 --- @@ -12,7 +12,10 @@ - 提供 Service 层 `IWorkIntegrationService`,供其它模块以 Spring Bean 注入方式直接调用。 - 使用 `IWorkProperties` 绑定 `application.yml` 中 `iwork` 的配置项。 - Token / 会话采用本地 Caffeine 缓存缓存(按 appId + operatorUserId 缓存 session),并在到期前按配置提前刷新。 -- 使用统一配置的 appId、公钥以及默认流程编号,无需再维护多套凭证。 +- 使用统一配置的 appId、公钥以及默认流程编号,无需再维护多套凭证,所有调用强制使用配置的 appId,不再接受请求覆盖。 +- 全链路以 requestId 作为唯一业务标识(发起返回、作废入参、日志查询、回调与重试均基于 requestId),workflowId 仅用于指定 iWork 模板。 +- 支持业务回调标识 bizCallbackKey(字符串,≤255),发起时提交,回调时按标识分发业务回调并带自动重试与手工重试入口。 +- 回调与日志记录会保存截断后的原始回调请求/响应文本(无需脱敏),无业务附件或处理异常时标记失败。 --- @@ -47,6 +50,10 @@ iwork: token: ttl-seconds: 3600 # token 有效期(秒) refresh-ahead-seconds: 60 # 在到期前多少秒认为需要刷新 + callback: + retry: + max-attempts: 3 # 业务回调自动重试次数(默认 3 次,可调整) + delay-seconds: 5 # 业务回调自动重试间隔秒数(默认 5 秒,可调整) client: connect-timeout: 5s response-timeout: 30s @@ -55,10 +62,11 @@ iwork: 说明: - `base-url` 为 iWork 网关的基础地址,不能留空。 -- `app-id` 与 `client-public-key` 共同构成注册/申请 token 所需的凭据信息,由配置统一提供,不再支持多套切换。 -- `workflow-id` 提供全局默认流程编号,单次调用也可通过 `workflowId` 覆盖。 +- `app-id` 与 `client-public-key` 共同构成注册/申请 token 所需的凭据信息,由配置统一提供,不再支持多套切换,系统强制使用配置的 appId,忽略请求中的 appId。 +- `workflow-id` 提供全局默认流程编号,仅用于向 iWork 指定流程模板;流程标识、查询、补偿、回调与重试一律使用 requestId,不再以 workflowId 标识业务。 - 请求头键名固定为 `app-id`、`client-public-key`、`secret`、`token`、`time`、`user-id`,无需在配置中重复声明。 - `org.*` 配置负责 iWork 人力组织 REST 代理:`token-seed` 为与 iWork 约定的标识,系统会自动将其与毫秒时间戳拼接并计算 MD5 生成 `key`,无需额外传递 token。 +- `callback.retry.*` 控制业务回调的自动重试次数与间隔,默认为 3 次、5 秒,可按需调整。 --- @@ -102,7 +110,7 @@ Controller 暴露的 REST 接口: ## 请求 VO 说明(重要字段) - IWorkBaseReqVO(公用字段) - - `appId` (String):为兼容历史接口保留,系统始终使用配置项 `iwork.app-id`。 + - `appId` (String):仅保留字段,系统强制使用配置项 `iwork.app-id`,忽略请求值。 - `operatorUserId` (String):在 iWork 内部代表操作人的用户编号(可为空,框架会使用 `properties.userId`)。 - `forceRefreshToken` (Boolean):是否强制刷新 token(例如遇到 token 错误时强制刷新)。 @@ -117,7 +125,7 @@ Controller 暴露的 REST 接口: - `success` / `message`:调用成功标志与提示信息。 - IWorkWorkflowCreateReqVO(统一用印流程发起) - - `workflowId` (String):流程模板 ID,必填,不再回退到配置。 + - `workflowId` (String):流程模板 ID,必填,通常由配置 `iwork.workflow-id` 提供;仅用于向 iWork 指定模板,不再作为业务标识。 - `jbr`:用印申请人(iWork 人员 ID,必填)。 - `yybm`:用印部门 ID(必填)。 - `fb`:用印单位/分部 ID(必填)。 @@ -128,10 +136,11 @@ Controller 暴露的 REST 接口: - `xyywjUrl`:用印材料附件 URL(必填)。 - `yysx`:用印事项(必填)。 - `ywxtdjbh`:业务系统单据编号(必填,同时用于生成流程标题“用印-{ywxtdjbh}”)。 + - `bizCallbackKey` (String):业务回调标识,≤255 字符,回调时按该标识分发到对应业务回调入口(可选但推荐)。 - 额外字段不再支持,Service 会根据以上字段自动补齐固定流程类型 (`lclx=2979600781334966993`) 与签署动作 (`qsdz=CORPORATE`)。 - IWorkWorkflowVoidReqVO(作废) - - `requestId` (String):流程请求编号(必填)。 + - `requestId` (String):流程请求编号(必填,唯一标识)。 - `reason`、`extraParams`、`formExtras` 等用于传递作废原因或额外字段。 - IWorkFormFieldVO(表单字段) @@ -188,8 +197,9 @@ public class MyService { 说明: -- 若需使用特定凭证,可设置 `req.setAppId("my-iwork-app")`。 -- 若需覆盖默认流程模板,可调用 `req.setWorkflowId(123L)` 指定。 +- 无需设置 appId,系统强制使用配置项 `iwork.app-id`。 +- `workflowId` 通常来自配置 `iwork.workflow-id`,也可在请求中指定模板编号,但流程标识一律以返回的 requestId 为准。 +- 可设置 `bizCallbackKey` 以便回调时分发到对应业务处理;建议在发起后立即记录响应中的 requestId 及 bizCallbackKey。 - 若希望以特定 iWork 操作人发起,可设置 `req.setOperatorUserId("1001")`。 --- @@ -202,7 +212,6 @@ public class MyService { curl -X POST \ -H "Content-Type: application/json" \ -d '{ - "appId":"my-iwork-app", "identifierKey":"loginid", "identifierValue":"zhangsan" }' \ @@ -238,19 +247,20 @@ curl -X POST -H "Content-Type: application/json" -d '{ "yysy":"与客户合同用印", "xyywjUrl":"https://files.example.com/contract.pdf", "yysx":"合同用印", - "ywxtdjbh":"DJ-2025-0001" + "ywxtdjbh":"DJ-2025-0001", + "bizCallbackKey":"seal-flow-callback" }' https://your-zt-server/admin-api/system/integration/iwork/workflow/create ``` > 说明:外部仍以 JSON 请求调用本服务,系统在向 iWork 转发时会自动将负载转换为 `application/x-www-form-urlencoded` 表单(含 `requestName`、`workflowId`、`mainData` 等字段)。 +> 响应的 `data.requestId` 为唯一业务标识,请在业务侧保存;appId 始终取配置;`xyywjFileName` 不存储。 1. Void workflow ```bash curl -X POST -H "Content-Type: application/json" -d '{ "requestId":"REQ-001", - "reason":"作废原因", - "appId":"my-iwork-app" + "reason":"作废原因" }' https://your-zt-server/admin-api/system/integration/iwork/workflow/void ``` @@ -259,34 +269,98 @@ curl -X POST -H "Content-Type: application/json" -d '{ ## 核心逻辑与细节 1. 基础参数解析 +appId:始终取配置 `iwork.app-id`,忽略请求值。 +主标识:全链路仅使用 `requestId`(发起返回、作废入参、日志查询、回调分发、重试都基于 requestId)。 +模板:`workflowId` 仅用于选择 iWork 模板,不作为业务标识。 +业务回调标识:`bizCallbackKey`(字符串,≤255),发起时提交,回调按该标识分发到具体业务回调入口。 +附件文件名:`xyywjFileName` 不存储也不参与处理;仅使用 `xyywjUrl`。 - 系统始终使用 `application.yml` 中配置的 `app-id` 与 `client-public-key` 与 iWork 通信。 - 请求体中的 `appId` 字段仅为兼容历史调用而保留,框架内部不会使用该值做切换。 - -1. Workflow 模板解析 - - 调用时优先使用请求体中的 `workflowId`。 - 若未显式传入,则回退到全局 `iwork.workflow-id`,若仍为空则抛出 `IWORK_WORKFLOW_ID_MISSING`。 - -1. 注册 + RSA + Token - - - 在首次或 token 过期时,会按以下步骤获取 session: + 调用时可在请求体中指定 `workflowId` 以选择模板;若未显式传入,则回退到全局 `iwork.workflow-id`,若仍为空则抛出 `IWORK_WORKFLOW_ID_MISSING`。 +POST `/system/integration/iwork/workflow/create`:发起用印流程,返回 `requestId`。 +POST `/system/integration/iwork/workflow/void`:作废流程,请求需携带 `requestId`。 +POST `/system/integration/iwork/callback/file`(上游回调):接收 iWork 回调,校验 `requestId`,若缺 `bizCallbackKey` 则记录失败但不中断附件保存;下载并落库附件,记录回调日志(截断原文),并在提供 `bizCallbackKey` 时通过 RocketMQ 分发业务回调(自动/手工重试)。 +POST `/system/integration/iwork/log/page`:分页查询用印回调日志(requestId / 业务单号 / bizCallbackKey / 状态 / 时间段)。 +POST `/system/integration/iwork/log/retry`:手工重试业务回调,需权限,入参 `requestId`(状态为失败/超重试时可用)。 1. 向 iWork 的 `register` 接口发起请求(Headers 包含 appId 与 clientPublicKey)。 2. 从注册响应中获取 `secret` 与 `spk`(服务端公钥),使用本地的 client 公钥做 RSA 加密(`spk` 用于加密),得到加密后的 secret 与 encryptedUserId。 - 3. 使用注册返回的密钥申请 token(apply-token),token 会被按 `ttl-seconds` 缓存。 - - - `IWorkIntegrationServiceImpl` 中维护一个 Caffeine `sessionCache`,缓存 key 为 `appId::operatorUserId`。 +发起:`workflowId`、`jbr`、`yybm`、`fb`、`sqsj`、`yyqx`、`xyywjUrl`、`yysx`、`ywxtdjbh` 必填;`bizCallbackKey` 可选但建议提供;appId 忽略。 +作废:`requestId` 必填;`reason` 可选。 +回调:若无法找到业务附件(或不存在),标记用印失败并记日志,不抛出未捕获异常;记录原始回调文本(截断)。 - 当 token 接近到期(`refresh-ahead-seconds`)时会在下一次请求触发刷新。 -1. 请求构造 - - - 用户解析、作废等场景继续以 `application/json` 调用 iWork。 - - 用印流程发起在转发至 iWork 时改为 `application/x-www-form-urlencoded` 表单,请求正文包含 `requestName`、`workflowId` 及字符串化后的 `mainData`,与 iWork 网关当前要求保持一致。 - - 认证 Header:由 `IWorkProperties.Headers` 中的常量控制,固定键名为 `app-id`、`client-public-key`、`secret`、`token`、`time`、`user-id`。 +1) 发起:校验必填 → 使用配置 appId 与模板 workflowId → 申请/缓存 token → 以 form-urlencoded 调 iWork → 返回 `requestId`,记录发起日志状态。 +2) 回调(/callback/file):校验 requestId + bizCallbackKey → 校验租户/必备字段 → 下载/保存附件(若缺业务附件则直接标记失败并记日志)→ 写入回调日志,保存原始回调请求/响应(截断)→ 通过 RocketMQ 发送业务回调消息(topic=`SYSTEM_IWORK_BIZ_CALLBACK`,tag=`bizCallbackKey`)。 +3) 业务处理 & 结果上报(跨模块/跨进程):业务模块订阅 `SYSTEM_IWORK_BIZ_CALLBACK` 对应 tag 处理后,将结果发布到 `SYSTEM_IWORK_BIZ_CALLBACK_RESULT`(tag 同 bizCallbackKey,携带 requestId / bizCallbackKey / success / errorMessage / payload / attempt / maxAttempts),system 模块消费结果并更新日志,失败且未超限时由 system 端按配置延迟重试再次投递回调消息。 +4) 重试:默认 3 次、间隔 5 秒,可配置(`iwork.callback.retry.*`),手工重试仍使用 `/log/retry`,由 system 模块重新投递 MQ 消息。 +5) 手工重试:需具备 iWork 模块权限;根据 requestId 将任务重新投递至回调分发器,不增加自动重试计数,可在日志详情中发起。 1. 响应解析 +`CREATE_PENDING` / `CREATE_SUCCESS` / `CREATE_FAILED` +`CALLBACK_PENDING` / `CALLBACK_SUCCESS` / `CALLBACK_FAILED` +`CALLBACK_RETRYING` / `CALLBACK_RETRY_FAILED` - - 实现里对响应成功的判定比较宽松:检查 `code`、`status`、`success`、`errno` 等字段(支持布尔、字符串 ‘0’/‘1’/‘success’)以判断是否成功,并解析常见的 message 字段 `msg|message|errmsg`。 +--- + +## 用印流程日志与业务回调 + +### 范围与标识 + +- appId:始终取配置 `iwork.app-id`,忽略请求值。 +- 主标识:全链路仅使用 `requestId`(发起返回、作废入参、日志查询、回调分发、重试都基于 requestId)。 +- 模板:`workflowId` 仅用于选择 iWork 模板,不作为业务标识。 +- 业务回调标识:`bizCallbackKey`(字符串,≤255),发起时提交,回调按该标识分发到具体业务回调入口。 +- 附件文件名:`xyywjFileName` 不存储也不参与处理;仅使用 `xyywjUrl`。 + +### 入口(REST) + +- POST `/system/integration/iwork/workflow/create`:发起用印流程,返回 `requestId`。 +- POST `/system/integration/iwork/workflow/void`:作废流程,请求需携带 `requestId`。 +- POST `/system/integration/iwork/callback/file`(上游回调):接收 iWork 回调,校验 `requestId`,若缺 `bizCallbackKey` 则记录失败但不中断附件保存;下载并落库附件,记录回调日志(截断原文),并在提供 `bizCallbackKey` 时通过 RocketMQ 分发业务回调(自动/手工重试)。 +- POST `/system/integration/iwork/log/page`:分页查询用印回调日志(requestId / 业务单号 / bizCallbackKey / 状态 / 时间段)。 +- POST `/system/integration/iwork/log/retry`:手工重试业务回调,需权限,入参 `requestId`(状态为失败/超重试时可用)。 + +### 必填/校验要点 + +- 发起:`workflowId`、`jbr`、`yybm`、`fb`、`sqsj`、`yyqx`、`xyywjUrl`、`yysx`、`ywxtdjbh` 必填;`bizCallbackKey` 可选但建议提供;appId 忽略。 +- 作废:`requestId` 必填;`reason` 可选。 +- 回调:若无法找到业务附件(或不存在),标记用印失败并记日志,不抛出未捕获异常;记录原始回调文本(截断)。 + +### 处理流程(摘要) + +1) 发起:校验必填 → 使用配置 appId 与模板 workflowId → 申请/缓存 token → 以 form-urlencoded 调 iWork → 返回 `requestId`,记录发起日志状态。 +2) 回调(/callback/file):校验 requestId + bizCallbackKey → 校验租户/必备字段 → 下载/保存附件(若缺业务附件则直接标记失败并记日志)→ 写入回调日志,保存原始回调请求/响应(截断)→ 通过 RocketMQ 发送业务回调消息(topic=`SYSTEM_IWORK_BIZ_CALLBACK`,tag=`bizCallbackKey`)。 +3) 业务处理 & 结果上报(跨模块/跨进程):业务模块订阅 `SYSTEM_IWORK_BIZ_CALLBACK` 对应 tag 处理后,将结果发布到 `SYSTEM_IWORK_BIZ_CALLBACK_RESULT`(tag 同 bizCallbackKey,携带 requestId / bizCallbackKey / success / errorMessage / payload / attempt / maxAttempts),system 模块消费结果并更新日志,失败且未超限时由 system 端按配置延迟重试再次投递回调消息。 +4) 重试:默认 3 次、间隔 5 秒,可配置(`iwork.callback.retry.*`),手工重试仍使用 `/log/retry`,由 system 模块重新投递 MQ 消息。 +5) 手工重试:需具备 iWork 模块权限;根据 requestId 将任务重新投递至回调分发器,不增加自动重试计数,可在日志详情中发起。 + +### 状态字面量(示例) + +- `CREATE_PENDING` / `CREATE_SUCCESS` / `CREATE_FAILED` +- `CALLBACK_PENDING` / `CALLBACK_SUCCESS` / `CALLBACK_FAILED` +- `CALLBACK_RETRYING` / `CALLBACK_RETRY_FAILED` + +> 实际实现可根据需要细化,唯一标识均为 requestId。 + +### 重试与配置 + +- 自动重试:默认 `maxAttempts=3`、`delaySeconds=5`,可配置(示例键:`iwork.callback.retry.max-attempts`、`iwork.callback.retry.delay-seconds`)。 +- 手工重试:权限校验(沿用 iWork 模块权限前缀);仅在失败/超重试状态下开放。 +- 幂等:按 requestId + bizCallbackKey 进行幂等检查,成功后不再重复分发,除非手工强制重试。 + +### 日志存储 + +- 表:`system_iwork_seal_callback_log`(示例字段) + - `requestId`(PK)、`businessCode`(ywxtdjbh)、`bizCallbackKey`、`status`、`retryCount`、`lastErrorMessage`、`fileUrl`、`fileId`、`businessFileId`、`requestBody`、`responseBody`、`rawCallback`(截断保存原文)、`lastCallbackTime`、`creator`、`createTime`、`updateTime`。 +- 字段要点: + - `rawCallback`:保存回调原始文本,截断存储(无需脱敏,不注明上限)。 + - 无业务附件或保存失败:写 `status=CREATE_FAILED` / `CALLBACK_FAILED` 并记录错误原因。 + +### 查询与展示 + +- 分页:沿用 `PageParam` 约束,`pageNo` 默认 1、`pageSize` 默认 10、上限 10000,仅分页浏览不支持导出。 +- 查询条件:`requestId`、`businessCode`(ywxtdjbh)、`bizCallbackKey`、`status`、时间范围(createTime / lastCallbackTime)。 +- 列表字段:requestId、业务单号、bizCallbackKey、状态、重试次数、最后错误、更新时间。 +- 详情:展示截断的回调原文(请求/响应)、错误原因、附件信息;提供“手工重试”按钮(需权限)。 --- @@ -326,8 +400,8 @@ curl -X POST -H "Content-Type: application/json" -d '{ ## 小结与建议 -- 在配置中补齐 `iwork.app-id`、`iwork.client-public-key`、`iwork.user-id`、`iwork.workflow-id` 等关键字段。 -- 优先在本地通过 `IWorkIntegrationService` Java API 调试,成功后再通过 Controller 的 REST 接口对外暴露。 -- 若遇到请求失败,查看应用日志(`[iWork]` 前缀的日志)与 iWork 网关返回 body,定位是注册、申请 token,还是业务接口(user-info/create/void)失败。 +- 在配置中补齐 `iwork.app-id`、`iwork.client-public-key`、`iwork.user-id`、`iwork.workflow-id` 等关键字段,并按需配置回调重试参数(默认 3 次,5 秒间隔)。 +- 优先在本地通过 `IWorkIntegrationService` Java API 调试,成功后再通过 Controller 的 REST 接口对外暴露;发起后请记录返回的 requestId(唯一标识)与 bizCallbackKey。 +- 若遇到请求失败,查看应用日志(`[iWork]` 前缀的日志)与 iWork 网关返回 body;若业务回调失败,可在日志页面查看截断原文并按权限发起手工重试。 文档已生成并保存到:`docs/iWork集成说明.md`。 diff --git a/docs/数据总线用户使用指南.md b/docs/数据总线用户使用指南.md new file mode 100644 index 00000000..d8e22a66 --- /dev/null +++ b/docs/数据总线用户使用指南.md @@ -0,0 +1,202 @@ +# 数据总线(Databus)用户使用指南 + +> **适用范围**:`zt-module-databus-server`(管理与运行时)、`zt-module-databus-api`(对外协议),面向已部署 `ztcloud` 后端 + `zt-vue-element` 前端的环境。文档区分 **业务管理员** 与 **第三方开发者** 两类读者,帮助您从零到一完成配置、发布与调用。 + +## 1. 角色视角与总体流程 + +| 角色 | 核心目标 | 关键系统入口 | +| --- | --- | --- | +| 业务管理员 | 在后台可视化界面中定义/编排 API,配置凭证、限流、访问日志,并确保版本可控 | 后台路径:`系统管理 → 数据总线`(或直接访问 `/#/databus`)| +| 第三方开发者 | 按照后台下发的 `apiCode + version + appId`,满足安全约束后调用统一网关,解析响应并接入自身系统 | 对外入口:`{protocol}://{host}{basePath}/{apiCode}/{version}`(默认 Base Path `/admin-api/databus/api/portal`)| + +完整流程示意: +1. **管理员初始化**(执行 DM8 脚本、开通菜单与权限)。 +2. **管理员在“API 定义”中建模**(步骤、变换、策略、版本发布)。 +3. **管理员为业务方创建凭证**(`ZT-App-Id`、密钥、IP 白名单、匿名用户等)。 +4. **第三方拿到凭证后发起调用**(签名 → 加密 → 发送 → 解密响应)。 +5. **管理员通过限流/日志功能持续运维**,必要时回滚版本或刷新缓存。 + +## 2. 通用准备事项 + +### 2.1 服务与配置 + +- **运行中服务**:`zt-server` 网关、`zt-module-databus-*` 相关微服务、Redis(用于 nonce、防重放、限流计数)。 +- **关键配置**(摘自 `application.yml`): + - `databus.api-portal.base-path`:默认 `/admin-api/databus/api/portal`,如需兼容旧系统可保留 `/databus/api/portal` 别名。 + - `allowed-ips/denied-ips`:无白名单时留空即放行全部;建议第三方出口 IP 全量登记。 + - `security`:`signature-type(MD5/SHA256)`、`encryption-type(AES/DES)`、`nonce-ttl`、`allowed-clock-skew-seconds`、是否强制请求/响应加密。 + - `enable-tenant-header`:如开启多租,会自动从 `ZT-Tenant-Id`(可自定义)透传。 + +### 2.2 数据库与菜单初始化(DM8) + +| 场景 | 脚本(目录 `sql/dm/`) | 说明 | +| --- | --- | --- | +| 初始化菜单与按钮权限 | `统一外部网关菜单_20251010.sql` | 建立“数据总线”一级菜单及 API/凭证/策略/访问日志页面的所有操作权限,支持重复执行。 | +| 访问日志菜单补充 | `数据总线API访问日志菜单权限_20251028.sql` | 若已执行主菜单脚本仍找不到访问日志入口,可追加该脚本。 | +| API 版本历史表 | `数据总线API版本历史表结构_备忘录模式_20251030.sql` | DM8 兼容语法,包含索引与字段注释,确保支持版本快照/回滚。 | +| 访问日志表 | `数据总线API访问日志表结构_20251028.sql` | 记录追踪 ID、请求/响应、步骤结果等,供后台“访问日志”页面检索。 | +| 示例/演示数据 | `../mysql/databus_sample_data.sql`(MySQL) | 可作为建模参考,字段一致,迁移到 DM 时仅需替换数据类型即可。 | + +> **执行顺序建议**:先运行菜单脚本 → 赋予管理员角色对应按钮权限 → 执行表结构脚本 → 根据需要导入示例数据。 + +## 3. 业务管理员操作路线 + +### 3.1 角色与权限校验 + +1. 通过 DM8 脚本写入菜单/按钮后,将 `数据总线` 相关权限分配给目标角色(如 `system_admin`)。 +2. 确认角色拥有以下最少权限:`databus:gateway:*`、`databus:credential:*`、`databus:policy:*`、`databus:gateway:access-log:query`。 + +### 3.2 创建 API 定义 + +进入 **数据总线 → API 定义**,按以下步骤操作: + +1. 点击“新建”填写基础信息:`API 编码 (apiCode)`、`版本 (version)`、`描述`、`HTTP Method`、`URI Pattern`(运行期仅用于文档,可自由描述)。 +2. 选择 **认证策略** 与 **限流策略**:默认策略可复用,也可在对应菜单先新建。 +3. 选择 **状态 = 草稿**,避免未完成配置就被调度。 + +> **提示**:Start/End 步骤系统会自动校验唯一性,必须至少存在一条 Start 与 End 步骤。 + +### 3.3 配置编排步骤与变换 + +1. 在 API 详情页新增步骤: + - **类型**:HTTP / RPC / Script / Start / End。 + - **目标地址**:HTTP 需写 `METHOD http://host/path`,并按需填写 Header/Body 映射表达式。 + - **映射表达式**:支持 JSON 表达式,常用写法示例同 `databus_sample_data.sql` 中 `JSON::(...)`。 + - **重试/超时**:以 JSON 形式配置 `maxAttempts`、`delayMs`。 +2. 配置步骤级或 API 级 **Transform**: + - 场景示例:请求前补链路追踪 ID(`REQUEST_PRE`)、响应前组装统一结构(`RESPONSE_PRE`)。 + - 校验规则:同一 `phase` 仅允许一条记录。 + +### 3.4 版本与发布 + +1. **保存即可生成版本快照**:系统会在 `databus_api_version` 中写入 JSON 快照并标记 `is_current=1`。 +2. **调试**:在 API 列表点击“调试”,通过 `POST /databus/gateway/invoke` 模拟真实请求,可自带 Header/Body。 +3. **上线**:修改 `状态=ONLINE` 后保存,系统自动刷新运行时 Flow。若需全量刷新,可在列表右上点击“刷新缓存”。 +4. **回滚**:在“版本历史”侧栏选择目标版本 → 点击“回滚”,系统将历史快照恢复为现有配置并重新发布。 + +### 3.5 凭证与策略运营 + +1. **客户端凭证**: + - 进入“客户端凭证” → 新建 → 生成 `appId`、密钥、签名/加密算法。 + - 可绑定匿名用户(后台会在调用时自动模拟该用户登录)。 + - 支持 IP 白名单、描述及状态维护。 +2. **限流策略**: + - 在“限流策略”菜单中新增 `FIXED_WINDOW` 类型,配置 `limit`、`windowSeconds`、`keyTemplate`。 + - 常见做法:`${apiCode}:${header.X-Client-Id}`,以不同客户端维度做计数。 + +### 3.6 监控与排障 + +1. **访问日志**:菜单路径“访问日志”,支持按 `traceId / apiCode / 时间` 检索详情,字段对应 `databus_api_access_log`。 +2. **常见问题定位**: + - 401 → 校验凭证/签名/时间戳。 + - 429 → 限流策略触发,可在日志中观察 `status=1`/`errorCode=API_RATE_LIMIT_EXCEEDED`。 + - 5xx → 检查步骤 `stepResults` 中的 `error` 描述、Integration Flow 日志。 + +## 4. 第三方开发者操作路线 + +### 4.1 凭证申请 + +向业务管理员提供以下信息:调用系统名称、出口 IP 列表、是否需要匿名账号、预期调用 API 列表。管理员创建凭证后会得到: + +- `ZT-App-Id` +- `encryptionKey` + `encryptionType`(AES/DES) +- `signatureType`(MD5/SHA256) +- 可选:匿名内部用户、租户 ID + +### 4.2 网络与安全要求 + +- 请求必须来自允许的 IP,且使用 HTTPS(推荐)或可信的内网段。 +- 时间戳与服务器偏差 ≤ `allowed-clock-skew-seconds`(默认 300 秒)。 +- `nonce` 在 `nonce-ttl-seconds` 内不得重复(默认 600 秒,服务器使用 Redis 去重)。 +- 若开启 `require-body-encryption`,请求体必须是 **加密后再 Base64 编码** 的字符串。 + +### 4.3 构建请求的 7 个步骤 + +| 步骤 | 动作 | 关键点 | +| --- | --- | --- | +| 1 | 生成 `timestamp` | `long` 类型毫秒值;建议每次现取。 | +| 2 | 生成 `nonce` | ≥8 位随机字符串,推荐 `UUID` 去掉 `-`。 | +| 3 | 准备明文 Body | JSON 文本,记为 `plainBody`。GET 请求仍建议传空 JSON `{}`,以便签名。 | +| 4 | 计算签名 | 将 `appId`、`timestamp`、`nonce`、`plainBody`(或 Query)按 key 排序拼接 `key=value`,使用约定算法得出 `signature`。 | +| 5 | 加密 Body | 以凭证密钥对 `plainBody` 执行 AES/DES 加密,再 Base64(需与服务端配置一致)。 | +| 6 | 组装请求头 | 至少包含 `ZT-App-Id`、`ZT-Timestamp`、`ZT-Nonce`、`ZT-Signature`、`Content-Type`,可附带 `ZT-Tenant-Id`、`X-Client-Id`。 | +| 7 | 发送请求 | URL = `{basePath}/{apiCode}/{version}`,HTTP 方法与后台配置一致;响应若被加密需反向解密。 | + +#### 请求示例(伪代码) + +```text +POST https://gw.example.com/admin-api/databus/api/portal/order.create/v1 +Headers: + ZT-App-Id: demo-app + ZT-Timestamp: 1732070400000 + ZT-Nonce: 0c5e2df9a1 + ZT-Signature: 8e377... + X-Client-Id: mall +Body (Base64):Q2hhcnNldGV4dC1CYXNlNjQgZW5jcnlwdGVkIGJvZHk= +``` + +#### 响应处理 + +1. 读取 HTTP 状态与 `code/message/traceId` 字段。 +2. 若后台开启响应加密,需使用同一密钥解密得到真正的 `response` JSON。 +3. 保留 `traceId` 以便与管理员对齐日志。 + +### 4.4 常见故障排查 + +| 症状 | 常见原因 | 自助检查 | +| --- | --- | --- | +| 401 + `签名校验失败` | 拼接顺序错误 / 对象未序列化一致 | 确认按字典序拼接 `key=value`,body 使用明文。 | +| 401 + `请求到达时间超出` | 客户端时间漂移 | 同步 NTP,或在发送前刷新服务器时间。 | +| 401 + `重复请求` | `nonce` 重复或被重放 | 确保每次生成唯一随机串。 | +| 403 | IP/应用被禁用 | 让管理员检查凭证状态、白名单。 | +| 429 | 限流策略触发 | 调整并发或与管理员协商提升 `limit`。 | +| 5xx + `步骤执行失败` | 后端步骤调用异常 | 提供 `traceId`,管理员可在访问日志中查看 `stepResults` 详细报错。 | + +## 5. 策略、监控与日志 + +### 5.1 限流与审计 + +- 策略以 JSON 存储在 `databus_policy_rate_limit`,字段:`limit`(次数)、`windowSeconds`(窗口秒)及可选 `keyTemplate`。 +- 当前默认策略实现为 **Redis 固定窗口**:Key 格式 `databus:api:rl:{apiCode}:{version}:{X-Client-Id}`。 +- 触发限流时返回 `HTTP 429`,日志中 `status=1` 且 `errorCode=API_RATE_LIMIT_EXCEEDED`。 +- 如需关闭限流,管理员可在 `application.yml` 中设置 `databus.api-portal.enable-rate-limit=false` 或在 API 上解除策略绑定。 +- 审计日志由 `ApiGatewayAccessLogger` 写入 `databus_api_access_log`,可结合 ELK/BI 做分析。 + +### 5.2 访问日志使用 + +1. **建表**:执行 `sql/dm/数据总线API访问日志表结构_20251028.sql`(已含主键、索引、字段注释)。 +2. **后台入口**:`数据总线 → 访问日志`,需 `databus:gateway:access-log:query` 权限。 +3. **常见字段解释**: + - `REQUEST_*`:存储原始请求;如启用请求加密,会记录解密后的明文。 + - `STEP_RESULTS`:JSON 数组,展示每个步骤的 `elapsed`、`request/response`、`error`,用于定位链路问题。 + - `TRACE_ID`:可与链路追踪或日志平台联动。 +4. **TODO**:如目标环境尚未执行该表脚本,请尽快在 DM 数据库运行,以免访问日志页面无法查询。 + +## 6. 演示数据与调试建议 + +- 使用 `sql/mysql/databus_sample_data.sql` 可一次性生成: + - 3 个示例 API(聚合查询 / 用户画像 / 快速登录)。 + - 对应的步骤、变换、限流策略、发布记录。 +- 若目标环境为 DM,可参考该脚本结构,将 `AUTO_INCREMENT` 改为手动 ID、`datetime` 改 `timestamp`,即可导入。 +- 通过示例数据,管理员可演练以下操作: + 1. 打开 API 列表验证导入内容; + 2. 使用“调试”通过管理端发起调用,确认步骤链路; + 3. 让第三方按照真实流程调用,观察访问日志、限流表现。 + +## 7. 附录:常用 REST 接口速查 + +| 模块 | 方法 | 路径 | 用途 | +| --- | --- | --- | --- | +| API 定义 | GET | `/databus/gateway/definition/page` | 列表检索 | +| | POST | `/databus/gateway/definition` | 新建/更新 API(PUT 更新) | +| | DELETE | `/databus/gateway/definition/{id}` | 删除并注销 Flow | +| API 版本 | GET | `/databus/gateway/version/list?apiId=` | 历史版本 | +| | PUT | `/databus/gateway/version/rollback` | 回滚 | +| 客户端凭证 | POST | `/databus/gateway/credential/create` | 创建凭证 | +| 限流策略 | POST | `/databus/gateway/policy/rate-limit` | 创建/更新策略 | +| 调试/刷新 | POST | `/databus/gateway/invoke` | 管理端调试 | +| | POST | `/databus/gateway/cache/refresh` | 全量刷新 Flow | +| 日志 | GET | `/databus/gateway/access-log/page` | 查看访问日志 | + +--- +如仍有未覆盖的业务场景,可在 `docs/数据总线模块大致功能与调用介绍.md` 中查阅更底层的架构说明,再结合本文步骤完成落地。祝使用顺利! diff --git a/sql/dm/system_iwork_seal_log_dm8.sql b/sql/dm/system_iwork_seal_log_dm8.sql new file mode 100644 index 00000000..5451ee8e --- /dev/null +++ b/sql/dm/system_iwork_seal_log_dm8.sql @@ -0,0 +1,43 @@ +-- iWork 用印回调日志(DM8) +-- 表:system_iwork_seal_log +-- 序列:system_iwork_seal_log_seq + +-- 清理旧对象(若存在) +DROP TABLE IF EXISTS system_iwork_seal_log; + +CREATE TABLE system_iwork_seal_log ( + id BIGINT NOT NULL, + request_id VARCHAR(128) NOT NULL, + business_code VARCHAR(128), + biz_callback_key VARCHAR(255), + status INTEGER, + retry_count INTEGER DEFAULT 0, + max_retry INTEGER, + last_error_message VARCHAR(512), + raw_callback VARCHAR(2000), + last_callback_time DATETIME, + creator VARCHAR(64), + create_time DATETIME DEFAULT SYSDATE, + updater VARCHAR(64), + update_time DATETIME DEFAULT SYSDATE, + deleted SMALLINT DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE (request_id) +); + +COMMENT ON TABLE system_iwork_seal_log IS 'iWork 用印回调日志'; +COMMENT ON COLUMN system_iwork_seal_log.id IS '主键'; +COMMENT ON COLUMN system_iwork_seal_log.request_id IS 'iWork requestId 唯一标识'; +COMMENT ON COLUMN system_iwork_seal_log.business_code IS '业务单号'; +COMMENT ON COLUMN system_iwork_seal_log.biz_callback_key IS '业务回调标识'; +COMMENT ON COLUMN system_iwork_seal_log.status IS '状态枚举'; +COMMENT ON COLUMN system_iwork_seal_log.retry_count IS '已重试次数'; +COMMENT ON COLUMN system_iwork_seal_log.max_retry IS '最大重试次数快照'; +COMMENT ON COLUMN system_iwork_seal_log.last_error_message IS '最后错误信息'; +COMMENT ON COLUMN system_iwork_seal_log.raw_callback IS '回调原文截断'; +COMMENT ON COLUMN system_iwork_seal_log.last_callback_time IS '最近回调时间'; +COMMENT ON COLUMN system_iwork_seal_log.creator IS '创建者'; +COMMENT ON COLUMN system_iwork_seal_log.create_time IS '创建时间'; +COMMENT ON COLUMN system_iwork_seal_log.updater IS '更新者'; +COMMENT ON COLUMN system_iwork_seal_log.update_time IS '最后更新时间'; +COMMENT ON COLUMN system_iwork_seal_log.deleted IS '是否删除'; diff --git a/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/api/iwork/dto/IWorkWorkflowCreateReqDTO.java b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/api/iwork/dto/IWorkWorkflowCreateReqDTO.java index 013eea65..95a10c6d 100644 --- a/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/api/iwork/dto/IWorkWorkflowCreateReqDTO.java +++ b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/api/iwork/dto/IWorkWorkflowCreateReqDTO.java @@ -36,9 +36,9 @@ public class IWorkWorkflowCreateReqDTO extends IWorkBaseReqDTO { @Schema(description = "用印材料附件 URL(必填)") private String xyywjUrl; - - @Schema(description = "用印材料附件文件名(必填)") - private String xyywjFileName; + + @Schema(description = "业务回调标识(回调分发使用,≤255 字符)") + private String bizCallbackKey; @Schema(description = "用印事项") private String yysx; diff --git a/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackMessage.java b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackMessage.java new file mode 100644 index 00000000..4eb80a2b --- /dev/null +++ b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackMessage.java @@ -0,0 +1,23 @@ +package com.zt.plat.module.system.mq.iwork; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class IWorkBizCallbackMessage { + + /** 统一回调主题 */ + public static final String TOPIC = "SYSTEM_IWORK_BIZ_CALLBACK"; + + /** requestId 唯一标识 */ + private String requestId; + /** 业务回调标识 */ + private String bizCallbackKey; + /** 回调负载对象(可为 Map) */ + private Object payload; + /** 当前尝试次数,从 0 开始 */ + private int attempt; + /** 最大尝试次数 */ + private int maxAttempts; +} diff --git a/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackResultMessage.java b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackResultMessage.java new file mode 100644 index 00000000..2a34f72c --- /dev/null +++ b/zt-module-system/zt-module-system-api/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackResultMessage.java @@ -0,0 +1,31 @@ +package com.zt.plat.module.system.mq.iwork; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IWorkBizCallbackResultMessage { + + /** 统一回调结果主题 */ + public static final String TOPIC = "SYSTEM_IWORK_BIZ_CALLBACK_RESULT"; + + /** requestId 唯一标识 */ + private String requestId; + /** 业务回调标识,对应发送方设置的 tag */ + private String bizCallbackKey; + /** 是否成功 */ + private boolean success; + /** 错误消息 */ + private String errorMessage; + /** 当前尝试次数(业务侧可回传) */ + private int attempt; + /** 最大尝试次数 */ + private int maxAttempts; + /** 回调负载(用于 system 端重试再投递) */ + private Object payload; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java index 88adb502..f3b6f08a 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java @@ -3,6 +3,7 @@ package com.zt.plat.module.system.controller.admin.integration.iwork; import com.zt.plat.framework.common.pojo.CommonResult; import com.zt.plat.framework.tenant.core.aop.TenantIgnore; import com.zt.plat.module.system.controller.admin.integration.iwork.vo.*; +import com.zt.plat.module.system.service.integration.iwork.IWorkCallbackLogService; import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService; import com.zt.plat.module.system.service.integration.iwork.IWorkOrgRestService; import com.zt.plat.module.system.service.integration.iwork.IWorkSyncService; @@ -19,6 +20,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.access.prepost.PreAuthorize; + import static com.zt.plat.framework.common.pojo.CommonResult.success; /** * 提供统一 iWork 流程能力的管理端接口。 @@ -34,6 +37,7 @@ public class IWorkIntegrationController { private final IWorkIntegrationService integrationService; private final IWorkOrgRestService orgRestService; private final IWorkSyncService syncService; + private final IWorkCallbackLogService callbackLogService; @PostMapping("/auth/register") @Operation(summary = "注册 iWork 凭证,获取服务端公钥与 secret") @@ -87,6 +91,39 @@ public class IWorkIntegrationController { return success(integrationService.voidWorkflow(reqVO)); } + @PreAuthorize("@ss.hasPermission('system:iwork:log:query')") + @PostMapping("/log/page") + @Operation(summary = "iWork 回调日志分页查询") + public CommonResult> pageLogs(@Valid @RequestBody IWorkCallbackLogPageReqVO reqVO) { + com.zt.plat.framework.common.pojo.PageResult page = callbackLogService.page(reqVO); + java.util.List mapped = new java.util.ArrayList<>(); + for (com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO log : page.getList()) { + IWorkCallbackLogRespVO vo = new IWorkCallbackLogRespVO(); + vo.setId(log.getId()); + vo.setRequestId(log.getRequestId()); + vo.setBusinessCode(log.getBusinessCode()); + vo.setBizCallbackKey(log.getBizCallbackKey()); + vo.setStatus(log.getStatus()); + vo.setRetryCount(log.getRetryCount()); + vo.setMaxRetry(log.getMaxRetry()); + vo.setLastErrorMessage(log.getLastErrorMessage()); + vo.setRawCallback(log.getRawCallback()); + vo.setLastCallbackTime(log.getLastCallbackTime()); + vo.setCreateTime(log.getCreateTime()); + vo.setUpdateTime(log.getUpdateTime()); + mapped.add(vo); + } + return success(new com.zt.plat.framework.common.pojo.PageResult<>(mapped, page.getTotal(), page.getSummary())); + } + + @PreAuthorize("@ss.hasPermission('system:iwork:log:retry')") + @PostMapping("/log/retry") + @Operation(summary = "iWork 回调手工重试") + public CommonResult retry(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) { + callbackLogService.resetAndDispatch(reqVO.getRequestId()); + return success(true); + } + // ----------------- 人力组织接口 ----------------- @PostMapping("/hr/subcompany/page") diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogPageReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogPageReqVO.java new file mode 100644 index 00000000..0eefd069 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogPageReqVO.java @@ -0,0 +1,39 @@ +package com.zt.plat.module.system.controller.admin.integration.iwork.vo; + +import com.zt.plat.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.zt.plat.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - iWork 用印回调日志分页查询") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class IWorkCallbackLogPageReqVO extends PageParam { + + @Schema(description = "requestId") + private String requestId; + + @Schema(description = "业务单号") + private String businessCode; + + @Schema(description = "业务回调标识") + private String bizCallbackKey; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "创建时间范围") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + @Schema(description = "最后回调时间范围") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] lastCallbackTime; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogRespVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogRespVO.java new file mode 100644 index 00000000..577908f9 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkCallbackLogRespVO.java @@ -0,0 +1,24 @@ +package com.zt.plat.module.system.controller.admin.integration.iwork.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "iWork 用印回调日志响应 VO") +@Data +public class IWorkCallbackLogRespVO { + + private Long id; + private String requestId; + private String businessCode; + private String bizCallbackKey; + private Integer status; + private Integer retryCount; + private Integer maxRetry; + private String lastErrorMessage; + private String rawCallback; + private LocalDateTime lastCallbackTime; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkFileCallbackReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkFileCallbackReqVO.java index 12a5d2b5..cb044d91 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkFileCallbackReqVO.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkFileCallbackReqVO.java @@ -2,12 +2,21 @@ package com.zt.plat.module.system.controller.admin.integration.iwork.vo; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; @Schema(name = "IWorkFileCallbackReqVO", description = "iWork 文件回调请求 VO") @Data public class IWorkFileCallbackReqVO { + @Schema(description = "iWork requestId,唯一标识", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "requestId 不能为空") + private String requestId; + + @Schema(description = "业务回调标识 bizCallbackKey,≤255 字符", example = "seal-flow-callback") + @Size(max = 255, message = "bizCallbackKey 长度不能超过 255 字符") + private String bizCallbackKey; + @Schema(description = "文件下载 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://example.com/file.pdf") @NotBlank(message = "文件 URL 不能为空") private String fileUrl; @@ -19,6 +28,9 @@ public class IWorkFileCallbackReqVO { @Schema(description = "文件名称,可选", example = "合同附件.pdf") private String fileName; - @Schema(description = "OA 单点下载使用的 ssoToken,可选", example = "6102A7C13F09DD6B1AF06CDA0E479AC8...") - private String ssoToken; + @Schema(description = "业务回调状态/结果码,可选") + private String status; + + @Schema(description = "原始回调文本(可选,若不传则使用 payload 或请求体序列化)") + private String rawBody; } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOaTokenReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOaTokenReqVO.java index bfbd488f..37df96b0 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOaTokenReqVO.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOaTokenReqVO.java @@ -14,6 +14,6 @@ public class IWorkOaTokenReqVO { @NotBlank(message = "loginId 不能为空") private String loginId; - @Schema(description = "应用 appid,未填则使用配置默认值", example = "a17ca6ca-88b0-463e-bffa-7995086bf225") + @Schema(description = "应用 appid,已固定使用配置值,无需传递", example = "") private String appId; } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCallbackReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCallbackReqVO.java new file mode 100644 index 00000000..66cf7d87 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCallbackReqVO.java @@ -0,0 +1,26 @@ +package com.zt.plat.module.system.controller.admin.integration.iwork.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Schema(description = "iWork 流程回调请求") +@Data +public class IWorkWorkflowCallbackReqVO { + + @Schema(description = "iWork requestId,唯一标识", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "requestId 不能为空") + private String requestId; + + @Schema(description = "业务单号 (ywxtdjbh)") + private String businessCode; + + @Schema(description = "业务回调标识 bizCallbackKey") + private String bizCallbackKey; + + @Schema(description = "回调状态/结果码") + private String status; + + @Schema(description = "原始回调文本(可截断存储)") + private String rawBody; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCreateReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCreateReqVO.java index 1b2fcc54..2430fc04 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCreateReqVO.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkWorkflowCreateReqVO.java @@ -34,9 +34,9 @@ public class IWorkWorkflowCreateReqVO extends IWorkBaseReqVO { @Schema(description = "用印材料附件 URL(必填)") private String xyywjUrl; - - @Schema(description = "用印材料附件文件名(必填)") - private String xyywjFileName; + + @Schema(description = "业务回调标识(回调分发使用,≤255 字符)") + private String bizCallbackKey; @Schema(description = "用印事项") private String yysx; diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/dataobject/iwork/IWorkSealLogDO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/dataobject/iwork/IWorkSealLogDO.java new file mode 100644 index 00000000..09e1d325 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/dataobject/iwork/IWorkSealLogDO.java @@ -0,0 +1,72 @@ +package com.zt.plat.module.system.dal.dataobject.iwork; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.zt.plat.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * iWork 用印流程回调日志。 + */ +@TableName("system_iwork_seal_log") +@KeySequence("system_iwork_seal_log_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class IWorkSealLogDO extends BaseDO { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * iWork 返回的请求编号,唯一业务标识。 + */ + private String requestId; + + /** + * 业务单号(ywxtdjbh)。 + */ + private String businessCode; + + /** + * 业务回调标识。 + */ + private String bizCallbackKey; + + /** + * 状态枚举,参考 IWorkCallbackStatusEnum。 + */ + private Integer status; + + /** + * 已执行的自动/手工重试次数。 + */ + private Integer retryCount; + + /** + * 最大重试次数(快照)。 + */ + private Integer maxRetry; + + /** + * 最后一次错误信息。 + */ + private String lastErrorMessage; + + /** + * 回调原始负载(截断)。 + */ + private String rawCallback; + + /** + * 最近一次回调时间。 + */ + private LocalDateTime lastCallbackTime; + +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/mysql/iwork/IWorkSealLogMapper.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/mysql/iwork/IWorkSealLogMapper.java new file mode 100644 index 00000000..55ac9ee6 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/dal/mysql/iwork/IWorkSealLogMapper.java @@ -0,0 +1,27 @@ +package com.zt.plat.module.system.dal.mysql.iwork; + +import com.zt.plat.framework.common.pojo.PageResult; +import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX; +import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkCallbackLogPageReqVO; +import com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface IWorkSealLogMapper extends BaseMapperX { + + default IWorkSealLogDO selectByRequestId(String requestId) { + return selectOne(IWorkSealLogDO::getRequestId, requestId); + } + + default PageResult selectPage(IWorkCallbackLogPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(IWorkSealLogDO::getRequestId, reqVO.getRequestId()) + .eqIfPresent(IWorkSealLogDO::getBusinessCode, reqVO.getBusinessCode()) + .eqIfPresent(IWorkSealLogDO::getBizCallbackKey, reqVO.getBizCallbackKey()) + .eqIfPresent(IWorkSealLogDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(IWorkSealLogDO::getCreateTime, reqVO.getCreateTime()) + .betweenIfPresent(IWorkSealLogDO::getLastCallbackTime, reqVO.getLastCallbackTime()) + .orderByDesc(IWorkSealLogDO::getId)); + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java index dde38deb..c7a4e0ee 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java @@ -42,6 +42,7 @@ public class IWorkProperties { private final Client client = new Client(); private final OrgRest org = new OrgRest(); private final Workflow workflow = new Workflow(); + private final Callback callback = new Callback(); private final Oa oa = new Oa(); @Data @@ -127,6 +128,26 @@ public class IWorkProperties { private String sealWorkflowId; } + @Data + public static class Callback { + /** + * 业务回调重试配置。 + */ + private final Retry retry = new Retry(); + } + + @Data + public static class Retry { + /** + * 最大重试次数,默认 3。 + */ + private int maxAttempts = 3; + /** + * 重试延迟(秒),默认 5。 + */ + private int delaySeconds = 5; + } + @Data public static class Oa { /** diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackListener.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackListener.java new file mode 100644 index 00000000..c92bfe45 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackListener.java @@ -0,0 +1,66 @@ +package com.zt.plat.module.system.mq.iwork; + +import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties; +import com.zt.plat.module.system.service.integration.iwork.IWorkCallbackLogService; +import com.zt.plat.module.system.mq.iwork.IWorkBizCallbackMessage; +import com.zt.plat.module.system.mq.iwork.IWorkBizCallbackResultMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 监听业务模块回传的处理结果,并在失败时由 system 模块负责重试投递原始回调消息。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@RocketMQMessageListener(topic = IWorkBizCallbackResultMessage.TOPIC, consumerGroup = IWorkBizCallbackResultMessage.TOPIC + "_CONSUMER") +public class IWorkBizCallbackListener implements RocketMQListener, InitializingBean { + + private final IWorkCallbackLogService logService; + private final IWorkProperties properties; + private final IWorkBizCallbackProducer producer; + private ScheduledExecutorService scheduler; + + @Override + public void afterPropertiesSet() { + scheduler = Executors.newScheduledThreadPool(1, r -> new Thread(r, "iwork-callback-retry")); + } + + @Override + public void onMessage(IWorkBizCallbackResultMessage message) { + String key = message.getBizCallbackKey(); + if (message.isSuccess()) { + logService.markSuccess(message.getRequestId()); + return; + } + + int attempt = message.getAttempt() + 1; + logService.incrementRetry(message.getRequestId()); + int maxAttempts = message.getMaxAttempts() > 0 ? message.getMaxAttempts() : properties.getCallback().getRetry().getMaxAttempts(); + + if (attempt < maxAttempts) { + logService.markFailure(message.getRequestId(), message.getErrorMessage(), true, maxAttempts); + + IWorkBizCallbackMessage next = IWorkBizCallbackMessage.builder() + .requestId(message.getRequestId()) + .bizCallbackKey(key) + .payload(message.getPayload()) + .attempt(attempt) + .maxAttempts(maxAttempts) + .build(); + + int delay = properties.getCallback().getRetry().getDelaySeconds(); + scheduler.schedule(() -> producer.send(next), delay, TimeUnit.SECONDS); + } else { + logService.markFailure(message.getRequestId(), message.getErrorMessage(), false, maxAttempts); + } + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackProducer.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackProducer.java new file mode 100644 index 00000000..1b526f23 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/mq/iwork/IWorkBizCallbackProducer.java @@ -0,0 +1,18 @@ +package com.zt.plat.module.system.mq.iwork; + +import lombok.RequiredArgsConstructor; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class IWorkBizCallbackProducer { + + private final RocketMQTemplate rocketMQTemplate; + + public void send(IWorkBizCallbackMessage message) { + // 使用 tag=bizCallbackKey,方便业务侧按 key 订阅 + String destination = IWorkBizCallbackMessage.TOPIC + ":" + message.getBizCallbackKey(); + rocketMQTemplate.syncSend(destination, message); + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkCallbackLogService.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkCallbackLogService.java new file mode 100644 index 00000000..c87ba5de --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkCallbackLogService.java @@ -0,0 +1,21 @@ +package com.zt.plat.module.system.service.integration.iwork; + +import com.zt.plat.framework.common.pojo.PageResult; +import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkCallbackLogPageReqVO; +import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCallbackReqVO; +import com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO; + +public interface IWorkCallbackLogService { + + IWorkSealLogDO upsertOnCallback(IWorkWorkflowCallbackReqVO reqVO, int maxRetry, String rawBody); + + void markSuccess(String requestId); + + void markFailure(String requestId, String error, boolean retrying, int maxRetry); + + void incrementRetry(String requestId); + + PageResult page(IWorkCallbackLogPageReqVO reqVO); + + void resetAndDispatch(String requestId); +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/callback/IWorkBizCallbackHandler.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/callback/IWorkBizCallbackHandler.java new file mode 100644 index 00000000..967d3140 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/callback/IWorkBizCallbackHandler.java @@ -0,0 +1,16 @@ +package com.zt.plat.module.system.service.integration.iwork.callback; + +public interface IWorkBizCallbackHandler { + + /** + * 返回 handler 能处理的 bizCallbackKey。 + */ + String getBizCallbackKey(); + + /** + * 处理业务回调。 + * @param requestId iWork requestId + * @param payload 回调负载 + */ + void handle(String requestId, Object payload) throws Exception; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/enums/IWorkCallbackStatusEnum.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/enums/IWorkCallbackStatusEnum.java new file mode 100644 index 00000000..c08a3e1c --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/enums/IWorkCallbackStatusEnum.java @@ -0,0 +1,20 @@ +package com.zt.plat.module.system.service.integration.iwork.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum IWorkCallbackStatusEnum { + + CREATE_PENDING(0), + CREATE_SUCCESS(1), + CREATE_FAILED(2), + CALLBACK_PENDING(3), + CALLBACK_SUCCESS(4), + CALLBACK_FAILED(5), + CALLBACK_RETRYING(6), + CALLBACK_RETRY_FAILED(7); + + private final int status; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImpl.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImpl.java new file mode 100644 index 00000000..d46dbbe6 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImpl.java @@ -0,0 +1,130 @@ +package com.zt.plat.module.system.service.integration.iwork.impl; + +import com.zt.plat.framework.common.pojo.PageResult; +import com.zt.plat.framework.common.util.json.JsonUtils; +import com.zt.plat.framework.common.util.object.ObjectUtils; +import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkCallbackLogPageReqVO; +import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCallbackReqVO; +import com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO; +import com.zt.plat.module.system.dal.mysql.iwork.IWorkSealLogMapper; +import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties; +import com.zt.plat.module.system.mq.iwork.IWorkBizCallbackMessage; +import com.zt.plat.module.system.mq.iwork.IWorkBizCallbackProducer; +import com.zt.plat.module.system.service.integration.iwork.IWorkCallbackLogService; +import com.zt.plat.module.system.service.integration.iwork.enums.IWorkCallbackStatusEnum; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class IWorkCallbackLogServiceImpl implements IWorkCallbackLogService { + + private static final int RAW_MAX = 2000; + + private final IWorkSealLogMapper logMapper; + private final IWorkBizCallbackProducer producer; + private final IWorkProperties properties; + + @Override + @Transactional(rollbackFor = Exception.class) + public IWorkSealLogDO upsertOnCallback(IWorkWorkflowCallbackReqVO reqVO, int maxRetry, String rawBody) { + IWorkSealLogDO existing = logMapper.selectByRequestId(reqVO.getRequestId()); + IWorkSealLogDO log = Optional.ofNullable(existing).orElseGet(IWorkSealLogDO::new); + log.setRequestId(reqVO.getRequestId()); + log.setBusinessCode(reqVO.getBusinessCode()); + log.setBizCallbackKey(reqVO.getBizCallbackKey()); + log.setStatus(IWorkCallbackStatusEnum.CALLBACK_PENDING.getStatus()); + log.setRetryCount(ObjectUtils.defaultIfNull(log.getRetryCount(), 0)); + log.setMaxRetry(maxRetry); + log.setRawCallback(truncate(rawBody)); + log.setLastCallbackTime(LocalDateTime.now()); + if (log.getId() == null) { + logMapper.insert(log); + } else { + logMapper.updateById(log); + } + return log; + } + + @Override + public void markSuccess(String requestId) { + IWorkSealLogDO log = new IWorkSealLogDO(); + log.setRequestId(requestId); + log.setStatus(IWorkCallbackStatusEnum.CALLBACK_SUCCESS.getStatus()); + log.setLastErrorMessage(null); + log.setLastCallbackTime(LocalDateTime.now()); + logMapper.update(log, new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(IWorkSealLogDO::getRequestId, requestId)); + } + + @Override + public void markFailure(String requestId, String error, boolean retrying, int maxRetry) { + IWorkSealLogDO log = new IWorkSealLogDO(); + log.setRequestId(requestId); + log.setStatus(retrying ? IWorkCallbackStatusEnum.CALLBACK_RETRYING.getStatus() : IWorkCallbackStatusEnum.CALLBACK_FAILED.getStatus()); + log.setLastErrorMessage(error); + log.setLastCallbackTime(LocalDateTime.now()); + log.setMaxRetry(maxRetry); + logMapper.update(log, new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(IWorkSealLogDO::getRequestId, requestId)); + } + + @Override + public void incrementRetry(String requestId) { + IWorkSealLogDO db = logMapper.selectByRequestId(requestId); + if (db == null) { + return; + } + IWorkSealLogDO log = new IWorkSealLogDO(); + log.setId(db.getId()); + log.setRetryCount(ObjectUtils.defaultIfNull(db.getRetryCount(), 0) + 1); + log.setLastCallbackTime(LocalDateTime.now()); + logMapper.updateById(log); + } + + @Override + public PageResult page(IWorkCallbackLogPageReqVO reqVO) { + return logMapper.selectPage(reqVO); + } + + @Override + public void resetAndDispatch(String requestId) { + IWorkSealLogDO db = logMapper.selectByRequestId(requestId); + if (db == null) { + return; + } + IWorkSealLogDO log = new IWorkSealLogDO(); + log.setId(db.getId()); + log.setRetryCount(0); + log.setStatus(IWorkCallbackStatusEnum.CALLBACK_RETRYING.getStatus()); + log.setLastCallbackTime(LocalDateTime.now()); + logMapper.updateById(log); + + int maxAttempts = properties.getCallback().getRetry().getMaxAttempts(); + Object payload; + try { + payload = JsonUtils.parseObject(db.getRawCallback(), Object.class); + } catch (Exception ex) { + payload = db.getRawCallback(); + } + producer.send(IWorkBizCallbackMessage.builder() + .requestId(db.getRequestId()) + .bizCallbackKey(db.getBizCallbackKey()) + .payload(payload) + .attempt(0) + .maxAttempts(maxAttempts) + .build()); + } + + private String truncate(String raw) { + if (!StringUtils.hasText(raw)) { + return raw; + } + return raw.length() > RAW_MAX ? raw.substring(0, RAW_MAX) : raw; + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java index 445af73b..d06b0adf 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java @@ -182,7 +182,7 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService { } AtomicReference attachmentIdRef = new AtomicReference<>(); - TenantUtils.execute(tenantId, () -> attachmentIdRef.set(saveCallbackAttachment(fileUrl, reqVO.getFileName(), referenceBusinessFile, reqVO.getSsoToken()))); + TenantUtils.execute(tenantId, () -> attachmentIdRef.set(saveCallbackAttachment(fileUrl, reqVO.getFileName(), referenceBusinessFile))); return attachmentIdRef.get(); } @@ -251,14 +251,14 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService { return executeOaRequest(request); } - private Long saveCallbackAttachment(String fileUrl, String overrideFileName, BusinessFileRespDTO referenceBusinessFile, String ssoToken) { + private Long saveCallbackAttachment(String fileUrl, String overrideFileName, BusinessFileRespDTO referenceBusinessFile) { Long businessId = referenceBusinessFile.getBusinessId(); FileCreateReqDTO fileCreateReqDTO = new FileCreateReqDTO(); fileCreateReqDTO.setName(resolveFileName(overrideFileName, fileUrl)); fileCreateReqDTO.setDirectory(null); fileCreateReqDTO.setType(null); - fileCreateReqDTO.setContent(downloadFileBytes(fileUrl, ssoToken)); + fileCreateReqDTO.setContent(downloadFileBytes(fileUrl)); CommonResult fileResult = fileApi.createFileWithReturn(fileCreateReqDTO); if (fileResult == null || !fileResult.isSuccess() || fileResult.getData() == null) { @@ -297,10 +297,8 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService { return businessFile; } - private byte[] downloadFileBytes(String fileUrl, String ssoToken) { - // 如果回调已提供 ssoToken,按需拼接后下载 OA 附件 - String finalUrl = appendSsoTokenIfNeeded(fileUrl, ssoToken); - + private byte[] downloadFileBytes(String fileUrl) { + String finalUrl = fileUrl; OkHttpClient client = okHttpClient(); Request request = new Request.Builder().url(finalUrl).get().build(); try (Response response = client.newCall(request).execute()) { @@ -317,22 +315,6 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService { } } - private String appendSsoTokenIfNeeded(String fileUrl, String ssoToken) { - // 未提供 token 或 URL 为空,直接返回原链接 - if (!StringUtils.hasText(ssoToken) || !StringUtils.hasText(fileUrl)) { - return fileUrl; - } - // 已包含 ssoToken(不区分大小写)则不重复添加 - String lower = fileUrl.toLowerCase(); - if (lower.contains("ssotoken=")) { - return fileUrl; - } - // 简单拼接查询参数 - return fileUrl.contains("?") - ? fileUrl + "&ssoToken=" + ssoToken - : fileUrl + "?ssoToken=" + ssoToken; - } - private String resolveFileName(String overrideName, String fileUrl) { if (StringUtils.hasText(overrideName)) { return overrideName; diff --git a/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImplTest.java b/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImplTest.java new file mode 100644 index 00000000..f0287595 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkCallbackLogServiceImplTest.java @@ -0,0 +1,63 @@ +package com.zt.plat.module.system.service.integration.iwork.impl; + +import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCallbackReqVO; +import com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO; +import com.zt.plat.module.system.dal.mysql.iwork.IWorkSealLogMapper; +import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties; +import com.zt.plat.module.system.mq.iwork.IWorkBizCallbackProducer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class IWorkCallbackLogServiceImplTest { + + private IWorkSealLogMapper mapper; + private IWorkBizCallbackProducer producer; + private IWorkProperties properties; + private IWorkCallbackLogServiceImpl service; + + @BeforeEach + void setup() { + mapper = mock(IWorkSealLogMapper.class); + producer = mock(IWorkBizCallbackProducer.class); + properties = new IWorkProperties(); + service = new IWorkCallbackLogServiceImpl(mapper, producer, properties); + } + + @Test + void upsertOnCallback_shouldTruncateRaw() { + String longRaw = "x".repeat(2100); + IWorkWorkflowCallbackReqVO req = new IWorkWorkflowCallbackReqVO(); + req.setRequestId("REQ-1"); + req.setBizCallbackKey("key"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IWorkSealLogDO.class); + when(mapper.selectByRequestId("REQ-1")).thenReturn(null); + + service.upsertOnCallback(req, 3, longRaw); + + verify(mapper).insert(captor.capture()); + IWorkSealLogDO saved = captor.getValue(); + assertThat(saved.getRawCallback()).hasSize(2000); + assertThat(saved.getMaxRetry()).isEqualTo(3); + } + + @Test + void incrementRetry_shouldIncreaseCount() { + IWorkSealLogDO existing = new IWorkSealLogDO(); + existing.setId(1L); + existing.setRequestId("REQ-2"); + existing.setRetryCount(1); + when(mapper.selectByRequestId("REQ-2")).thenReturn(existing); + + service.incrementRetry("REQ-2"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IWorkSealLogDO.class); + verify(mapper).updateById(captor.capture()); + assertThat(captor.getValue().getRetryCount()).isEqualTo(2); + } +} diff --git a/zt-module-template/zt-module-template-api/src/main/java/com/zt/plat/module/template/enums/ErrorCodeConstants.java b/zt-module-template/zt-module-template-api/src/main/java/com/zt/plat/module/template/enums/ErrorCodeConstants.java index ec932af6..b95de638 100644 --- a/zt-module-template/zt-module-template-api/src/main/java/com/zt/plat/module/template/enums/ErrorCodeConstants.java +++ b/zt-module-template/zt-module-template-api/src/main/java/com/zt/plat/module/template/enums/ErrorCodeConstants.java @@ -13,6 +13,10 @@ public interface ErrorCodeConstants { // ========== 合同 补充编号 ========== ErrorCode DEMO_CONTRACT_NOT_EXISTS = new ErrorCode(2_100_000_000, "合同不存在"); + ErrorCode DEMO_CONTRACT_FILE_URL_NOT_EXISTS = new ErrorCode(2_100_000_001, "合同缺少文件地址,无法发起用印"); + ErrorCode DEMO_CONTRACT_IWORK_CREATE_FAILED = new ErrorCode(2_100_000_002, "iWork 用印发起失败:{}"); + ErrorCode DEMO_CONTRACT_IWORK_VOID_FAILED = new ErrorCode(2_100_000_003, "iWork 用印作废失败:{}"); + // ========== 虚拟化表格 1_100_000_001 ========== ErrorCode DEMO_VIRTUALIZED_TABLE_NOT_EXISTS = new ErrorCode(1_100_000_001, "虚拟化表格不存在"); diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/DemoContractController.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/DemoContractController.java index bd80ea2f..ccb6a8ca 100644 --- a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/DemoContractController.java +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/DemoContractController.java @@ -16,12 +16,13 @@ import com.zt.plat.module.bpm.api.task.BpmTaskApi; import com.zt.plat.module.bpm.api.task.dto.BpmApprovalDetailReqDTO; import com.zt.plat.module.infra.api.businessfile.BusinessFileApi; import com.zt.plat.module.infra.api.file.FileApi; -import com.zt.plat.module.infra.api.file.dto.FileRespDTO; import com.zt.plat.module.system.api.dept.DeptApi; -import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractPageReqVO; -import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractRespVO; -import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractSaveReqVO; +import com.zt.plat.module.system.api.iwork.dto.IWorkOperationRespDTO; +import com.zt.plat.module.system.api.permission.PermissionApi; +import com.zt.plat.module.system.api.user.AdminUserApi; +import com.zt.plat.module.template.controller.admin.contract.vo.*; import com.zt.plat.module.template.dal.dataobject.contract.DemoContractDO; +import com.zt.plat.module.template.service.contract.DemoContractIWorkService; import com.zt.plat.module.template.service.contract.DemoContractService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -67,6 +68,14 @@ public class DemoContractController extends AbstractFileUploadController impleme private BpmProcessInstanceApi bpmInsApi; @Resource private FileApi fileApi; + @Resource + private AdminUserApi adminUserApi; + + @Resource + private PermissionApi permissionApi; + + @Resource + private DemoContractIWorkService demoContractIWorkService; @PostMapping("/create") @Operation(summary = "创建合同") @@ -75,6 +84,13 @@ public class DemoContractController extends AbstractFileUploadController impleme return success(demoContractService.createDemoContract(createReqVO)); } + @PostMapping("/iwork/create") + @Operation(summary = "发起合同 iWork 用印") + @PreAuthorize("@ss.hasPermission('template:demo-contract:create')") + public CommonResult createIWorkWorkflow(@Valid @RequestBody DemoContractIWorkCreateReqVO reqVO) { + return success(demoContractIWorkService.createWorkflow(reqVO)); + } + @PutMapping("/update") @Operation(summary = "更新合同") @PreAuthorize("@ss.hasPermission('template:demo-contract:update')") @@ -92,6 +108,13 @@ public class DemoContractController extends AbstractFileUploadController impleme return success(true); } + @PostMapping("/iwork/void") + @Operation(summary = "作废合同 iWork 用印") + @PreAuthorize("@ss.hasPermission('template:demo-contract:update')") + public CommonResult voidIWorkWorkflow(@Valid @RequestBody DemoContractIWorkVoidReqVO reqVO) { + return success(demoContractIWorkService.voidWorkflow(reqVO)); + } + @DeleteMapping("/delete-list") @Parameter(name = "ids", description = "编号", required = true) @Operation(summary = "批量删除合同") @@ -104,7 +127,6 @@ public class DemoContractController extends AbstractFileUploadController impleme @GetMapping("/get") @Operation(summary = "获得合同") @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('template:demo-contract:query')") public CommonResult getDemoContract(@RequestParam("id") Long id) { DemoContractDO demoContract = demoContractService.getDemoContract(id); return success(BeanUtils.toBean(demoContract, DemoContractRespVO.class)); @@ -112,7 +134,6 @@ public class DemoContractController extends AbstractFileUploadController impleme @GetMapping("/page") @Operation(summary = "获得合同分页") - @PreAuthorize("@ss.hasPermission('template:demo-contract:query')") public CommonResult> getDemoContractPage(@Valid DemoContractPageReqVO pageReqVO) { CommonResult file = fileApi.getFile(1968928810422521857L); diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkCreateReqVO.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkCreateReqVO.java new file mode 100644 index 00000000..1cc3e3ed --- /dev/null +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkCreateReqVO.java @@ -0,0 +1,59 @@ +package com.zt.plat.module.template.controller.admin.contract.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 发起合同 iWork 用印请求 VO + */ +@Data +public class DemoContractIWorkCreateReqVO { + + @Schema(description = "合同 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "10086") + @NotNull(message = "合同 ID 不能为空") + private Long contractId; + + @Schema(description = "用印申请人(iWork 人员 ID)", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用印申请人不能为空") + private String jbr; + + @Schema(description = "用印部门 ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用印部门不能为空") + private String yybm; + + @Schema(description = "用印单位(分部 ID)", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用印单位不能为空") + private String fb; + + @Schema(description = "申请时间,格式 yyyy-MM-dd", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "申请时间不能为空") + private String sqsj; + + @Schema(description = "用印去向") + private String yyqx; + + @Schema(description = "用印依据附件 URL") + private String yyfkUrl; + + @Schema(description = "用印事由或内容摘要", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用印事由不能为空") + private String yysy; + + @Schema(description = "用印事项", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用印事项不能为空") + private String yysx; + + @Schema(description = "业务系统单据编号(用于派生流程标题)") + private String ywxtdjbh; + + @Schema(description = "配置的 iWork 凭证 appId;为空时使用默认凭证") + private String appId; + + @Schema(description = "iWork 操作人用户编号") + private String operatorUserId; + + @Schema(description = "是否强制刷新 token") + private Boolean forceRefreshToken; +} diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkVoidReqVO.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkVoidReqVO.java new file mode 100644 index 00000000..b6b36b3d --- /dev/null +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/contract/vo/DemoContractIWorkVoidReqVO.java @@ -0,0 +1,36 @@ +package com.zt.plat.module.template.controller.admin.contract.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.Map; + +/** + * 作废 / 干预 iWork 用印流程请求 VO + */ +@Data +public class DemoContractIWorkVoidReqVO { + + @Schema(description = "流程请求编号", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "流程请求编号不能为空") + private String requestId; + + @Schema(description = "作废原因") + private String reason; + + @Schema(description = "额外参数") + private Map extraParams; + + @Schema(description = "额外 Form 数据") + private Map formExtras; + + @Schema(description = "配置的 iWork 凭证 appId;为空时使用默认凭证") + private String appId; + + @Schema(description = "iWork 操作人用户编号") + private String operatorUserId; + + @Schema(description = "是否强制刷新 token") + private Boolean forceRefreshToken; +} diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/framework/rpc/config/RpcConfiguration.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/framework/rpc/config/RpcConfiguration.java index 4d1e73d1..8dee651c 100644 --- a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/framework/rpc/config/RpcConfiguration.java +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/framework/rpc/config/RpcConfiguration.java @@ -6,12 +6,17 @@ import com.zt.plat.module.infra.api.config.ConfigApi; import com.zt.plat.module.infra.api.file.FileApi; import com.zt.plat.module.infra.api.websocket.WebSocketSenderApi; import com.zt.plat.module.system.api.dept.DeptApi; +import com.zt.plat.module.system.api.iwork.IWorkIntegrationApi; +import com.zt.plat.module.system.api.permission.PermissionApi; import com.zt.plat.module.system.api.sequence.SequenceApi; import com.zt.plat.module.system.api.sms.SmsSendApi; +import com.zt.plat.module.system.api.user.AdminUserApi; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Configuration; @Configuration(value = "templateRpcConfiguration", proxyBeanMethods = false) -@EnableFeignClients(clients = {FileApi.class, WebSocketSenderApi.class, ConfigApi.class, DeptApi.class, SequenceApi.class, BpmTaskApi.class, BpmProcessInstanceApi.class, SmsSendApi.class}) +@EnableFeignClients(clients = {AdminUserApi.class, FileApi.class, WebSocketSenderApi.class, +ConfigApi.class, DeptApi.class, SequenceApi.class, BpmTaskApi.class, BpmProcessInstanceApi.class, +SmsSendApi.class, PermissionApi.class, IWorkIntegrationApi.class}) public class RpcConfiguration { } diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkService.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkService.java new file mode 100644 index 00000000..b7dc444f --- /dev/null +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkService.java @@ -0,0 +1,21 @@ +package com.zt.plat.module.template.service.contract; + +import com.zt.plat.module.system.api.iwork.dto.IWorkOperationRespDTO; +import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractIWorkCreateReqVO; +import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractIWorkVoidReqVO; + +/** + * 合同 iWork 集成 Service + */ +public interface DemoContractIWorkService { + + /** + * 发起 iWork 用印流程 + */ + IWorkOperationRespDTO createWorkflow(DemoContractIWorkCreateReqVO reqVO); + + /** + * 作废 / 干预 iWork 用印流程 + */ + IWorkOperationRespDTO voidWorkflow(DemoContractIWorkVoidReqVO reqVO); +} diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkServiceImpl.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkServiceImpl.java new file mode 100644 index 00000000..14764e47 --- /dev/null +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/service/contract/DemoContractIWorkServiceImpl.java @@ -0,0 +1,91 @@ +package com.zt.plat.module.template.service.contract; + +import cn.hutool.core.util.StrUtil; +import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil; +import com.zt.plat.framework.common.pojo.CommonResult; +import com.zt.plat.framework.common.util.object.BeanUtils; +import com.zt.plat.module.system.api.iwork.IWorkIntegrationApi; +import com.zt.plat.module.system.api.iwork.dto.IWorkOperationRespDTO; +import com.zt.plat.module.system.api.iwork.dto.IWorkWorkflowCreateReqDTO; +import com.zt.plat.module.system.api.iwork.dto.IWorkWorkflowVoidReqDTO; +import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractIWorkCreateReqVO; +import com.zt.plat.module.template.controller.admin.contract.vo.DemoContractIWorkVoidReqVO; +import com.zt.plat.module.template.dal.dataobject.contract.DemoContractDO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.zt.plat.module.template.enums.ErrorCodeConstants.*; + +/** + * 合同 iWork 集成 Service 实现 + */ +@Slf4j +@Service +@Validated +public class DemoContractIWorkServiceImpl implements DemoContractIWorkService { + + private static final String BIZ_CALLBACK_KEY = "demo-contract-seal"; + + @Resource + private DemoContractService demoContractService; + + @Resource + private IWorkIntegrationApi iWorkIntegrationApi; + + @Override + public IWorkOperationRespDTO createWorkflow(DemoContractIWorkCreateReqVO reqVO) { + DemoContractDO contract = demoContractService.getDemoContract(reqVO.getContractId()); + if (contract == null) { + throw exception(DEMO_CONTRACT_NOT_EXISTS); + } + if (StrUtil.isBlank(contract.getFileUrl())) { + throw exception(DEMO_CONTRACT_FILE_URL_NOT_EXISTS); + } + + IWorkWorkflowCreateReqDTO reqDTO = BeanUtils.toBean(reqVO, IWorkWorkflowCreateReqDTO.class); + reqDTO.setXyywjUrl(contract.getFileUrl()); + reqDTO.setBizCallbackKey(BIZ_CALLBACK_KEY); + if (StrUtil.isBlank(reqDTO.getYwxtdjbh())) { + reqDTO.setYwxtdjbh(contract.getCode()); + } + + CommonResult result = iWorkIntegrationApi.createWorkflow(reqDTO); + IWorkOperationRespDTO data = extractDataOrThrow(result, DEMO_CONTRACT_IWORK_CREATE_FAILED, "发起用印", reqVO.getContractId()); + Long requestId = data.getPayload() != null && data.getPayload().getData() != null + ? data.getPayload().getData().getRequestId() : null; + log.info("[bizKey={}] 发起 iWork 用印成功,contractId={}, requestId={}, message={}", + BIZ_CALLBACK_KEY, reqVO.getContractId(), requestId, data.getMessage()); + return data; + } + + @Override + public IWorkOperationRespDTO voidWorkflow(DemoContractIWorkVoidReqVO reqVO) { + IWorkWorkflowVoidReqDTO reqDTO = BeanUtils.toBean(reqVO, IWorkWorkflowVoidReqDTO.class); + CommonResult result = iWorkIntegrationApi.voidWorkflow(reqDTO); + IWorkOperationRespDTO data = extractDataOrThrow(result, DEMO_CONTRACT_IWORK_VOID_FAILED, "作废用印", null); + Long requestId = data.getPayload() != null && data.getPayload().getData() != null + ? data.getPayload().getData().getRequestId() : null; + log.info("[bizKey={}] 作废 iWork 用印成功,requestId={}, message={}", + BIZ_CALLBACK_KEY, requestId != null ? requestId : reqVO.getRequestId(), data.getMessage()); + return data; + } + + private IWorkOperationRespDTO extractDataOrThrow(CommonResult result, + com.zt.plat.framework.common.exception.ErrorCode errorCode, + String action, + Long contractId) { + if (result == null) { + log.warn("[bizKey={}] {}失败,contractId={},原因=响应为空", BIZ_CALLBACK_KEY, action, contractId); + throw ServiceExceptionUtil.exception(errorCode, "响应为空"); + } + if (result.isError() || result.getData() == null) { + log.warn("[bizKey={}] {}失败,contractId={},code={},msg={}", + BIZ_CALLBACK_KEY, action, contractId, result.getCode(), result.getMsg()); + throw ServiceExceptionUtil.exception(errorCode, result.getMsg()); + } + return result.getData(); + } +} diff --git a/模块功能清单.md b/模块功能清单.md new file mode 100644 index 00000000..f76e18f7 --- /dev/null +++ b/模块功能清单.md @@ -0,0 +1,18 @@ +# 模块功能清单 + +## 模块功能一览 + +- zt-dependencies:统一管理整套微服务的依赖版本,确保各模块框架与组件版本一致、可兼容。 +- zt-framework:封装共用的 Spring Boot Starter 与基础工具(多租户、数据权限、监控、消息、MyBatis 扩展、WebSocket、安全、测试等),为业务模块提供统一的底座能力。 +- zt-gateway:基于 Spring Cloud Gateway 实现的服务网关,承担路由转发、统一鉴权、限流、灰度、日志等流量治理职责。 +- zt-server:汇聚管理端与用户端的统一入口服务,承载聚合接口与对外 API 发布。 +- 系统管理模块:提供用户、角色、部门、岗位、菜单、权限、租户、字典、地区、敏感词、短信邮件、站内信、OAuth2、SSO( epalt、e 办统一认证)、日志、外部同步(e 办数据同步、iWork 组织人员信息同步、中铝主数据同步、同平台跨系统采集与分发系统数据等)等一体化管理体验,支持企业组织架构与安全策略的全场景配置。 +- 基础设施模块:覆盖代码生成、配置中心、数据源、文件与存储通道、业务文件、标准命名库、接口文档、Redis、定时任务、监控日志、WebSocket、SkyWalking 等基础设施能力,提供可视化面板方便研发与运维协作。 +- 工作流程模块:基于 Flowable,提供 BPMN 与仿钉钉 SIMPLE 双设计器、流程建模、表单、监听器、表达式、任务办理、会签/或签/加签/驳回/抄送等流程能力,前端可视化配置与办理确保审批体验顺畅。 +- 数据总线模块:面向 API 网关编排的管理模块,具备接口定义与版本管理、凭证发放、策略编排、访问与运行日志、调用监控等能力,前端界面支持网关、凭证、策略、日志的一站式治理。 +- HTML 转 PDF 模块:提供 HTML 模板渲染为 PDF 的后台服务,支持批量转换与安全校验,适用于各种文档出具场景。 +- 规则引擎模块:提供业务规则建模、决策链编排、版本发布、测试仿真、运行监控等能力,并以可视化方式搭建与调试规则。 +- 报表与大屏模块:集成大屏与报表,支持设计、存储、预览、发布与数据源管理,实现数据可视化与报表快速构建。 +- OnlyOffice 集成:提供在线文档预览与协作入口,满足在线编辑、批注需求。 +- 公共页面、组件、状态与插件体系:包括登录/主页/个人中心等基础导航,图表表格表单等高复用组件,Pinia 状态管理以及 Element Plus、ECharts、form-create、国际化、UnoCSS、埋点等插件,支撑所有业务模块的产品化体验。 +- 信创化迁移改造:系统中所有不兼容国产化数据库的中间件,服务,以及模块都进行信创化改造,支持并适配达梦 8 数据库。 From 1fa8296385c7125bc24a0739721a913190f87734 Mon Sep 17 00:00:00 2001 From: yangchaojin <549193112@qq.com> Date: Wed, 28 Jan 2026 18:57:50 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=8F=AF=E4=B8=8B=E8=BD=BD=E6=A0=87=E8=AF=86=E3=80=81=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E7=9F=AD=E4=BF=A1=E9=AA=8C=E8=AF=81=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/dm/ruoyi-vue-pro-dm8.sql | 4 +- .../common/enums/VerifyCodeSendType.java | 9 ++ .../infra/api/file/dto/FileCreateReqDTO.java | 2 + .../infra/api/file/dto/FileRespDTO.java | 3 + .../controller/admin/file/FileController.java | 100 +++++++++++++++++- .../admin/file/vo/file/FileRespVO.java | 20 +++- .../infra/dal/dataobject/file/FileDO.java | 5 + .../infra/dal/redis/RedisKeyConstants.java | 3 + .../rpc/config/RpcConfiguration.java | 3 +- .../infra/service/file/FileService.java | 32 ++++++ .../infra/service/file/FileServiceImpl.java | 80 +++++++++++++- 11 files changed, 247 insertions(+), 14 deletions(-) create mode 100644 zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java diff --git a/sql/dm/ruoyi-vue-pro-dm8.sql b/sql/dm/ruoyi-vue-pro-dm8.sql index 83fc4b8f..f9a6db25 100644 --- a/sql/dm/ruoyi-vue-pro-dm8.sql +++ b/sql/dm/ruoyi-vue-pro-dm8.sql @@ -340,7 +340,8 @@ CREATE TABLE infra_file ( updater varchar(64) DEFAULT '' NULL, update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted bit DEFAULT '0' NOT NULL, - DOWNLOAD_COUNT INT DEFAULT 0 NOT NULL + DOWNLOAD_COUNT INT DEFAULT 0 NOT NULL, + DOWNLOADABLE SMALLINT DEFAULT 1 NOT NULL ); COMMENT ON COLUMN infra_file.id IS '文件编号'; @@ -358,6 +359,7 @@ COMMENT ON COLUMN infra_file.updater IS '更新者'; COMMENT ON COLUMN infra_file.update_time IS '更新时间'; COMMENT ON COLUMN infra_file.deleted IS '是否删除'; COMMENT ON COLUMN INFRA_FILE.DOWNLOAD_COUNT IS '下载次数'; +COMMENT ON COLUMN INFRA_FILE.DOWNLOADABLE IS '是否可下载(1是,0否)'; COMMENT ON TABLE infra_file IS '文件表'; CREATE INDEX idx_infra_file_hash ON infra_file(hash); diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java new file mode 100644 index 00000000..10e1ef6f --- /dev/null +++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/enums/VerifyCodeSendType.java @@ -0,0 +1,9 @@ +package com.zt.plat.framework.common.enums; + +/** + * 验证码发送方式 + */ +public enum VerifyCodeSendType { + SMS, // 短信验证码 + E_OFFICE // e办消息推送 +} diff --git a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java index c4290020..0b07387c 100644 --- a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java +++ b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileCreateReqDTO.java @@ -22,4 +22,6 @@ public class FileCreateReqDTO { @NotEmpty(message = "文件内容不能为空") private byte[] content; + @Schema(description = "是否可下载(true是,false否)", example = "true") + private Boolean downloadable; } diff --git a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java index 58af4c9a..3b318d0c 100644 --- a/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java +++ b/zt-module-infra/zt-module-infra-api/src/main/java/com/zt/plat/module/infra/api/file/dto/FileRespDTO.java @@ -37,4 +37,7 @@ public class FileRespDTO { @Schema(description = "文件下载次数") private Integer downloadCount; + @Schema(description = "是否可下载(true是,false否)") + private Boolean downloadable; + } \ No newline at end of file diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java index 1c5d9b6a..3e812f1b 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/FileController.java @@ -3,6 +3,7 @@ package com.zt.plat.module.infra.controller.admin.file; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.URLUtil; +import com.zt.plat.framework.common.enums.VerifyCodeSendType; import com.zt.plat.framework.common.exception.ServiceException; import com.zt.plat.framework.common.pojo.CommonResult; import com.zt.plat.framework.common.pojo.PageResult; @@ -21,6 +22,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -43,12 +45,17 @@ import static com.zt.plat.module.infra.framework.file.core.utils.FileTypeUtils.w @Slf4j public class FileController { + @Value("${zt.file.preview-base-url:}") + private String previewBaseUrl; + @Resource private FileService fileService; @GetMapping("/get") @Operation(summary = "获取文件预览地址", description = "根据 fileId 返回文件预览 url(kkfile)") - public CommonResult getPreviewUrl(@RequestParam("fileId") Long fileId) { + public CommonResult getPreviewUrl(@RequestParam("fileId") Long fileId, + @RequestParam(value = "code", required = false) String code, + HttpServletRequest request) throws Exception { FileDO fileDO = fileService.getActiveFileById(fileId); if (fileDO == null) { return CommonResult.error(HttpStatus.NOT_FOUND.value(), "文件不存在"); @@ -59,6 +66,27 @@ public class FileController { // FileDO 转换为 FileRespVO FileRespVO fileRespVO = BeanUtils.toBean(fileDO, FileRespVO.class); + + // 加密文件:塞入“临时解密预览 URL” + if (Boolean.TRUE.equals(fileRespVO.getIsEncrypted())) { // FileDO 通过 aesIv 判断加密 + + if (cn.hutool.core.util.StrUtil.isBlank(code)) { + return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "加密文件预览需要验证码 code"); + } + // 验证通过:发放给 kkfile 用的短期 token(kkfile 不带登录态) + Long userId = getLoginUserId(); + boolean flag = fileService.verifyCode(fileId, userId, code); + if(!flag){ + return CommonResult.customize(null, HttpStatus.INTERNAL_SERVER_ERROR.value(), "验证码错误"); + } + + String token = fileService.generatePreviewToken(fileId, userId); + + String baseUrl = buildPublicBaseUrl(request); // 见下方函数 + String decryptUrl = baseUrl + "/admin-api/infra/file/preview-decrypt?fileId=" + fileId + "&token=" + token; + fileRespVO.setUrl(decryptUrl); + } + return success(fileRespVO); } @@ -166,15 +194,32 @@ public class FileController { } @GetMapping("/generate-download-code") @Operation(summary = "获取下载验证码") - public CommonResult preDownloadEncrypt(@RequestParam("fileId") Long fileId) { + public CommonResult preDownloadEncrypt(@RequestParam("fileId") Long fileId, + @RequestParam(value = "sendType", required = false) String sendType // 可选:SMS / E_OFFICE + ) { Long userId = getLoginUserId(); + + // 解析 sendType(允许为空) + VerifyCodeSendType sendTypeEnum = null; + if (sendType != null && !sendType.trim().isEmpty()) { + try { + sendTypeEnum = VerifyCodeSendType.valueOf(sendType.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + return CommonResult.error(HttpStatus.BAD_REQUEST.value(), + "sendType 参数不合法,可选:SMS / E_OFFICE"); + } + } + FileDO activeFileById = fileService.getActiveFileById(fileId); if (activeFileById == null) { return CommonResult.error(HttpStatus.NOT_FOUND.value(), "文件不存在"); } + FileRespVO fileRespVO = BeanUtils.toBean(activeFileById, FileRespVO.class); try { - fileService.generateFileVerificationCode(fileId, userId); + String code = fileService.generateFileVerificationCode(fileId, userId); + if(sendTypeEnum != null) + fileService.sendVerifyCode(code, sendTypeEnum); // 发送验证码 return CommonResult.customize(fileRespVO, HttpStatus.OK.value(), "验证码已生成,请使用验证码下载文件"); } catch (ServiceException e) { return CommonResult.customize(fileRespVO, HttpStatus.OK.value(), e.getMessage()); @@ -191,4 +236,53 @@ public class FileController { } return CommonResult.customize(null, HttpStatus.OK.value(), "验证码校验通过"); } + + @GetMapping("/preview-decrypt") + @PermitAll + @TenantIgnore + @Operation(summary = "加密文件预览解密流(供 kkfile 拉取)") + public void previewDecrypt(@RequestParam("fileId") Long fileId, + @RequestParam("token") String token, + HttpServletResponse response) throws Exception { + + boolean ok = fileService.verifyPreviewToken(fileId, token); + if (!ok) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + return; + } + + FileDO fileDO = fileService.getActiveFileById(fileId); + if (fileDO == null) { + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + + // byte[] content = fileService.getDecryptedBytes(fileId); + response.setHeader("Cache-Control", "no-store"); + response.setContentType(fileDO.getType()); + + String filename = java.net.URLEncoder.encode(fileDO.getName(), java.nio.charset.StandardCharsets.UTF_8); + response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + filename); + + // cn.hutool.core.io.IoUtil.write(response.getOutputStream(), true, content); + fileService.writeDecryptedToStream(fileId, response.getOutputStream()); + } + + private String buildPublicBaseUrl(HttpServletRequest request) { + if (previewBaseUrl != null && !previewBaseUrl.isBlank()) { + return previewBaseUrl.endsWith("/") + ? previewBaseUrl.substring(0, previewBaseUrl.length() - 1) + : previewBaseUrl; + } + // 兜底:从请求推断 + String scheme = request.getHeader("X-Forwarded-Proto"); + if (scheme == null) scheme = request.getScheme(); + + String host = request.getHeader("X-Forwarded-Host"); + if (host == null) host = request.getHeader("Host"); + if (host == null) host = request.getServerName() + ":" + request.getServerPort(); + + return scheme + "://" + host; + } + } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java index 6d4d12f4..77d3d443 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/file/vo/file/FileRespVO.java @@ -23,8 +23,8 @@ import java.util.Date; @Accessors(chain = true) public class FileRespVO { public String getUrl() { - // 加密附件不返回 url - if (Boolean.TRUE.equals(this.isEncrypted)) { + // 不可下载 或 加密附件不返回 url + if (Boolean.FALSE.equals(this.downloadable) || Boolean.TRUE.equals(this.isEncrypted)) { return null; } // 如果 url 已经是临时下载地址(如预签名 URL),直接返回 @@ -62,8 +62,8 @@ public class FileRespVO { private String previewUrl; public String getPreviewUrl() { - // 加密附件不返回 previewUrl - if (Boolean.TRUE.equals(this.isEncrypted)) { + // 不可下载不返回 previewUrl + if (Boolean.FALSE.equals(this.downloadable) ) { return null; } // 仅当 url 不为空时生成 @@ -75,7 +75,15 @@ public class FileRespVO { if (onlinePreview == null || onlinePreview.isEmpty()) { return null; } - String presignedUrl = this.getUrl(); + // 添加加密文件预览逻辑 + String presignedUrl = null; + if (Boolean.TRUE.equals(this.isEncrypted)) { + if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { + presignedUrl = url; + } + }else{ + presignedUrl = this.getUrl(); + } if (presignedUrl == null || presignedUrl.isEmpty()) { return null; } @@ -102,4 +110,6 @@ public class FileRespVO { @Schema(description = "下载次数") private Integer downloadCount; + @Schema(description = "是否可下载(true是,false否)") + private Boolean downloadable; } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java index c82c0e3c..d1be1f72 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/dataobject/file/FileDO.java @@ -70,6 +70,11 @@ public class FileDO extends BaseDO { */ private Integer downloadCount; + /** + * 是否可下载(true是,false否) + */ + private Boolean downloadable; + /** * 是否加密 *

diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java index 8ffde8c1..dcaf93ff 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/dal/redis/RedisKeyConstants.java @@ -6,4 +6,7 @@ package com.zt.plat.module.infra.dal.redis; public class RedisKeyConstants { public static final String FILE_VERIFICATION_CODE = "infra:file:verification_code:%d:%d"; public static final String FILE_VERIFICATION_CODE_USER_SET = "infra:file:verification_code:user:%d"; + + // 加密文件预览token + public static final String FILE_PREVIEW_TOKEN = "infra:file:preview-token:%s"; } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java index 352e5313..b978f66b 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/framework/rpc/config/RpcConfiguration.java @@ -2,11 +2,12 @@ package com.zt.plat.module.infra.framework.rpc.config; import com.zt.plat.module.system.api.permission.PermissionApi; import com.zt.plat.module.system.api.permission.RoleApi; +import com.zt.plat.module.system.api.sms.SmsSendApi; import com.zt.plat.module.system.api.user.AdminUserApi; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Configuration; @Configuration(value = "infraRpcConfiguration", proxyBeanMethods = false) -@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class}) +@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class, SmsSendApi.class }) public class RpcConfiguration { } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java index 71e1fdc9..52d959b1 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileService.java @@ -1,5 +1,6 @@ package com.zt.plat.module.infra.service.file; +import com.zt.plat.framework.common.enums.VerifyCodeSendType; import com.zt.plat.framework.common.pojo.PageResult; import com.zt.plat.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePageReqVO; @@ -10,6 +11,8 @@ import com.zt.plat.module.infra.dal.dataobject.file.FileDO; import jakarta.validation.constraints.NotEmpty; import lombok.SneakyThrows; +import java.io.OutputStream; + /** * 文件 Service 接口 * @@ -72,6 +75,14 @@ public interface FileService { */ String generateFileVerificationCode(Long fileId, Long userId); + + /** + * 发送验证码 + * @param code 验证码 + * @param verifyCodeSendType 发送类型 + */ + void sendVerifyCode(String code, VerifyCodeSendType verifyCodeSendType); + /** * 校验验证码并返回解密后的文件内容 */ @@ -125,4 +136,25 @@ public interface FileService { * @param fileId */ void incDownloadCount(Long fileId); + + /** + * 临时生成文件预览token + * @param fileId 文件ID + * @param userId 用户ID + * @return 临时token + */ + String generatePreviewToken(Long fileId, Long userId); + + /** + * 验证文件预览token + * @param fileId 文件ID + * @param token 用户ID + * @return 临时token + */ + boolean verifyPreviewToken(Long fileId, String token); + + /** + * 校验预览 token 后,将文件内容解密并写入输出流(用于预览) + */ + void writeDecryptedToStream(Long fileId, OutputStream outputStream) throws Exception; } diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java index 66346820..6a7b4801 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/service/file/FileServiceImpl.java @@ -2,13 +2,17 @@ package com.zt.plat.module.infra.service.file; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.google.common.annotations.VisibleForTesting; +import com.zt.plat.framework.common.enums.VerifyCodeSendType; import com.zt.plat.framework.common.pojo.PageResult; import com.zt.plat.framework.common.util.object.BeanUtils; +import com.zt.plat.framework.security.core.LoginUser; +import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils; import com.zt.plat.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePageReqVO; import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; @@ -21,6 +25,9 @@ import com.zt.plat.module.infra.framework.file.core.client.FileClient; import com.zt.plat.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; import com.zt.plat.module.infra.framework.file.core.utils.FileTypeUtils; import com.zt.plat.module.infra.util.VerificationCodeUtil; +import com.zt.plat.module.system.api.permission.RoleApi; +import com.zt.plat.module.system.api.sms.SmsSendApi; +import com.zt.plat.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO; import jakarta.annotation.Resource; import lombok.SneakyThrows; import org.apache.commons.lang3.StringUtils; @@ -31,10 +38,9 @@ import org.springframework.stereotype.Service; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; +import java.io.OutputStream; import java.security.SecureRandom; -import java.util.Base64; -import java.util.Collections; -import java.util.List; +import java.util.*; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -51,8 +57,17 @@ public class FileServiceImpl implements FileService { @Value("${zt.AES.key}") private String aesKey; - @Value("${zt.verify-code:}") + @Value("${zt.verify-code:666666}") private String fixedVerifyCode; + // 加密文件预览token过期时间 + @Value("${zt.file.preview-expire-seconds:300}") + private Integer previewExpireSeconds; + + @Resource + private RoleApi roleApi; + + @Resource + private SmsSendApi smsSendApi; @Resource private StringRedisTemplate stringRedisTemplate; @@ -65,6 +80,31 @@ public class FileServiceImpl implements FileService { return VerificationCodeUtil.generateCode(codeKey, userSetKey, stringRedisTemplate); } + @Override + public void sendVerifyCode(String code, VerifyCodeSendType verifyCodeSendType) { + if(verifyCodeSendType == null) return; + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + Assert.notNull(loginUser,"用户未登录或权限不足!"); + if(loginUser == null) return; + if (VerifyCodeSendType.SMS.equals(verifyCodeSendType)) { + Map templateParams = new HashMap<>(); + templateParams.put("code",code); + SmsSendSingleToUserReqDTO smsSendReqDTO = new SmsSendSingleToUserReqDTO(); + if(loginUser.getInfo().get(LoginUser.INFO_KEY_PHONE)!=null) + smsSendReqDTO.setMobile(loginUser.getInfo().get(LoginUser.INFO_KEY_PHONE)); + smsSendReqDTO.setUserId(loginUser.getId()); + smsSendReqDTO.setTemplateCode("test_02"); + smsSendReqDTO.setTemplateParams(templateParams); + smsSendApi.sendSingleSmsToAdmin(smsSendReqDTO); + return; + } + + if (VerifyCodeSendType.E_OFFICE.equals(verifyCodeSendType)) { + // TODO 预留实现接口 + return; + } + } + @Override public byte[] verifyCodeAndGetFile(Long fileId, Long userId, String code) throws Exception { // 开发模式下,验证码直接获取配置进行比对 @@ -349,4 +389,36 @@ public class FileServiceImpl implements FileService { fileMapper.incDownloadCount(fileId); } + @Override + public String generatePreviewToken(Long fileId, Long userId) { + // 你也可以加:validateFileExists(fileId) + String token = UUID.randomUUID().toString().replace("-", ""); + String key = String.format(RedisKeyConstants.FILE_PREVIEW_TOKEN, token); + stringRedisTemplate.opsForValue().set(key, String.valueOf(fileId), + java.time.Duration.ofSeconds(previewExpireSeconds)); + return token; + } + + @Override + public boolean verifyPreviewToken(Long fileId, String token) { + String key = String.format(RedisKeyConstants.FILE_PREVIEW_TOKEN, token); + String val = stringRedisTemplate.opsForValue().get(key); + if (val == null || !val.equals(String.valueOf(fileId))) { + return false; + } + // 可选:单次使用更安全 + // stringRedisTemplate.delete(key); + return true; + } + + @Override + public void writeDecryptedToStream(Long fileId, OutputStream os) throws Exception { + FileDO fileDO = getActiveFileById(fileId); + if (fileDO == null) { + throw exception(FILE_NOT_EXISTS); + } + + byte[] decrypted = getDecryptedBytes(fileId); + IoUtil.write(os, true, decrypted); + } }