Merge remote-tracking branch 'ztcloud/test' into dev

This commit is contained in:
yangchaojin
2026-01-28 19:00:36 +08:00
43 changed files with 1524 additions and 89 deletions

View File

@@ -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 作为唯一业务标识(发起返回、作废入参、日志查询、回调与重试均基于 requestIdworkflowId 仅用于指定 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. 使用注册返回的密钥申请 tokenapply-tokentoken 会被按 `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 / maxAttemptssystem 模块消费结果并更新日志,失败且未超限时由 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 / maxAttemptssystem 模块消费结果并更新日志,失败且未超限时由 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`

View File

@@ -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-typeMD5/SHA256``encryption-typeAES/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` | 新建/更新 APIPUT 更新) |
| | 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` 中查阅更底层的架构说明,再结合本文步骤完成落地。祝使用顺利!

View File

@@ -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);

View File

@@ -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 '是否删除';

View File

@@ -0,0 +1,9 @@
package com.zt.plat.framework.common.enums;
/**
* 验证码发送方式
*/
public enum VerifyCodeSendType {
SMS, // 短信验证码
E_OFFICE // e办消息推送
}

View File

@@ -21,4 +21,6 @@ public class FileCreateReqDTO {
@NotEmpty(message = "文件内容不能为空")
private byte[] content;
@Schema(description = "是否可下载true是false否", example = "true")
private Boolean downloadable;
}

View File

@@ -37,4 +37,7 @@ public class FileRespDTO {
@Schema(description = "文件下载次数")
private Integer downloadCount;
@Schema(description = "是否可下载true是false否")
private Boolean downloadable;
}

View File

@@ -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 返回文件预览 urlkkfile")
public CommonResult<FileRespVO> getPreviewUrl(@RequestParam("fileId") Long fileId) {
public CommonResult<FileRespVO> 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 用的短期 tokenkkfile 不带登录态)
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<FileRespVO> preDownloadEncrypt(@RequestParam("fileId") Long fileId) {
public CommonResult<FileRespVO> 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;
}
}

View File

@@ -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;
}

View File

@@ -70,6 +70,11 @@ public class FileDO extends BaseDO {
*/
private Integer downloadCount;
/**
* 是否可下载true是false否
*/
private Boolean downloadable;
/**
* 是否加密
* <p>

View File

@@ -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";
}

View File

@@ -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 {
}

View File

@@ -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;
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<com.zt.plat.framework.common.pojo.PageResult<IWorkCallbackLogRespVO>> pageLogs(@Valid @RequestBody IWorkCallbackLogPageReqVO reqVO) {
com.zt.plat.framework.common.pojo.PageResult<com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO> page = callbackLogService.page(reqVO);
java.util.List<IWorkCallbackLogRespVO> 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<Boolean> retry(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) {
callbackLogService.resetAndDispatch(reqVO.getRequestId());
return success(true);
}
// ----------------- 人力组织接口 -----------------
@PostMapping("/hr/subcompany/page")

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<IWorkSealLogDO> {
default IWorkSealLogDO selectByRequestId(String requestId) {
return selectOne(IWorkSealLogDO::getRequestId, requestId);
}
default PageResult<IWorkSealLogDO> selectPage(IWorkCallbackLogPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<IWorkSealLogDO>()
.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));
}
}

View File

@@ -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 {
/**

View File

@@ -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<IWorkBizCallbackResultMessage>, 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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<IWorkSealLogDO> page(IWorkCallbackLogPageReqVO reqVO);
void resetAndDispatch(String requestId);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<IWorkSealLogDO>()
.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<IWorkSealLogDO>()
.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<IWorkSealLogDO> 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;
}
}

View File

@@ -182,7 +182,7 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
}
AtomicReference<Long> 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<FileRespDTO> 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;

View File

@@ -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<IWorkSealLogDO> 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<IWorkSealLogDO> captor = ArgumentCaptor.forClass(IWorkSealLogDO.class);
verify(mapper).updateById(captor.capture());
assertThat(captor.getValue().getRetryCount()).isEqualTo(2);
}
}

View File

@@ -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, "虚拟化表格不存在");

View File

@@ -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<IWorkOperationRespDTO> 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<IWorkOperationRespDTO> 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<DemoContractRespVO> 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<PageResult<DemoContractRespVO>> getDemoContractPage(@Valid DemoContractPageReqVO pageReqVO) {
CommonResult<FileRespDTO> file = fileApi.getFile(1968928810422521857L);

View File

@@ -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;
}

View File

@@ -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<String, Object> extraParams;
@Schema(description = "额外 Form 数据")
private Map<String, String> formExtras;
@Schema(description = "配置的 iWork 凭证 appId为空时使用默认凭证")
private String appId;
@Schema(description = "iWork 操作人用户编号")
private String operatorUserId;
@Schema(description = "是否强制刷新 token")
private Boolean forceRefreshToken;
}

View File

@@ -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 {
}

View File

@@ -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);
}

View File

@@ -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<IWorkOperationRespDTO> 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<IWorkOperationRespDTO> 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<IWorkOperationRespDTO> 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();
}
}

18
模块功能清单.md Normal file
View File

@@ -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 数据库。