Merge remote-tracking branch 'base-version/main' into dev

# Conflicts:
#	zt-dependencies/pom.xml
This commit is contained in:
chenbowen
2025-11-20 18:35:03 +08:00
27 changed files with 2040 additions and 180 deletions

340
docs/iWork集成说明.md Normal file
View File

@@ -0,0 +1,340 @@
# iWork 统一集成使用说明
本文档介绍如何在 System 模块中使用项目已实现的统一 iWork 流程发起能力controller + service + properties。内容包含配置项、调用方式内部 Java 调用 & 外部 HTTP 调用)、请求/响应示例、错误处理、缓存与 Token 生命周期、典型问题与排查步骤。
---
## 概览
项目在 `system` 模块下实现了一套对外统一的 iWork 集成能力:
- 提供管理端接口REST路径前缀`/system/integration/iwork`
- 提供 Service 层 `IWorkIntegrationService`,供其它模块以 Spring Bean 注入方式直接调用。
- 使用 `IWorkProperties` 绑定 `application.yml``iwork` 的配置项。
- Token / 会话采用本地 Caffeine 缓存缓存(按 appId + operatorUserId 缓存 session并在到期前按配置提前刷新。
- 使用统一配置的 appId、公钥以及默认流程编号无需再维护多套凭证。
---
## 配置YAML
`application.yml`(或 profile添加或修改如下项示例摘自 `zt-server/src/main/resources/application.yaml`
```yaml
iwork:
base-url: https://iwork.example.com
app-id: my-iwork-app # 固定使用的 iWork 应用编号
client-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... # 与 iWork 约定的客户端公钥Base64
user-id: system # 默认操作用户(当调用未指定 operatorUserId 时使用)
org:
token-seed: 5936562a-d47c-4a29-9b74-b310e6c971b7
paths:
subcompany-page: /api/hrm/resful/getHrmsubcompanyWithPage
department-page: /api/hrm/resful/getHrmdepartmentWithPage
job-title-page: /api/hrm/resful/getJobtitleInfoWithPage
user-page: /api/hrm/resful/getHrmUserInfoWithPage
sync-subcompany: /api/hrm/resful/synSubcompany
sync-department: /api/hrm/resful/synDepartment
sync-job-title: /api/hrm/resful/synJobtitle
sync-user: /api/hrm/resful/synHrmresource
workflow-id: 54 # 当调用方未传 workflowId 时使用的默认流程编号
paths:
register: /api/ec/dev/auth/regist
apply-token: /api/ec/dev/auth/applytoken
user-info: /api/workflow/paService/getUserInfo
create-workflow: /api/workflow/paService/doCreateRequest
void-workflow: /api/workflow/paService/doCancelRequest
token:
ttl-seconds: 3600 # token 有效期(秒)
refresh-ahead-seconds: 60 # 在到期前多少秒认为需要刷新
client:
connect-timeout: 5s
response-timeout: 30s
```
说明:
- `base-url` 为 iWork 网关的基础地址,不能留空。
- `app-id``client-public-key` 共同构成注册/申请 token 所需的凭据信息,由配置统一提供,不再支持多套切换。
- `workflow-id` 提供全局默认流程编号,单次调用也可通过 `workflowId` 覆盖。
- 请求头键名固定为 `app-id``client-public-key``secret``token``time``user-id`,无需在配置中重复声明。
- `org.*` 配置负责 iWork 人力组织 REST 代理:`token-seed` 为与 iWork 约定的标识,系统会自动将其与毫秒时间戳拼接并计算 MD5 生成 `key`,无需额外传递 token。
---
## 典型调用路径Controller
Controller 暴露的 REST 接口:
- POST /system/integration/iwork/user/resolve
- 说明:根据外部识别信息查找 iWork 的用户编号userId
- 请求:见下方 `Resolve User` 示例。
- POST /system/integration/iwork/workflow/create
- 说明:在 iWork 中发起流程。
- 请求:见下方 `Create Workflow` 示例。
- POST /system/integration/iwork/workflow/void
- 说明:作废/干预流程。
- 请求:见下方 `Void Workflow` 示例。
这些接口的响应均使用项目的 `CommonResult` 封装,实际返回的业务对象在 `data` 字段。
---
### 人力组织 REST 接口key + ts
为对接 PDF 所述的人力组织 RESTFUL 接口Controller 额外暴露了以下代理端点,用于通过 `key + ts` 生成的 token 与 iWork 交互:
- POST `/system/integration/iwork/hr/subcompany/page` —— 请求体传入 `params`Map对应 `getHrmsubcompanyWithPage`
- POST `/system/integration/iwork/hr/department/page` —— 对应 `getHrmdepartmentWithPage`
- POST `/system/integration/iwork/hr/job-title/page` —— 对应 `getJobtitleInfoWithPage`
- POST `/system/integration/iwork/hr/user/page` —— 对应 `getHrmUserInfoWithPage`
- POST `/system/integration/iwork/hr/subcompany/sync` —— 请求体传入 `data`List<Map>),对应 `synSubcompany`
- POST `/system/integration/iwork/hr/department/sync` —— 对应 `synDepartment`
- POST `/system/integration/iwork/hr/job-title/sync` —— 对应 `synJobtitle`
- POST `/system/integration/iwork/hr/user/sync` —— 对应 `synHrmresource`
所有请求均自动封装为 `application/x-www-form-urlencoded`,并把 `token` 字段设置为 `{"key":"<md5>","ts":"<timestamp>"}`,无需调用方重复计算。
---
## 请求 VO 说明(重要字段)
- IWorkBaseReqVO公用字段
- `appId` (String):为兼容历史接口保留,系统始终使用配置项 `iwork.app-id`
- `operatorUserId` (String):在 iWork 内部代表操作人的用户编号(可为空,框架会使用 `properties.userId`)。
- `forceRefreshToken` (Boolean):是否强制刷新 token例如遇到 token 错误时强制刷新)。
- IWorkUserInfoReqVO用于解析用户
- `identifierKey` (String):外部标识 key必须例如 "loginid")。
- `identifierValue` (String):外部标识值(必须,例如用户名)。
- `payload` (Map):额外的请求载荷,会与 identifier 合并后发送到 iWork。
- `queryParams` (Map):如果需要通过查询参数传递额外信息,可使用此字段。
- IWorkUserInfoRespVO解析用户响应
- `userId` (String):从 iWork 响应中解析出的用户编号(如果能解析到)。
- `payload` / `rawBody`:原始返回信息。
- `success` / `message`:调用成功标志与提示信息。
- IWorkWorkflowCreateReqVO发起流程
- `requestName` (String):流程标题。
- `workflowId` (Long):流程模板 ID可选缺省时使用配置的默认值
- `mainFields` (`List<IWorkFormFieldVO>`):主表字段集合。
- `detailTables` (`List<IWorkDetailTableVO>`):明细表集合(可选)。
- `otherParams` / `formExtras`:额外参数,`formExtras` 会以 form-data 方式追加。
- IWorkWorkflowVoidReqVO作废
- `requestId` (String):流程请求编号(必填)。
- `reason``extraParams``formExtras` 等用于传递作废原因或额外字段。
- IWorkFormFieldVO表单字段
- `fieldName` (String):字段名(必填),与 iWork 表单字段 key 对应。
- `fieldValue` (String):字段值(必填)。
- IWorkDetailRecordVO明细记录
- `recordOrder` (Integer):可选记录序号(从 0 开始),用于 iWork 明细排序。
- `fields` (List&lt;IWorkFormFieldVO&gt;):该明细行下的字段集合(必填)。
- IWorkDetailTableVO明细表
- `tableDBName` (String)iWork 明细表表名(必填,如 `formtable_main_26_dt1`)。
- `records` (List&lt;IWorkDetailRecordVO&gt;):明细记录集合(必填)。
---
## Java内部调用示例
项目同时提供 `IWorkIntegrationService` Bean可直接注入并调用
```java
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailRecordVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailTableVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkFormFieldVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
@RequiredArgsConstructor
@Component
public class MyService {
private final IWorkIntegrationService iworkService;
public void startFlow() {
IWorkWorkflowCreateReqVO req = new IWorkWorkflowCreateReqVO();
// 使用 application.yml 中配置的 app-id无需额外指定
req.setRequestName("测试-创建流程");
// 若需要覆盖配置的默认流程,可显式设置 workflowId
// req.setWorkflowId(54L);
// 主表字段
IWorkFormFieldVO nameField = new IWorkFormFieldVO();
nameField.setFieldName("name");
nameField.setFieldValue("张三");
IWorkFormFieldVO amountField = new IWorkFormFieldVO();
amountField.setFieldName("amount");
amountField.setFieldValue("1000");
req.setMainFields(List.of(nameField, amountField));
// 明细表(可选)
IWorkFormFieldVO detailField = new IWorkFormFieldVO();
detailField.setFieldName("itemName");
detailField.setFieldValue("办公用品");
IWorkDetailRecordVO record = new IWorkDetailRecordVO();
record.setRecordOrder(0);
record.setFields(List.of(detailField));
IWorkDetailTableVO detailTable = new IWorkDetailTableVO();
detailTable.setTableDBName("formtable_main_26_dt1");
detailTable.setRecords(List.of(record));
req.setDetailTables(List.of(detailTable));
IWorkOperationRespVO resp = iworkService.createWorkflow(req);
if (resp.isSuccess()) {
// 处理成功,例如记录 requestId
} else {
// 日志或重试
}
}
}
```
说明:
- 若需使用特定凭证,可设置 `req.setAppId("my-iwork-app")`
- 若需覆盖默认流程模板,可调用 `req.setWorkflowId(123L)` 指定。
- 若希望以特定 iWork 操作人发起,可设置 `req.setOperatorUserId("1001")`
---
## HTTP外部调用示例cURL
1. Resolve user
```bash
curl -X POST \
-H "Content-Type: application/json" \
-d '{
"appId":"my-iwork-app",
"identifierKey":"loginid",
"identifierValue":"zhangsan"
}' \
https://your-zt-server/admin-api/system/integration/iwork/user/resolve
```
成功返回示例CommonResult 包装):
```json
{
"code": 0,
"msg": "success",
"data": {
"userId": "1001",
"success": true,
"payload": { ... },
"rawBody": "{...}"
}
}
```
1. Create workflow
```bash
curl -X POST -H "Content-Type: application/json" -d '{
"requestName":"测试REST创建流程",
"workflowId":54,
"mainFields":[{"fieldName":"name","fieldValue":"张三"}],
"appId":"my-iwork-app"
}' https://your-zt-server/admin-api/system/integration/iwork/workflow/create
```
1. Void workflow
```bash
curl -X POST -H "Content-Type: application/json" -d '{
"requestId":"REQ-001",
"reason":"作废原因",
"appId":"my-iwork-app"
}' https://your-zt-server/admin-api/system/integration/iwork/workflow/void
```
---
## 核心逻辑与细节
1. 基础参数解析
系统始终使用 `application.yml` 中配置的 `app-id``client-public-key` 与 iWork 通信。
请求体中的 `appId` 字段仅为兼容历史调用而保留,框架内部不会使用该值做切换。
1. Workflow 模板解析
调用时优先使用请求体中的 `workflowId`
若未显式传入,则回退到全局 `iwork.workflow-id`,若仍为空则抛出 `IWORK_WORKFLOW_ID_MISSING`
1. 注册 + RSA + Token
- 在首次或 token 过期时,会按以下步骤获取 session
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`
- 当 token 接近到期(`refresh-ahead-seconds`)时会在下一次请求触发刷新。
1. 请求构造
- JSON 请求使用 `application/json`,表单请求(如创建流程/作废)使用 `application/x-www-form-urlencoded`
- 认证 Header`IWorkProperties.Headers` 中的常量控制,固定键名为 `app-id``client-public-key``secret``token``time``user-id`
1. 响应解析
- 实现里对响应成功的判定比较宽松:检查 `code``status``success``errno` 等字段(支持布尔、字符串 0/1/success以判断是否成功并解析常见的 message 字段 `msg|message|errmsg`
---
## 常见错误与排查
- baseUrl 未配置IWORK_BASE_URL_MISSING
- 处理:确保 `iwork.base-url` 配置正确。
- 配置缺失IWORK_CONFIGURATION_INVALID
- 场景:`app-id``client-public-key``user-id` 等关键字段没有配置或只包含空白字符。
- 处理:在 `application.yml` 或配置中心中补充对应字段,确保它们与 iWork 侧一致。
- 流程编号缺失IWORK_WORKFLOW_ID_MISSING
- 场景:请求体、凭证与全局配置均未提供流程模板编号。
- 处理:在请求中指定 `workflowId`,或在配置中设置 `workflow-id` / 凭证级 `default-workflow-id`
- RSA 加密/注册/申请 token 失败IWORK_REGISTER_FAILED / IWORK_APPLY_TOKEN_FAILED / IWORK_REMOTE_REQUEST_FAILED
- 处理:通过日志查看 iWork 返回的 HTTP 状态码与 body确认请求头/路径/参数是否匹配 iWork 网关要求。
- 用户解析失败
- 确认 `identifierKey`/`identifierValue` 是否正确填写并与 iWork 的查询接口契合;可启用 `forceRefreshToken` 触发 session 刷新以排除 token 过期造成的问题。
---
## 进阶主题
- 并发与缓存
- `sessionCache` 最大条目数为 256若高并发/多凭证/多操作人场景,可能需调整容量。
- 超时与 HTTP 客户端
- `IWorkProperties.client.response-timeout` 可用于设定响应超时;连接超时通常由 Reactor Netty 全局配置控制。
- 单元测试
- 项目中已有 MockWebServer 测试样例(`IWorkIntegrationServiceImplTest`),可参考测试用例模拟 iWork 的注册、申请 token、用户查询、创建/作废流程的交互。
---
## 小结与建议
- 在配置中补齐 `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失败。
文档已生成并保存到:`docs/iWork集成说明.md`

View File

View File

@@ -0,0 +1,124 @@
# 分页汇总功能使用说明
本文档介绍如何在平台项目中启用分页接口的汇总行SUM统计能力。该能力基于 `PageResult` 返回体与 `@PageSum` 标注,在分页查询时自动计算并返回指定字段的合计值。
## 适用场景
- 需要在分页列表底部展示金额、数量等合计值。
- 希望后端自动补充汇总信息,避免前端手动累加。
- 已使用 `BaseMapperX` 及其 `selectPage` 等分页便捷方法。
## 功能概览
| 组件 | 位置 | 作用 |
| --- | --- | --- |
| `@PageSum` | `com.zt.plat.framework.common.annotation.PageSum` | 标注需要参与 SUM 聚合的实体字段 |
| `PageResult.summary` | `com.zt.plat.framework.common.pojo.PageResult` | 承载字段 -> `BigDecimal` 的汇总结果 |
| `PageSumSupport` | `com.zt.plat.framework.mybatis.core.sum.PageSumSupport` | 负责扫描注解、克隆查询条件并执行 SUM 查询 |
| `BaseMapperX.selectPage` | `com.zt.plat.framework.mybatis.core.mapper.BaseMapperX` | 在分页与非分页查询后自动附加汇总信息 |
## 接入步骤
### 1. 在实体上标注 `@PageSum`
```java
@TableName("order_summary")
public class OrderSummaryDO {
private Long id;
@PageSum
private BigDecimal amount;
@PageSum(column = "tax_amount")
private BigDecimal tax;
@PageSum(column = "discount")
private BigDecimal discountSummary;
// 其它字段 ...
}
```
- 不传 `column` 时,默认使用 MyBatis-Plus 实体字段映射的数据库列。
- 如需跨表或函数(例如 `sum(price * quantity)`),可在 `column` 中直接写 SQL 片段。
- 必须是数值类型(`Number``BigDecimal`、原生数值)。非数值字段会被忽略并打印警告日志。
- 对于并不存在于表中的“汇总专用字段”,仅需在 `@PageSum` 中声明 `exist = false`,框架会自动注入等效的 `@TableField(exist = false)`,无需再次编写 `@TableField` 注解。
### 2. 使用 `BaseMapperX` 的分页能力
```java
PageResult<OrderSummaryRespVO> page = orderSummaryMapper.selectPage(pageParam, wrapper);
```
-`BaseMapperX.selectPage`(含排序参数版本)支持自动附加汇总结果。
- 对于 `PageParam.PAGE_SIZE_NONE`(不分页)场景同样有效。
- `selectJoinPage` 暂未附加汇总信息,如需支持请二次封装。
> ⚠️ 目前的汇总增强依赖 MyBatis-Plus 默认分页(单表/简单条件)实现聚合。若需在复杂联表或高度自定义 SQL 中进行统计,请单独编写汇总接口,或在自定义逻辑中手工调用 `PageSumSupport.tryAttachSummary(...)`,避免影响现有查询语句。
### 3. 暴露响应结果
`PageResult` 现在包含两个与数量相关的属性:
- `total`:分页总数量,仍通过 `total` 字段返回(向后兼容 `totalCount` 反序列化)。
- `summary`Map 结构,键为实体字段名,值为 `BigDecimal` 类型的合计值。
示例响应:
```json
{
"code": 0,
"data": {
"list": [
{ "id": 1, "amount": 20.00, "tax": 1.20 },
{ "id": 2, "amount": 30.00, "tax": 1.80 }
],
"total": 2,
"summary": {
"amount": 50.00,
"tax": 3.00
}
}
}
```
前端即可直接读取 `data.summary.amount` 展示汇总行,无需手工聚合。
## 常见问题
### 汇总结果为空
- 检查实体字段是否正确标注 `@PageSum`,且类型为数值。
- 确认 Mapper 的泛型实体与查询结果实体一致,`PageSumSupport` 会基于 Mapper 泛型解析实体类型。
- 若查询条件覆盖了 `select` 列表(如显式调用 `select(...)`),请确保 SUM 语句仍能执行;`PageSumSupport` 会克隆 Wrapper 并重新设置 `select` 列表,手写 SQL 需保证兼容。
### 自定义 SQL & 复杂场景
- 对于需要复杂汇总(如 CASE WHEN可在 `column` 属性中写 SQL 表达式:
```java
@PageSum(column = "SUM(CASE WHEN status = 'PAID' THEN amount ELSE 0 END)")
private BigDecimal paidAmount;
```
- 当前实现 **仅会扫描 Mapper 泛型实体类** 上的 `@PageSum` 标注。若分页接口最终返回 VO请先在实体上完成标注再使用 `PageResult.convert(...)` 或其它方式将数据转换为 VO转换后 `summary` 内容会被完整保留。
### 非 BaseMapperX 查询
- 目前自动聚合只对 `BaseMapperX.selectPage` 及分页列表查询有效。
- 若使用 XML 自定义 SQL可在逻辑中手动调用 `PageSumSupport.tryAttachSummary(mapper, wrapper, pageResult)`。
## 调试与测试
- 单元测试示例:`com.zt.plat.framework.mybatis.core.sum.PageSumSupportTest`。
- 运行 `mvn -pl zt-framework/zt-spring-boot-starter-mybatis -am test` 可验证功能和回归。
- 日志中会输出数字解析或字段配置异常的告警信息,便于定位问题。
## 变更兼容性
- `PageResult` 仍通过 `list`/`total` 提供原有分页数据,向后兼容旧接口。
- 新增 `summary` 字段,前端可按需展示。
- `totalCount` Setter / Getter 仍保留(`@JsonIgnore`),可兼容旧代码逻辑。
如需进一步扩展(例如 AVG、MAX 等聚合),可按现有结构在 `PageSumSupport` 基础上新增注解与聚合逻辑。

194
docs/外部单点登录.md Normal file
View File

@@ -0,0 +1,194 @@
# 外部单点登录External SSO接入说明
## 功能概述
- 支持外部系统携带一次性 token 跳转本系统,实现免密单点登录。
- 去除了历史实现中的 payload 解密、nonce 强校验、自动建号与邮箱匹配等逻辑,所有账号均需在本地预先存在并保持映射关系。
- 前后端新增 `/system/sso/verify` 校验能力,返回标准的 `AuthLoginRespVO` 令牌信息,并记录审计与登录日志。
- 通过 `ExternalSsoClient` 抽象外部接口调用,可按需自定义实现或复用默认 HTTP 客户端封装。
## 关键组件
### 后端
- `ExternalSsoServiceImpl`:单点登录主流程,实现参数校验、外部用户查询、本地账号匹配、令牌签发与日志记录。
- `ExternalSsoStrategy`:定义按来源系统拆分的策略接口,不同系统可实现自定义的拉取与匹配逻辑。
- `DefaultExternalSsoStrategy`:默认策略实现,复用配置化的 HTTP 客户端与匹配顺序,可按优先级被自定义策略覆盖。
- `ExternalSsoClient`:获取外部用户信息的接口抽象。
- `DefaultExternalSsoClient`:基于 `RestTemplate` 的默认实现,支持 Header/Query/Body 占位符渲染、重试、响应字段映射、代理配置。
- `ExternalSsoClientConfiguration`:通过 `@Configuration` 在缺省情况下注册 `DefaultExternalSsoClient` Bean允许业务自定义覆盖。
- `ExternalSsoProperties``external-sso.*` 配置项,包含开关、外部接口、账号映射、跨域等子配置;示例配置已同步到 `zt-module-system/zt-module-system-server/src/main/resources/application.yaml``zt-server/src/main/resources/application.yaml`
- `ExternalSsoVerifyReqVO``POST /system/sso/verify` 请求载荷。
- `ExternalSsoUserInfo`:外部用户标准化模型,包含基础字段与自定义属性集合。
### 前端
- `src/router/modules/remaining.ts`:新增隐藏路由 `/externalsso`,用于回调页面。
- `src/views/Login/ExternalSsoCallback.vue`:处理 URL 参数、调用校验接口、落地令牌、跳转目标页面,异常时提示并引导返回登录页。
- `src/api/login/index.ts`:新增 `externalSsoVerify` 方法,请求 `/system/sso/verify` 并返回 `TokenType`
## 调用流程
1. 外部系统完成本地认证后,构造 URL`{本系统域名}/#/externalsso?x-token={外部token}&target={回跳地址}` 并跳转,可选附带 `sourceSystem` 指定来源系统。
2. Vue 页面 `ExternalSsoCallback` 解析查询参数,优先读取 `x-token`(兼容历史的 `token` 参数)并校验是否存在;缺失时终止流程并提示错误。
3. 前端调用 `POST /system/sso/verify`,请求体:
```json
{
"token": "外部系统颁发的 token",
"targetUri": "/#/dashboard", // 可选
"sourceSystem": "partner-a" // 可选
}
```
4. `ExternalSsoServiceImpl#verifyToken` 执行以下步骤:
- 校验功能开关与 token 有效性。
- 基于 `sourceSystem` 选择匹配的 `ExternalSsoStrategy`,若无匹配直接返回来源系统不支持。
- 通过策略触发 `ExternalSsoClient` 拉取外部用户信息(默认调用配置的 HTTP 接口)。
- 按策略内定义的匹配顺序(默认外部 ID → 用户名 → 手机号)查找本地账号,未命中直接返回 "未找到匹配的本地用户"。
- 校验账号状态是否启用,签发 OAuth2 访问令牌并记录登录日志(类型 `LOGIN_EXTERNAL_SSO`)。
- 生成操作审计日志,记录外部响应摘要、映射账号、目标地址、来源系统等信息。
5. 前端拿到 `AuthLoginRespVO`,通过 `authUtil.setToken`/`setTenantId` 持久化,随后跳转到整理后的 `targetUri`(默认 `/`)。
6. 当流程出现异常(例如 token 缺失、外部接口失败、账号不存在)时,后端返回对应错误码,前端弹出提示并清除本地缓存。
下图为时序示意:
```text
外部系统 -> 浏览器 -> Vue /externalsso -> POST /system/sso/verify -> ExternalSsoService -> ExternalSsoClient -> 外部用户接口
\-> OAuth2TokenService -> 登录日志/审计日志
```
## 前端细节
- `ExternalSsoCallback.vue` 对 `target`/`targetUri`/`redirect` 参数做统一解码与归一化,支持携带绝对地址或 hash 路由,防止开放跳转漏洞。
- 解析 URL 中的 `sourceSystem`(兼容 `source`、`systemCode`)并透传给后端,便于在多来源系统场景下选择策略。
- 在成功回调后调用 `router.replace`,保证不会产生历史记录;失败时引导用户回到 `/login` 并附带原目标地址。
- 通过 `buildErrorMessage` 兼容后端返回的 `msg`、`Error` 对象或字符串,统一展示错误提示。
## 后端流程拆解
- **开关与参数校验**
- 关闭开关或缺少 token 时抛出 `EXTERNAL_SSO_DISABLED` / `EXTERNAL_SSO_TOKEN_MISSING`。
- **外部用户获取**
- 默认客户端会在 `external-sso.remote` 中加载请求配置,支持 GET/POST 等多种场景。
- 占位符:`${externalUserId}`(初始值为 token、`${shareToken}`/`${token}`(共享服务访问 token、`${xToken}`(原始回调 token、`${targetUri}`、`${sourceSystem}`。
- 会自动通过 `ShareServiceUtils` 获取共享服务访问 token并写入 `ShareServiceProperties#tokenHeaderName` 对应的请求头。
- 请求体会以 JSON 形式发送 `{ "x-token": "回调参数中的 token" }`,满足上游接口 `S_BF_CS_01` 的要求。
- `validateResponse` 可按 `codeField` 与 `successCode` 校验业务状态,失败时抛出带详细信息的 `ExternalSsoClientException`。
- **本地账号匹配**
- 使用 `mapping.order` 控制字段优先级;`custom.entries` 保存 "外部ID → 本地用户ID" 静态映射。
- 找不到用户或账号禁用时分别抛出 `EXTERNAL_SSO_USER_NOT_FOUND`、`EXTERNAL_SSO_USER_DISABLED`,均会同步写入登录日志。
- **令牌签发与日志**
- 通过 `OAuth2TokenService#createAccessToken` 使用默认客户端 `CLIENT_ID_DEFAULT` 颁发本地访问令牌。
- `recordAuditLog` 把原始响应的 SHA-256 摘要、外部属性、token 摘要等写入操作日志,便于排查。
- `recordLoginLog` 记录登录行为并在成功时更新用户最后登录 IP。
## `ExternalSsoClient` 扩展
- 默认实现 `DefaultExternalSsoClient` 由 `ExternalSsoClientConfiguration` 自动注册,若需要接入其它协议,可在任意配置类中自定义:
```java
@Bean
public ExternalSsoClient customExternalSsoClient(...) {
return new MyExternalSsoClient(...);
}
```
- 默认实现要点:
- 按配置构造 `RestTemplate`,支持连接/读取超时、HTTP 代理、重试次数。
- 解析 JSON 响应,将指定字段映射到 `ExternalSsoUserInfo`,并保留原始 data 节点到 `attributes`。
- 在解析失败、状态码异常时抛出带原始响应的 `ExternalSsoClientException`。
## 配置项参考
```yaml
external-sso:
enabled: true
system-code: example-partner
token:
secret: "shared-secret"
algorithm: AES
allowed-clock-skew-seconds: 60
max-age-seconds: 300
require-nonce: false
replay-protection-enabled: false
remote:
base-url: http://10.1.7.110
user-info-path: /api/sso/user
method: POST
headers:
Authorization: "Bearer ${token}"
query-params: {}
body:
userId: "${externalUserId}"
code-field: code
success-code: "0"
message-field: message
data-field: data
user-id-field: data.userId
username-field: data.username
nickname-field: data.nickname
email-field: data.email
mobile-field: data.mobile
tenant-id-field: data.tenantId
connect-timeout-millis: 3000
read-timeout-millis: 5000
retry-count: 1
proxy:
enabled: false
mapping:
order:
- EXTERNAL_ID
- USERNAME
- MOBILE
ignore-case: true
update-profile-on-login: false
custom:
entries:
partnerUser001: 10001
cors:
allowed-origins:
- https://partner.example.com
allowed-methods: ["OPTIONS", "POST"]
allowed-headers: ["Authorization", "Content-Type"]
allow-credentials: true
max-age: 1800
```
| 配置路径 | 说明 |
| --- | --- |
| `enabled` | 总开关,关闭后接口直接返回 `EXTERNAL_SSO_DISABLED` |
| `system-code` | 默认来源系统标识,可作为 `sourceSystem` 的缺省值及日志标签 |
| `token.*` | 若仍需解密/校验外部 token可在自定义 `ExternalSsoClient` 内按需使用;默认实现仅透传 |
| `remote.*` | 外部接口 HTTP 调用参数、字段映射与超时控制,模板占位符支持 `externalUserId`、`shareToken`(`token`)、`xToken`、`targetUri`、`sourceSystem` |
| `mapping.order` | 本地账号匹配优先级,支持 `EXTERNAL_ID`、`USERNAME`、`MOBILE` |
| `mapping.custom.entries` | 外部用户标识到本地用户 ID 的静态映射表 |
| `cors.*` | 用于开放 `/system/sso/verify` 的跨域访问白名单 |
## 错误码与日志
- 错误码:
- `1_002_000_050`:功能未开启。
- `1_002_000_051`token 缺失。
- `1_002_000_055`:外部接口异常,具体原因写入占位符。
- `1_002_000_056`:未匹配到本地用户。
- `1_002_000_057`:本地用户已禁用。
- `1_002_000_058`:来源系统不支持,需配置匹配的策略实现。
- 登录日志:使用 `LoginLogTypeEnum.LOGIN_EXTERNAL_SSO` 记录成功/失败。
- 操作日志:类型 `EXTERNAL_SSO/VERIFY`,包含外部用户 ID、映射账号、目标地址、来源系统、响应摘要等元数据。
## 注意事项
- 所有账号须提前维护映射,系统不会自动创建或按邮箱兜底匹配用户。
- `targetUri` 会在前端归一化,避免开放跳转风险;无合法目标时默认跳转首页。
- 确保外部接口返回的 JSON 字段与配置保持一致,必要时可通过 `remote.data-field` 指向具体节点。
- 若外部接口速度较慢或易失败,可提高 `retry-count`、超时时间或自定义客户端实现。
- 如需记录更多审计信息,可在 `ExternalSsoUserInfo#addAttribute` 注入自定义字段,审计日志会自动保留。
## 扩展与测试建议
- 通过提供新的 `ExternalSsoStrategy` 或 `ExternalSsoClient` Bean可扩展不同来源系统的对接方式。
- 建议为主要错误场景编写集成测试token 缺失、映射缺失、来源系统不支持、外部接口超时、账号被禁用等。
- 外部系统回调前可先调用 `/system/sso/verify` 联调接口验证配置是否正确,再接入正式流程。

View File

@@ -0,0 +1,205 @@
# Databus 模块 API 功能与第三方调用说明
> 适用范围:`zt-module-databus`Server 侧)+ `zt-module-databus-api`(接口定义)。本文基于 2025-11-20 主干分支代码。
## 1. 模块定位与整体能力
- **目标**:对外暴露统一的数据/业务编排网关,允许后台在可视化界面中配置 API、步骤、变换与限流策略并即时发布到运行态。
- **核心特性**
1. API 全生命周期管理(定义、版本、回滚、发布缓存刷新)。
2. 编排引擎基于 Spring Integration 动态装配,支持 Start/HTTP/RPC/Script/End 步骤及 JSON 变换链路。
3. 多重安全防护IP 白/黑名单、应用凭证、时间戳 + 随机串、报文加解密、签名、防重放、租户隔离、匿名固定用户等。
4. QoS 能力可插拔限流策略Redis 固定窗口计数)、审计日志、追踪 ID & Step 级结果入库。
5. Debug 支持:管理端 `POST /databus/gateway/invoke` 可注入任意参数模拟真实调用。
## 2. 运行时架构概览
| 组件 | 位置 | 作用 |
| --- | --- | --- |
| `GatewaySecurityFilter` | `framework.integration.gateway.security` | 过滤并校验所有落在 `databus.api-portal.base-path` 之下的 HTTP 请求,完成 IP 校验、报文解密、签名、防重放、匿名用户注入、响应加密。 |
| `ApiGatewayExecutionService` | `framework.integration.gateway.core` | 将 HTTP 请求映射为 `ApiInvocationContext`,调度 Integration Flow构造统一响应。 |
| `IntegrationFlowManager` | `framework.integration.gateway.core` | 按 `apiCode + version` 动态注册 Spring Integration Flow支持热刷新与调试临时 Flow。 |
| `ApiFlowDispatcher` | 同上 | 依据 apiCode/version 找到输入通道,发送请求并等待 `ApiInvocationContext` 回传。 |
| `PolicyAdvisorFactory` + `DefaultRateLimitPolicyEvaluator` | `framework.integration.gateway.core/policy` | 在 Flow 上织入限流等策略,当前默认实现支持 Redis 固定窗口。 |
| `ApiGatewayAccessLogger` | `framework.integration.gateway.core` | 生成访问日志 `databus_api_access_log`,记录 Trace、请求/响应、耗时、步骤结果等。 |
| 管控 REST 控制器 | `controller.admin.gateway.*` | 管理 API 定义、版本、凭证、策略、访问日志等。 |
## 4. 管控端 REST 接口速查
| 模块 | 方法 | 路径 | 说明 |
| --- | --- | --- | --- |
| API 定义 | GET | `/databus/gateway/definition/page` | 分页查询(支持 code/描述筛选)。 |
| | GET | `/databus/gateway/definition/{id}` | 详情(含步骤、变换、限流绑定)。 |
| | POST | `/databus/gateway/definition` | 新建定义,必填步骤(至少 Start+End。 |
| | PUT | `/databus/gateway/definition` | 更新并自动刷新对应 Flow。 |
| | DELETE | `/databus/gateway/definition/{id}` | 删除并注销 Flow。 |
| API 网关 | POST | `/databus/gateway/invoke` | 管理端调试调用。 |
| | GET | `/databus/gateway/definitions` | 拉取当前已上线定义(供灰度/网关缓存)。 |
| | POST | `/databus/gateway/cache/refresh` | 强制刷新所有 Flow 缓存。 |
| API 版本 | GET | `/databus/gateway/version/get?id=` | 查询版本详情(自动还原 snapshotData。 |
| | GET | `/databus/gateway/version/page` | 分页。 |
| | GET | `/databus/gateway/version/list?apiId=` | 列出某 API 的全部版本。 |
| | PUT | `/databus/gateway/version/rollback` | 根据 `id + remark` 回滚。 |
| | GET | `/databus/gateway/version/compare` | 差异对比sourceId/targetId。 |
| 客户端凭证 | GET | `/databus/gateway/credential/page` | 分页。 |
| | GET | `/databus/gateway/credential/get?id=` | 详情(含匿名配置)。 |
| | POST | `/databus/gateway/credential/create` | 新增凭证。 |
| | PUT | `/databus/gateway/credential/update` | 更新。 |
| | DELETE | `/databus/gateway/credential/delete?id=` | 删除。 |
| | GET | `/databus/gateway/credential/list-simple` | 下拉使用。 |
| 限流策略 | GET | `/databus/gateway/policy/rate-limit/page` | 分页检索。 |
| | GET | `/databus/gateway/policy/rate-limit/{id}` | 详情。 |
| | GET | `/databus/gateway/policy/rate-limit/simple-list` | 精简列表。 |
| | POST/PUT/DELETE | `/databus/gateway/policy/rate-limit` | 新增/更新/删除。 |
| 访问日志 | GET | `/databus/gateway/access-log/page` | 分页(需 `databus:gateway:access-log:query` 权限)。 |
| | GET | `/databus/gateway/access-log/get?id=` | 单条详情(自动补充 API 描述)。 |
> 所有接口默认返回 `CommonResult` 包装,字段 `code/message/data`。必要时参考对应 VO位置 `controller.admin.gateway.vo`)。
## 5. API 生命周期管理要点
1. **状态机**`ApiStatusEnum`(草稿/已上线/已下线/已废弃。Integration Flow 只加载 `ONLINE` 状态定义。
2. **版本快照**:每次保存时写入 `databus_api_version`,可通过 `snapshotData` 一键恢复(`rollback` 接口)。
3. **变换校验**:保存时会校验同一级 `TransformPhaseEnum` 不可重复,并确保 Start/End 唯一且位于首尾。
4. **缓存刷新**
- 单 API创建/更新/删除后自动调用 `IntegrationFlowManager.refresh(apiCode, version)`
- 全量:管理员可调用 `/databus/gateway/cache/refresh` 做兜底。
## 6. 网关请求路径与响应格式
- **默认 Base Path**`/admin-api/databus/api/portal`(可通过 `databus.api-portal.base-path` 覆盖;兼容旧版 `/databus/api/portal`)。
- **最终路径**`{basePath}/{apiCode}/{version}`,示例 `/admin-api/databus/api/portal/order.create/v1`
- **支持方法**GET/POST/PUT/DELETE/PATCH均被映射为 `ApiInvocationContext.httpMethod`
- **响应包装**
```json
{
"code": 200,
"message": "OK",
"response": { "bizField": "value" },
"traceId": "c8a3d52f-..."
}
```
> `code` 与 HTTP 状态保持一致;`response` 为 API 变换后的业务体;所有错误也沿用该 Envelope若启用响应加密则返回 Base64 字串)。
## 7. 配置项(`application.yml`)重点
```yaml
databus:
api-portal:
base-path: /admin-api/databus/api/portal
allowed-ips: [10.0.0.0/24] # 可为空表示全放行
denied-ips: []
enable-tenant-header: true
tenant-header: ZT-Tenant-Id
enable-audit: true
enable-rate-limit: true
security:
enabled: true
signature-type: MD5 # 或 SHA256
encryption-type: AES # 或 DES
allowed-clock-skew-seconds: 300
nonce-ttl-seconds: 600
require-body-encryption: true
encrypt-response: true
```
> `GatewaySecurityFilter` 会自动注册到最高优先级 +10确保该路径的请求先经过安全校验。
## 8. 第三方调用流程详解
### 8.1 前置准备
1. **申请凭证**:在后台创建 `API 客户端凭证`,得到:
- `appId`(对应 `ZT-App-Id` 头)
- `encryptionKey`(用于 AES/DES 对称加密,服务器使用 `CryptoSignatureUtils.decrypt` 解密)
- `encryptionType``signatureType`
- `allowAnonymous` = true 时需选择一个固定系统用户(服务器将自动颁发内部 JWT
2. **确定 API**:记录 `apiCode``version`、请求方法、入参/变换契约。
3. **网络白名单**:将第三方出口 IP 加入 `allowed-ips`,否则直接返回 403。
4. **Redis 要求**:需保证 Redis 可用(用于 nonce、防重放、限流计数
### 8.2 请求构建步骤
| 序号 | 操作 | 说明 |
| --- | --- | --- |
| 1 | 生成时间戳 | `timestamp = System.currentTimeMillis()`,与服务器时间差 ≤ 300s。 |
| 2 | 生成随机串 | `nonce` 长度≥8可使用 `UUID.randomUUID().toString().replace("-", "")`。 |
| 3 | 准备明文 Body | 例如 `{"orderNo":"SO20251120001"}`,记为 `plainBody`。 |
| 4 | 计算签名 | 将所有签名字段放入 Map详见下节调用 `CryptoSignatureUtils.verifySignature` 同样的规则:对 key 排序、跳过 `signature` 字段、使用 `&` 连接 `key=value`,再用 `MD5/SHA256` 计算;结果赋值给 `ZT-Signature`。*注意:签名使用明文 body。* |
| 5 | 加密请求体 | 使用凭证的 `encryptionKey + encryptionType``plainBody` 进行对称加密Base64 结果作为 HTTP BodyContent-Type 可设 `text/plain``application/json`。 |
| 6 | 组装请求头 | `ZT-App-Id`, `ZT-Timestamp`, `ZT-Nonce`, `ZT-Signature`, `ZT-Tenant-Id`(可选), `X-Client-Id`(建议,与限流相关),如有自带 JWT 则设置 `Authorization`。 |
| 7 | 发送请求 | URL = `https://{host}{basePath}/{apiCode}/{version}`,方法与 API 定义保持一致。 |
#### 签名字段示例
```
appId=demo-app
&body={"orderNo":"SO20251120001"}
&nonce=0c5e2df9a1
&timestamp=1732070400000
```
- Query 参数将被拼接为 `key=value`(多值以逗号连接),自动忽略 `signature` 字段。
- Request Body 若非 JSON则退化为字符串整体签名。
#### cURL 示例
```bash
curl -X POST "https://gw.example.com/admin-api/databus/api/portal/order.create/v1" \
-H "ZT-App-Id: demo-app" \
-H "ZT-Timestamp: 1732070400000" \
-H "ZT-Nonce: 0c5e2df9a1" \
-H "ZT-Signature: 8e377..." \
-H "X-Client-Id: mall" \
-H "Content-Type: text/plain" \
-d "Q2hhcnNldGV4dC1CYXNlNjQgZW5jcnlwdGVkIGJvZHk="
```
> `-d` 的实际内容应当是 AES/ DES 加密后的 Base64 字符串。
### 8.3 响应处理
1. 读取 HTTP 状态与 `ApiGatewayResponse.code/message/traceId`
2.`security.encrypt-response=true`,则响应体本身是加密串,需要使用同一 `encryptionKey/encryptionType` 解密得到 JSON再解析 `response` 字段。
3. `traceId` 可用于后台日志及 `访问日志` 页面关联排查。
### 8.4 错误与重试策略
| 场景 | 表现 | 处理建议 |
| --- | --- | --- |
| 时间戳/Nonce 不合法 | HTTP 401`message` = `请求到达时间超出 300s`/`重复请求` | 校准服务器时间;`nonce` 不可重复Redis TTL 默认 600s。 |
| 签名失败 | HTTP 401`message` = `签名校验失败` | 检查签名字符串、字符编码、大小写。 |
| 未配置密钥 | HTTP 500`message` = `应用未配置加密密钥` | 在后台凭证中补齐密钥与算法,或取消强制加密。 |
| 限流触发 | HTTP 429`message` = `请求触发限流策略` | 调整 `X-Client-Id` 级并发或增大策略 `limit/windowSeconds`。 |
| API 未发布 | HTTP 404`message` = `API 定义未发布或已下线` | 确认 `status=ONLINE`,并刷新缓存。 |
## 9. 限流策略配置
- 存储在 `ApiPolicyRateLimitDO.config`JSON 结构示例:
```json
{
"limit": 1000,
"windowSeconds": 60,
"keyTemplate": "${apiCode}:${tenantId}:${header.X-Client-Id}" // 预留扩展
}
```
- 当前默认实现读取 `limit`(默认 100`windowSeconds`(默认 60
- Redis Key 格式:`databus:api:rl:{apiCode}:{version}:{X-Client-Id}`,当计数首次出现时自动设置过期。
- 限流拦截后会抛出 `API_RATE_LIMIT_EXCEEDED`,在访问日志中标记 `status=1/2`
## 10. 访问日志字段对照
| 字段 | 说明 |
| --- | --- |
| `traceId` | 来自 `TracerUtils`,可在日志与链路追踪中搜索。 |
| `requestHeaders`, `requestBody`, `responseBody` | 默认截断至 4000 字符JSON 序列化存储。 |
| `status` | 0=成功,1=客户端错误,2=服务端错误,3=未知。 |
| `stepResults` | 序列化的步骤执行列表(见 `ApiStepResult`),含 `request/response/elapsed/error`。 |
| `extra` | 附加变量/属性,供排查自定义上下文。 |
> 可通过 `/databus/gateway/access-log/page` + `traceId` 或 `apiCode` 条件快速定位第三方问题。

View File

@@ -26,14 +26,11 @@
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<properties>
<revision>3.0.43</revision>
<flatten-maven-plugin.version>1.6.0</flatten-maven-plugin.version>
<!-- 统一依赖管理 -->
<spring.boot.version>3.4.5</spring.boot.version>
<spring.cloud.version>2024.0.1</spring.cloud.version>
<spring.cloud.alibaba.version>2023.0.3.2</spring.cloud.alibaba.version>
<!-- 分布式事务相关 -->
<seata.version>2.4.0</seata.version>
<!-- Web 相关 -->
<springdoc.version>2.8.3</springdoc.version>
<knife4j.version>4.6.0</knife4j.version>
@@ -88,8 +85,6 @@
<netty.version>4.1.116.Final</netty.version>
<mqtt.version>1.2.5</mqtt.version>
<pf4j-spring.version>0.9.0</pf4j-spring.version>
<docx4j.version>11.4.7</docx4j.version>
<docx4j-jaxb.version>11.4.7</docx4j-jaxb.version>
<!-- 规则引擎 -->
<liteflow.version>2.15.1</liteflow.version>
<vertx.version>4.5.13</vertx.version>
@@ -135,20 +130,6 @@
<scope>import</scope>
</dependency>
<!-- 分布式事务Seata -->
<!-- 显式覆盖 Spring Cloud Alibaba BOM 中的 Seata 1.8.0,升级到 2.4.0 以支持达梦数据库 -->
<!-- 注意Seata 2.2.0+ 改为使用 org.apache.seata groupId -->
<dependency>
<groupId>org.apache.seata</groupId>
<artifactId>seata-all</artifactId>
<version>${seata.version}</version>
</dependency>
<dependency>
<groupId>org.apache.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
<!-- 业务组件 -->
<dependency>
<groupId>io.github.mouzt</groupId>
@@ -488,6 +469,12 @@
<version>${podam.version}</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp3.version}</version>
</dependency>
<!-- 工作流相关 -->
<dependency>
<groupId>org.flowable</groupId>
@@ -724,17 +711,6 @@
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>${mqtt.version}</version>
</dependency>
<!-- docx4j - Word文档处理 -->
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-JAXB-ReferenceImpl</artifactId>
<version>${docx4j-jaxb.version}</version>
</dependency>
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-core</artifactId>
<version>${docx4j.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -67,10 +67,10 @@
<artifactId>zt-spring-boot-starter-redis</artifactId>
</dependency>
<!-- Reactive HTTP client for iWork integration -->
<!-- HTTP client for iWork integration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<dependency>

View File

@@ -1,12 +1,19 @@
package com.zt.plat.module.system.controller.admin.integration.iwork;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDepartmentQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkJobTitleQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgSyncReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkSubcompanyQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
import com.zt.plat.module.system.service.integration.iwork.IWorkOrgRestService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -30,6 +37,7 @@ import static com.zt.plat.framework.common.pojo.CommonResult.success;
public class IWorkIntegrationController {
private final IWorkIntegrationService integrationService;
private final IWorkOrgRestService orgRestService;
@PostMapping("/user/resolve")
@Operation(summary = "根据外部标识获取 iWork 用户编号")
@@ -48,4 +56,54 @@ public class IWorkIntegrationController {
public CommonResult<IWorkOperationRespVO> voidWorkflow(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) {
return success(integrationService.voidWorkflow(reqVO));
}
// ----------------- 人力组织接口 -----------------
@PostMapping("/hr/subcompany/page")
@Operation(summary = "获取 iWork 分部列表")
public CommonResult<IWorkOrgRespVO> listSubcompanies(@Valid @RequestBody IWorkSubcompanyQueryReqVO reqVO) {
return success(orgRestService.listSubcompanies(reqVO));
}
@PostMapping("/hr/department/page")
@Operation(summary = "获取 iWork 部门列表")
public CommonResult<IWorkOrgRespVO> listDepartments(@Valid @RequestBody IWorkDepartmentQueryReqVO reqVO) {
return success(orgRestService.listDepartments(reqVO));
}
@PostMapping("/hr/job-title/page")
@Operation(summary = "获取 iWork 岗位列表")
public CommonResult<IWorkOrgRespVO> listJobTitles(@Valid @RequestBody IWorkJobTitleQueryReqVO reqVO) {
return success(orgRestService.listJobTitles(reqVO));
}
@PostMapping("/hr/user/page")
@Operation(summary = "获取 iWork 人员列表")
public CommonResult<IWorkOrgRespVO> listUsers(@Valid @RequestBody IWorkUserQueryReqVO reqVO) {
return success(orgRestService.listUsers(reqVO));
}
@PostMapping("/hr/subcompany/sync")
@Operation(summary = "同步分部信息至 iWork")
public CommonResult<IWorkOrgRespVO> syncSubcompanies(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) {
return success(orgRestService.syncSubcompanies(reqVO));
}
@PostMapping("/hr/department/sync")
@Operation(summary = "同步部门信息至 iWork")
public CommonResult<IWorkOrgRespVO> syncDepartments(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) {
return success(orgRestService.syncDepartments(reqVO));
}
@PostMapping("/hr/job-title/sync")
@Operation(summary = "同步岗位信息至 iWork")
public CommonResult<IWorkOrgRespVO> syncJobTitles(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) {
return success(orgRestService.syncJobTitles(reqVO));
}
@PostMapping("/hr/user/sync")
@Operation(summary = "同步人员信息至 iWork")
public CommonResult<IWorkOrgRespVO> syncUsers(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) {
return success(orgRestService.syncUsers(reqVO));
}
}

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 lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* iWork 部门查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class IWorkDepartmentQueryReqVO extends IWorkOrgBaseQueryReqVO {
@Schema(description = "部门编码")
private String departmentCode;
@Schema(description = "部门名称")
private String departmentName;
@Schema(description = "所属分部ID")
private String subcompanyId;
}

View File

@@ -0,0 +1,21 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* iWork 岗位查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class IWorkJobTitleQueryReqVO extends IWorkOrgBaseQueryReqVO {
@Schema(description = "岗位编码")
private String jobTitleCode;
@Schema(description = "岗位名称")
private String jobTitleName;
}

View File

@@ -0,0 +1,22 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
/**
* iWork 组织查询基础参数。
*/
@Data
public class IWorkOrgBaseQueryReqVO {
@Schema(description = "当前页码", example = "1")
private Integer curpage;
@Schema(description = "每页条数", example = "10")
private Integer pagesize;
@Schema(description = "查询参数(扩展用),将被序列化为 params 传给 iWork")
private Map<String, Object> params;
}

View File

@@ -0,0 +1,73 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.Map;
/**
* 查询 iWork 人力组织信息所需的参数。
*/
@Data
public class IWorkOrgQueryReqVO {
@Schema(description = "当前页码", example = "1")
private Integer curpage;
@Schema(description = "每页条数", example = "10")
private Integer pagesize;
// ================= 分部查询 =================
@Schema(description = "分部编码")
private String subcompanyCode;
@Schema(description = "分部名称")
private String subcompanyName;
// ================= 部门查询 =================
@Schema(description = "部门编码")
private String departmentCode;
@Schema(description = "部门名称")
private String departmentName;
@Schema(description = "所属分部ID")
private String subcompanyId;
// ================= 岗位查询 =================
@Schema(description = "岗位编码")
private String jobTitleCode;
@Schema(description = "岗位名称")
private String jobTitleName;
// ================= 人员查询 =================
@Schema(description = "人员工号")
private String workCode;
@Schema(description = "人员姓名")
private String lastName;
@Schema(description = "所属部门ID")
private String departmentId;
@Schema(description = "所属岗位ID")
private String jobTitleId;
@Schema(description = "人员状态 (0:试用, 1:正式, 2:临时, 3:试用延期, 4:解聘, 5:离职, 6:退休, 7:无效)")
private String status;
@Schema(description = "手机号")
private String mobile;
@Schema(description = "邮箱")
private String email;
@Schema(description = "查询参数(扩展用),将被序列化为 params 传给 iWork")
private Map<String, Object> params;
}

View File

@@ -0,0 +1,28 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
/**
* 对 iWork 人力组织 REST 请求的响应封装。
*/
@Data
public class IWorkOrgRespVO {
@Schema(description = "响应中的业务数据data 字段或整体映射)")
private Map<String, Object> payload;
@Schema(description = "原始响应字符串")
private String rawBody;
@Schema(description = "是否判断为成功")
private boolean success;
@Schema(description = "提示信息")
private String message;
@Schema(description = "响应码")
private String code;
}

View File

@@ -0,0 +1,19 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 同步 iWork 人力组织信息的请求。
*/
@Data
public class IWorkOrgSyncReqVO {
@Schema(description = "同步数据集合,将被序列化为 data 传给 iWork", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "同步数据不能为空")
private List<Map<String, Object>> data;
}

View File

@@ -0,0 +1,21 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* iWork 分部查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class IWorkSubcompanyQueryReqVO extends IWorkOrgBaseQueryReqVO {
@Schema(description = "分部编码")
private String subcompanyCode;
@Schema(description = "分部名称")
private String subcompanyName;
}

View File

@@ -0,0 +1,39 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* iWork 人员查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class IWorkUserQueryReqVO extends IWorkOrgBaseQueryReqVO {
@Schema(description = "人员工号")
private String workCode;
@Schema(description = "人员姓名")
private String lastName;
@Schema(description = "所属部门ID")
private String departmentId;
@Schema(description = "所属分部ID")
private String subcompanyId;
@Schema(description = "所属岗位ID")
private String jobTitleId;
@Schema(description = "人员状态 (0:试用, 1:正式, 2:临时, 3:试用延期, 4:解聘, 5:离职, 6:退休, 7:无效)")
private String status;
@Schema(description = "手机号")
private String mobile;
@Schema(description = "邮箱")
private String email;
}

View File

@@ -19,11 +19,6 @@ import java.time.Duration;
@ConfigurationProperties(prefix = "iwork")
public class IWorkProperties {
/**
* 是否开启 iWork 集成能力。
*/
private boolean enabled = false;
/**
* iWork 网关的基础地址。
*/
@@ -56,6 +51,8 @@ public class IWorkProperties {
private final Token token = new Token();
@Valid
private final Client client = new Client();
@Valid
private final OrgRest org = new OrgRest();
@Data
public static class Paths {
@@ -123,4 +120,26 @@ public class IWorkProperties {
@NotNull(message = "iWork 客户端响应超时时间不能为空")
private Duration responseTimeout;
}
@Data
public static class OrgRest {
/**
* 认证所需的标识(与 iWork 约定)。
*/
private String tokenSeed;
@Valid
private final OrgPaths paths = new OrgPaths();
}
@Data
public static class OrgPaths {
private String subcompanyPage;
private String departmentPage;
private String jobTitlePage;
private String userPage;
private String syncSubcompany;
private String syncDepartment;
private String syncJobTitle;
private String syncUser;
}
}

View File

@@ -7,15 +7,15 @@ import com.zt.plat.framework.common.exception.ErrorCode;
*/
public interface IWorkIntegrationErrorCodeConstants {
ErrorCode IWORK_DISABLED = new ErrorCode(1_010_200_001, "iWork 集成未启用,请先完成配置");
ErrorCode IWORK_BASE_URL_MISSING = new ErrorCode(1_010_200_002, "iWork 集成未配置网关地址");
ErrorCode IWORK_CONFIGURATION_INVALID = new ErrorCode(1_010_200_003,
ErrorCode IWORK_BASE_URL_MISSING = new ErrorCode(1_010_200_001, "iWork 集成未配置网关地址");
ErrorCode IWORK_CONFIGURATION_INVALID = new ErrorCode(1_010_200_002,
"iWork 集成缺少必填配置appId/clientPublicKey/userId/workflowId");
ErrorCode IWORK_REGISTER_FAILED = new ErrorCode(1_010_200_004, "iWork 注册授权失败");
ErrorCode IWORK_APPLY_TOKEN_FAILED = new ErrorCode(1_010_200_005, "iWork 令牌申请失败");
ErrorCode IWORK_REMOTE_REQUEST_FAILED = new ErrorCode(1_010_200_006, "iWork 接口请求失败");
ErrorCode IWORK_USER_IDENTIFIER_MISSING = new ErrorCode(1_010_200_007, "缺少用户识别信息,无法调用 iWork 接口");
ErrorCode IWORK_OPERATOR_USER_MISSING = new ErrorCode(1_010_200_008, "缺少 iWork 操作人用户编号");
ErrorCode IWORK_WORKFLOW_ID_MISSING = new ErrorCode(1_010_200_009, "缺少 iWork 流程模板编号");
ErrorCode IWORK_REGISTER_FAILED = new ErrorCode(1_010_200_003, "iWork 注册授权失败");
ErrorCode IWORK_APPLY_TOKEN_FAILED = new ErrorCode(1_010_200_004, "iWork 令牌申请失败");
ErrorCode IWORK_REMOTE_REQUEST_FAILED = new ErrorCode(1_010_200_005, "iWork 接口请求失败");
ErrorCode IWORK_USER_IDENTIFIER_MISSING = new ErrorCode(1_010_200_006, "缺少用户识别信息,无法调用 iWork 接口");
ErrorCode IWORK_OPERATOR_USER_MISSING = new ErrorCode(1_010_200_007, "缺少 iWork 操作人用户编号");
ErrorCode IWORK_WORKFLOW_ID_MISSING = new ErrorCode(1_010_200_008, "缺少 iWork 流程模板编号");
ErrorCode IWORK_ORG_IDENTIFIER_MISSING = new ErrorCode(1_010_200_009, "iWork 人力组织接口缺少认证标识");
ErrorCode IWORK_ORG_REMOTE_FAILED = new ErrorCode(1_010_200_010, "iWork 人力组织接口请求失败");
}

View File

@@ -0,0 +1,30 @@
package com.zt.plat.module.system.service.integration.iwork;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDepartmentQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkJobTitleQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgSyncReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkSubcompanyQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserQueryReqVO;
/**
* iWork 人力组织 REST 接口门面。
*/
public interface IWorkOrgRestService {
IWorkOrgRespVO listSubcompanies(IWorkSubcompanyQueryReqVO reqVO);
IWorkOrgRespVO listDepartments(IWorkDepartmentQueryReqVO reqVO);
IWorkOrgRespVO listJobTitles(IWorkJobTitleQueryReqVO reqVO);
IWorkOrgRespVO listUsers(IWorkUserQueryReqVO reqVO);
IWorkOrgRespVO syncSubcompanies(IWorkOrgSyncReqVO reqVO);
IWorkOrgRespVO syncDepartments(IWorkOrgSyncReqVO reqVO);
IWorkOrgRespVO syncJobTitles(IWorkOrgSyncReqVO reqVO);
IWorkOrgRespVO syncUsers(IWorkOrgSyncReqVO reqVO);
}

View File

@@ -23,21 +23,25 @@ import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import okhttp3.FormBody;
import okhttp3.Headers;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import javax.crypto.Cipher;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
@@ -58,24 +62,24 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private static final okhttp3.MediaType JSON_MEDIA_TYPE = okhttp3.MediaType.get("application/json; charset=UTF-8");
private final IWorkProperties properties;
private final ObjectMapper objectMapper;
private final WebClient.Builder webClientBuilder;
private final Cache<SessionKey, IWorkSession> sessionCache = Caffeine.newBuilder()
.maximumSize(256)
.build();
private final Cache<String, PublicKey> publicKeyCache = Caffeine.newBuilder()
private final Cache<String, PublicKey> publicKeyCache = Caffeine.newBuilder()
.maximumSize(64)
.build();
private volatile WebClient cachedWebClient;
private volatile OkHttpClient cachedHttpClient;
@Override
public IWorkUserInfoRespVO resolveUserId(IWorkUserInfoReqVO reqVO) {
assertEnabled();
assertConfigured();
String appId = resolveAppId();
String clientPublicKey = resolveClientPublicKey();
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
@@ -90,7 +94,7 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
@Override
public IWorkOperationRespVO createWorkflow(IWorkWorkflowCreateReqVO reqVO) {
assertEnabled();
assertConfigured();
String appId = resolveAppId();
String clientPublicKey = resolveClientPublicKey();
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
@@ -104,7 +108,7 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
@Override
public IWorkOperationRespVO voidWorkflow(IWorkWorkflowVoidReqVO reqVO) {
assertEnabled();
assertConfigured();
String appId = resolveAppId();
String clientPublicKey = resolveClientPublicKey();
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
@@ -119,10 +123,7 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
return buildOperationResponse(responseBody);
}
private void assertEnabled() {
if (!properties.isEnabled()) {
throw ServiceExceptionUtil.exception(IWORK_DISABLED);
}
private void assertConfigured() {
if (!StringUtils.hasText(properties.getBaseUrl())) {
throw ServiceExceptionUtil.exception(IWORK_BASE_URL_MISSING);
}
@@ -193,25 +194,13 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
}
private RegistrationResult register(String appId, String clientPublicKey) {
String responseBody;
try {
responseBody = webClient()
.post()
.uri(properties.getPaths().getRegister())
.headers(headers -> {
headers.set(properties.getHeaders().getAppId(), appId);
headers.set(properties.getHeaders().getClientPublicKey(), clientPublicKey);
})
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] register failed. status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] register failed", ex);
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, ex.getMessage());
}
Request request = new Request.Builder()
.url(resolveUrl(properties.getPaths().getRegister()))
.header(properties.getHeaders().getAppId(), appId)
.header(properties.getHeaders().getClientPublicKey(), clientPublicKey)
.post(RequestBody.create(null, new byte[0]))
.build();
String responseBody = executeRequest(request, IWORK_REGISTER_FAILED);
JsonNode node = parseJson(responseBody, IWORK_REGISTER_FAILED);
String secret = textValue(node, "secret");
String spk = textValue(node, "spk");
@@ -222,26 +211,14 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
}
private String applyToken(String appId, String encryptedSecret) {
String responseBody;
try {
responseBody = webClient()
.post()
.uri(properties.getPaths().getApplyToken())
.headers(headers -> {
headers.set(properties.getHeaders().getAppId(), appId);
headers.set(properties.getHeaders().getSecret(), encryptedSecret);
headers.set(properties.getHeaders().getTime(), String.valueOf(properties.getToken().getTtlSeconds()));
})
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] apply token failed. status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] apply token failed", ex);
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, ex.getMessage());
}
Request request = new Request.Builder()
.url(resolveUrl(properties.getPaths().getApplyToken()))
.header(properties.getHeaders().getAppId(), appId)
.header(properties.getHeaders().getSecret(), encryptedSecret)
.header(properties.getHeaders().getTime(), String.valueOf(properties.getToken().getTtlSeconds()))
.post(RequestBody.create(null, new byte[0]))
.build();
String responseBody = executeRequest(request, IWORK_APPLY_TOKEN_FAILED);
JsonNode node = parseJson(responseBody, IWORK_APPLY_TOKEN_FAILED);
String token = textValue(node, "token");
if (!StringUtils.hasText(token)) {
@@ -255,64 +232,54 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
String appId,
IWorkSession session,
Map<String, Object> payload) {
try {
return webClient()
.post()
.uri(uriBuilder -> {
uriBuilder.path(path);
if (queryParams != null) {
queryParams.forEach((key, value) -> {
if (value != null) {
uriBuilder.queryParam(key, value);
}
});
}
return uriBuilder.build();
})
.headers(headers -> setAuthHeaders(headers, appId, session))
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload == null ? Collections.emptyMap() : payload)
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] request {} failed. status={}, body={}", path, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] request {} failed", path, ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getMessage());
HttpUrl baseUrl = HttpUrl.parse(resolveUrl(path));
if (baseUrl == null) {
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, "非法的 URL");
}
HttpUrl.Builder urlBuilder = baseUrl.newBuilder();
if (queryParams != null) {
queryParams.forEach((key, value) -> {
if (value != null) {
urlBuilder.addQueryParameter(key, String.valueOf(value));
}
});
}
String jsonPayload = toJsonString(payload == null ? Collections.emptyMap() : payload);
RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, jsonPayload);
Headers headers = authHeaders(appId, session)
.set("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.build();
Request request = new Request.Builder()
.url(urlBuilder.build())
.headers(headers)
.post(body)
.build();
return executeRequest(request, IWORK_REMOTE_REQUEST_FAILED);
}
private String executeFormRequest(String path,
String appId,
IWorkSession session,
MultiValueMap<String, String> formData) {
try {
return webClient()
.post()
.uri(path)
.headers(headers -> setAuthHeaders(headers, appId, session))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] form request {} failed. status={}, body={}", path, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] form request {} failed", path, ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getMessage());
}
FormBody.Builder builder = new FormBody.Builder();
formData.forEach((key, values) -> {
if (values != null) {
values.forEach(value -> builder.add(key, value));
}
});
Request request = new Request.Builder()
.url(resolveUrl(path))
.headers(authHeaders(appId, session).build())
.post(builder.build())
.build();
return executeRequest(request, IWORK_REMOTE_REQUEST_FAILED);
}
private void setAuthHeaders(org.springframework.http.HttpHeaders headers,
String appId,
IWorkSession session) {
headers.set(properties.getHeaders().getAppId(), appId);
headers.set(properties.getHeaders().getToken(), session.getToken());
headers.set(properties.getHeaders().getUserId(), session.getEncryptedUserId());
private Headers.Builder authHeaders(String appId, IWorkSession session) {
return new Headers.Builder()
.set(properties.getHeaders().getAppId(), appId)
.set(properties.getHeaders().getToken(), session.getToken())
.set(properties.getHeaders().getUserId(), session.getEncryptedUserId());
}
private Map<String, Object> buildUserPayload(IWorkUserInfoReqVO reqVO) {
@@ -545,38 +512,66 @@ public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
}
}
private WebClient webClient() {
WebClient client = cachedWebClient;
private OkHttpClient okHttpClient() {
OkHttpClient client = cachedHttpClient;
if (client != null) {
return client;
}
synchronized (this) {
if (cachedWebClient == null) {
cachedWebClient = buildWebClient();
if (cachedHttpClient == null) {
cachedHttpClient = buildHttpClient();
}
return cachedWebClient;
return cachedHttpClient;
}
}
private WebClient buildWebClient() {
WebClient.Builder builder = cloneBuilder();
builder.baseUrl(properties.getBaseUrl());
private OkHttpClient buildHttpClient() {
OkHttpClient.Builder builder = new OkHttpClient.Builder();
IWorkProperties.Client clientProps = properties.getClient();
if (clientProps != null) {
Duration responseTimeout = clientProps.getResponseTimeout();
if (responseTimeout != null) {
builder.filter((request, next) -> next.exchange(request).timeout(responseTimeout));
if (clientProps.getConnectTimeout() != null) {
builder.connectTimeout(clientProps.getConnectTimeout());
}
if (clientProps.getResponseTimeout() != null) {
builder.callTimeout(clientProps.getResponseTimeout());
builder.readTimeout(clientProps.getResponseTimeout());
builder.writeTimeout(clientProps.getResponseTimeout());
}
// 连接超时时间由全局的 HttpClient 自定义器统一配置(若存在)。
}
builder.retryOnConnectionFailure(true);
return builder.build();
}
private WebClient.Builder cloneBuilder() {
try {
return webClientBuilder.clone();
} catch (UnsupportedOperationException ex) {
return WebClient.builder();
private String resolveUrl(String path) {
if (StringUtils.hasText(path) && (path.startsWith("http://") || path.startsWith("https://"))) {
return path;
}
String baseUrl = properties.getBaseUrl();
if (!StringUtils.hasText(baseUrl)) {
throw ServiceExceptionUtil.exception(IWORK_BASE_URL_MISSING);
}
boolean baseEndsWithSlash = baseUrl.endsWith("/");
boolean pathStartsWithSlash = StringUtils.hasText(path) && path.startsWith("/");
if (baseEndsWithSlash && pathStartsWithSlash) {
return baseUrl + path.substring(1);
}
if (!baseEndsWithSlash && !pathStartsWithSlash) {
return baseUrl + "/" + path;
}
return baseUrl + path;
}
private String executeRequest(Request request, ErrorCode errorCode) {
try (Response response = okHttpClient().newCall(request).execute()) {
String responseBody = response.body() != null ? response.body().string() : null;
if (!response.isSuccessful()) {
log.error("[iWork] request {} failed. status={} body={}", request.url(), response.code(), responseBody);
throw ServiceExceptionUtil.exception(errorCode, response.code(), responseBody);
}
return responseBody;
} catch (IOException ex) {
log.error("[iWork] request {} failed", request.url(), ex);
throw ServiceExceptionUtil.exception(errorCode, ex.getMessage());
}
}

View File

@@ -0,0 +1,371 @@
package com.zt.plat.module.system.service.integration.iwork.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDepartmentQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkJobTitleQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgBaseQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgSyncReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkSubcompanyQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserQueryReqVO;
import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties;
import com.zt.plat.module.system.service.integration.iwork.IWorkOrgRestService;
import lombok.extern.slf4j.Slf4j;
import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import static com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationErrorCodeConstants.IWORK_BASE_URL_MISSING;
import static com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationErrorCodeConstants.IWORK_ORG_IDENTIFIER_MISSING;
import static com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationErrorCodeConstants.IWORK_ORG_REMOTE_FAILED;
/**
* 默认的人力组织 REST 代理实现。
*/
@Slf4j
@Service
public class IWorkOrgRestServiceImpl implements IWorkOrgRestService {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private final IWorkProperties properties;
private final ObjectMapper objectMapper;
private final Clock clock;
private volatile OkHttpClient cachedHttpClient;
@Autowired
public IWorkOrgRestServiceImpl(IWorkProperties properties,
ObjectMapper objectMapper) {
this(properties, objectMapper, Clock.systemUTC());
}
IWorkOrgRestServiceImpl(IWorkProperties properties,
ObjectMapper objectMapper,
Clock clock) {
this.properties = properties;
this.objectMapper = objectMapper;
this.clock = clock;
}
@Override
public IWorkOrgRespVO listSubcompanies(IWorkSubcompanyQueryReqVO reqVO) {
String path = orgPaths().getSubcompanyPage();
Map<String, Object> params = buildBaseParams(reqVO);
if (StringUtils.hasText(reqVO.getSubcompanyCode())) {
params.put("subcompanycode", reqVO.getSubcompanyCode());
}
if (StringUtils.hasText(reqVO.getSubcompanyName())) {
params.put("subcompanyname", reqVO.getSubcompanyName());
}
return invokeParamsEndpoint(path, params);
}
@Override
public IWorkOrgRespVO listDepartments(IWorkDepartmentQueryReqVO reqVO) {
String path = orgPaths().getDepartmentPage();
Map<String, Object> params = buildBaseParams(reqVO);
if (StringUtils.hasText(reqVO.getDepartmentCode())) {
params.put("departmentcode", reqVO.getDepartmentCode());
}
if (StringUtils.hasText(reqVO.getDepartmentName())) {
params.put("departmentname", reqVO.getDepartmentName());
}
if (StringUtils.hasText(reqVO.getSubcompanyId())) {
params.put("subcompanyid", reqVO.getSubcompanyId());
}
return invokeParamsEndpoint(path, params);
}
@Override
public IWorkOrgRespVO listJobTitles(IWorkJobTitleQueryReqVO reqVO) {
String path = orgPaths().getJobTitlePage();
Map<String, Object> params = buildBaseParams(reqVO);
if (StringUtils.hasText(reqVO.getJobTitleCode())) {
params.put("jobtitlecode", reqVO.getJobTitleCode());
}
if (StringUtils.hasText(reqVO.getJobTitleName())) {
params.put("jobtitlename", reqVO.getJobTitleName());
}
return invokeParamsEndpoint(path, params);
}
@Override
public IWorkOrgRespVO listUsers(IWorkUserQueryReqVO reqVO) {
String path = orgPaths().getUserPage();
Map<String, Object> params = buildBaseParams(reqVO);
if (StringUtils.hasText(reqVO.getWorkCode())) {
params.put("workcode", reqVO.getWorkCode());
}
if (StringUtils.hasText(reqVO.getLastName())) {
params.put("lastname", reqVO.getLastName());
}
if (StringUtils.hasText(reqVO.getSubcompanyId())) {
params.put("subcompanyid", reqVO.getSubcompanyId());
}
if (StringUtils.hasText(reqVO.getDepartmentId())) {
params.put("departmentid", reqVO.getDepartmentId());
}
if (StringUtils.hasText(reqVO.getJobTitleId())) {
params.put("jobtitleid", reqVO.getJobTitleId());
}
if (StringUtils.hasText(reqVO.getStatus())) {
params.put("status", reqVO.getStatus());
}
if (StringUtils.hasText(reqVO.getMobile())) {
params.put("mobile", reqVO.getMobile());
}
if (StringUtils.hasText(reqVO.getEmail())) {
params.put("email", reqVO.getEmail());
}
return invokeParamsEndpoint(path, params);
}
private Map<String, Object> buildBaseParams(IWorkOrgBaseQueryReqVO reqVO) {
Map<String, Object> params = new HashMap<>();
if (reqVO.getParams() != null) {
params.putAll(reqVO.getParams());
}
if (reqVO.getCurpage() != null) {
params.put("curpage", reqVO.getCurpage());
}
if (reqVO.getPagesize() != null) {
params.put("pagesize", reqVO.getPagesize());
}
return params;
}
@Override
public IWorkOrgRespVO syncSubcompanies(IWorkOrgSyncReqVO reqVO) {
String path = orgPaths().getSyncSubcompany();
return invokeDataEndpoint(path, reqVO.getData());
}
@Override
public IWorkOrgRespVO syncDepartments(IWorkOrgSyncReqVO reqVO) {
String path = orgPaths().getSyncDepartment();
return invokeDataEndpoint(path, reqVO.getData());
}
@Override
public IWorkOrgRespVO syncJobTitles(IWorkOrgSyncReqVO reqVO) {
String path = orgPaths().getSyncJobTitle();
return invokeDataEndpoint(path, reqVO.getData());
}
@Override
public IWorkOrgRespVO syncUsers(IWorkOrgSyncReqVO reqVO) {
String path = orgPaths().getSyncUser();
return invokeDataEndpoint(path, reqVO.getData());
}
private IWorkOrgRespVO invokeParamsEndpoint(String path, Map<String, Object> params) {
String payload = toJson(params);
return executeForm(path, "params", payload);
}
private IWorkOrgRespVO invokeDataEndpoint(String path, Object data) {
String payload = toJson(data);
return executeForm(path, "data", payload);
}
private IWorkOrgRespVO executeForm(String path, String fieldName, String jsonPayload) {
assertOrgConfigured(path);
FormBody.Builder formBuilder = new FormBody.Builder(StandardCharsets.UTF_8);
if (StringUtils.hasText(fieldName) && StringUtils.hasText(jsonPayload)) {
formBuilder.add(fieldName, jsonPayload);
}
formBuilder.add("token", buildTokenJson());
RequestBody requestBody = formBuilder.build();
Request request = new Request.Builder()
.url(resolveUrl(path))
.header("Content-Type", MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.post(requestBody)
.build();
try (Response response = okHttpClient().newCall(request).execute()) {
String responseBody = response.body() != null ? response.body().string() : null;
if (!response.isSuccessful()) {
log.error("[iWork-Org] 调用 {} 失败status={} body={}", path, response.code(), responseBody);
throw ServiceExceptionUtil.exception(IWORK_ORG_REMOTE_FAILED, response.code(), responseBody);
}
return buildResponse(responseBody);
} catch (IOException ex) {
log.error("[iWork-Org] 调用 {} 失败", path, ex);
throw ServiceExceptionUtil.exception(IWORK_ORG_REMOTE_FAILED, ex.getMessage());
}
}
private String buildTokenJson() {
String tokenSeed = StringUtils.trimWhitespace(orgConfig().getTokenSeed());
if (!StringUtils.hasText(tokenSeed)) {
throw ServiceExceptionUtil.exception(IWORK_ORG_IDENTIFIER_MISSING);
}
long ts = clock.millis();
String raw = tokenSeed + ts;
// 通过 MD5(tokenSeed + ts) 计算 key并转为大写以符合 PDF 约定
String hashed = md5Upper(raw);
Map<String, String> token = Map.of(
"key", hashed,
"ts", String.valueOf(ts)
);
return toJson(token);
}
private String md5Upper(String raw) {
byte[] bytes = raw.getBytes(StandardCharsets.UTF_8);
String hex = org.springframework.util.DigestUtils.md5DigestAsHex(bytes);
return hex.toUpperCase(Locale.ROOT);
}
private IWorkOrgRespVO buildResponse(String responseBody) {
// 统一解析 iWork 响应,兼容 data 节点和扁平结构
IWorkOrgRespVO respVO = new IWorkOrgRespVO();
respVO.setRawBody(responseBody);
if (!StringUtils.hasText(responseBody)) {
respVO.setPayload(Collections.emptyMap());
return respVO;
}
JsonNode node = parseJson(responseBody);
respVO.setCode(textValue(node, "code"));
respVO.setMessage(resolveMessage(node));
respVO.setSuccess(isSuccess(node));
JsonNode payloadNode = node.has("data") ? node.get("data") : node;
respVO.setPayload(objectMapper.convertValue(payloadNode, MAP_TYPE));
return respVO;
}
private JsonNode parseJson(String body) {
try {
return objectMapper.readTree(body);
} catch (JsonProcessingException ex) {
log.error("[iWork-Org] 无法解析 JSON 响应: {}", body, ex);
throw ServiceExceptionUtil.exception(IWORK_ORG_REMOTE_FAILED, "响应不是合法 JSON");
}
}
private String resolveMessage(JsonNode node) {
if (node == null) {
return null;
}
if (node.has("msg")) {
return node.get("msg").asText();
}
if (node.has("message")) {
return node.get("message").asText();
}
return null;
}
private boolean isSuccess(JsonNode node) {
if (node == null) {
return false;
}
if ("1".equals(textValue(node, "code"))) {
return true;
}
if ("1".equals(textValue(node, "status"))) {
return true;
}
return "1".equals(textValue(node, "success"));
}
private String textValue(JsonNode node, String field) {
return node != null && node.has(field) ? node.get(field).asText() : null;
}
private void assertOrgConfigured(String path) {
IWorkProperties.OrgRest org = orgConfig();
if (!StringUtils.hasText(properties.getBaseUrl())) {
throw ServiceExceptionUtil.exception(IWORK_BASE_URL_MISSING);
}
if (!StringUtils.hasText(path)) {
throw ServiceExceptionUtil.exception(IWORK_ORG_REMOTE_FAILED, "缺少接口路径配置");
}
}
private IWorkProperties.OrgRest orgConfig() {
return properties.getOrg();
}
private IWorkProperties.OrgPaths orgPaths() {
return orgConfig().getPaths();
}
private String toJson(Object payload) {
try {
return objectMapper.writeValueAsString(payload == null ? Collections.emptyMap() : payload);
} catch (JsonProcessingException ex) {
throw ServiceExceptionUtil.exception(IWORK_ORG_REMOTE_FAILED, "序列化 JSON 失败: " + ex.getMessage());
}
}
private OkHttpClient okHttpClient() {
OkHttpClient client = cachedHttpClient;
if (client != null) {
return client;
}
synchronized (this) {
if (cachedHttpClient == null) {
cachedHttpClient = buildHttpClient();
}
return cachedHttpClient;
}
}
private OkHttpClient buildHttpClient() {
OkHttpClient.Builder builder = new OkHttpClient.Builder();
IWorkProperties.Client clientProps = properties.getClient();
if (clientProps != null) {
if (clientProps.getConnectTimeout() != null) {
builder.connectTimeout(clientProps.getConnectTimeout());
}
if (clientProps.getResponseTimeout() != null) {
builder.callTimeout(clientProps.getResponseTimeout());
builder.readTimeout(clientProps.getResponseTimeout());
builder.writeTimeout(clientProps.getResponseTimeout());
}
}
builder.retryOnConnectionFailure(true);
return builder.build();
}
private String resolveUrl(String path) {
if (StringUtils.hasText(path) && (path.startsWith("http://") || path.startsWith("https://"))) {
return path;
}
String baseUrl = properties.getBaseUrl();
if (!StringUtils.hasText(baseUrl)) {
throw ServiceExceptionUtil.exception(IWORK_BASE_URL_MISSING);
}
boolean baseEndsWithSlash = baseUrl.endsWith("/");
boolean pathStartsWithSlash = StringUtils.hasText(path) && path.startsWith("/");
if (baseEndsWithSlash && pathStartsWithSlash) {
return baseUrl + path.substring(1);
}
if (!baseEndsWithSlash && !pathStartsWithSlash) {
return baseUrl + "/" + path;
}
return baseUrl + path;
}
}

View File

@@ -50,8 +50,8 @@ spring:
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
data:
redis:
host: nacos-redis # 地址
port: 6379 # 端口
host: 172.16.46.63 # 地址
port: 30379 # 端口
database: 1 # 数据库索引
# password: 123456 # 密码,建议生产环境开启
@@ -61,6 +61,16 @@ spring:
rocketmq:
name-server: 172.16.46.63:30876 # RocketMQ Namesrv
spring:
# RabbitMQ 配置项,对应 RabbitProperties 配置类
rabbitmq:
host: 127.0.0.1 # RabbitMQ 服务的地址
port: 5672 # RabbitMQ 服务的端口
username: guest # RabbitMQ 服务的账号
password: guest # RabbitMQ 服务的密码
# Kafka 配置项,对应 KafkaProperties 配置类
kafka:
bootstrap-servers: 127.0.0.1:9092 # 指定 Kafka Broker 地址,可以设置多个,以逗号分隔
--- #################### 定时任务相关配置 ####################
xxl:
@@ -153,3 +163,28 @@ justauth:
type: REDIS
prefix: 'social_auth_state:' # 缓存前缀,目前只对 Redis 缓存生效,默认 JUSTAUTH::STATE::
timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟
seata:
enabled: true
application-id: system-server
tx-service-group: dev_tx_group
enable-auto-data-source-proxy: true
data-source-proxy-mode: AT
registry:
type: file
config:
type: file
service:
vgroupMapping:
default_tx_group: default
dev_tx_group: default
test_tx_group: default
prod_tx_group: default
default:
grouplist: 172.16.46.63:30088
client:
tm:
defaultGlobalTransactionTimeout: 60000
undo:
logTable: undo_log
dataValidation: true
logSerialization: jackson

View File

@@ -106,7 +106,6 @@ easy-trans:
--- #################### iWork 集成配置 ####################
iwork:
enabled: true
base-url: http://172.16.36.233:8080
app-id:
client-public-key:
@@ -124,6 +123,17 @@ iwork:
client:
connect-timeout: 5s
response-timeout: 30s
org:
token-seed: 456465
paths:
subcompany-page: /api/hrm/resful/getHrmsubcompanyWithPage
department-page: /api/hrm/resful/getHrmdepartmentWithPage
job-title-page: /api/hrm/resful/getJobtitleInfoWithPage
user-page: /api/hrm/resful/getHrmUserInfoWithPage
sync-subcompany: /api/hrm/resful/synSubcompany
sync-department: /api/hrm/resful/synDepartment
sync-job-title: /api/hrm/resful/synJobtitle
sync-user: /api/hrm/resful/synHrmresource
--- #################### RPC 远程调用相关配置 ####################

View File

@@ -0,0 +1,121 @@
package com.zt.plat.module.system.service.integration.iwork.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgQueryReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOrgSyncReqVO;
import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties;
import com.zt.plat.module.system.service.integration.iwork.IWorkOrgRestService;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class IWorkOrgRestServiceImplTest {
private MockWebServer mockWebServer;
private IWorkOrgRestService service;
private IWorkProperties properties;
private Clock fixedClock;
@BeforeEach
void setUp() throws Exception {
mockWebServer = new MockWebServer();
mockWebServer.start();
properties = buildProperties();
fixedClock = Clock.fixed(Instant.ofEpochMilli(1_672_531_200_000L), ZoneOffset.UTC);
service = new IWorkOrgRestServiceImpl(properties, new ObjectMapper(), fixedClock);
}
@AfterEach
void tearDown() throws Exception {
mockWebServer.shutdown();
}
@Test
void shouldListSubcompanies() throws Exception {
mockWebServer.enqueue(jsonResponse("{\"code\":\"1\",\"data\":{\"page\":1}}"));
IWorkOrgQueryReqVO reqVO = new IWorkOrgQueryReqVO();
reqVO.setParams(Map.of("curpage", 1));
IWorkOrgRespVO respVO = service.listSubcompanies(reqVO);
assertThat(respVO.isSuccess()).isTrue();
assertThat(respVO.getPayload()).containsEntry("page", 1);
RecordedRequest request = mockWebServer.takeRequest();
assertThat(request.getPath()).isEqualTo(properties.getOrg().getPaths().getSubcompanyPage());
String decoded = URLDecoder.decode(request.getBody().readUtf8(), StandardCharsets.UTF_8);
assertThat(decoded).contains("params={\"curpage\":1}");
String tokenJson = extractField(decoded, "token");
assertThat(tokenJson).isNotBlank();
assertThat(tokenJson).contains("\"ts\":\"1672531200000\"");
String expectedKey = DigestUtils.md5DigestAsHex("test-seed1672531200000".getBytes(StandardCharsets.UTF_8)).toUpperCase();
assertThat(tokenJson).contains("\"key\":\"" + expectedKey + "\"");
}
@Test
void shouldSyncDepartments() throws Exception {
mockWebServer.enqueue(jsonResponse("{\"code\":\"1\",\"result\":{}}"));
IWorkOrgSyncReqVO reqVO = new IWorkOrgSyncReqVO();
reqVO.setData(List.of(Map.of("@action", "add", "code", "demo")));
IWorkOrgRespVO respVO = service.syncDepartments(reqVO);
assertThat(respVO.isSuccess()).isTrue();
assertThat(respVO.getPayload()).containsKey("result");
RecordedRequest request = mockWebServer.takeRequest();
assertThat(request.getPath()).isEqualTo(properties.getOrg().getPaths().getSyncDepartment());
String decoded = URLDecoder.decode(request.getBody().readUtf8(), StandardCharsets.UTF_8);
assertThat(decoded).contains("data=[{\"@action\":\"add\",\"code\":\"demo\"}]");
}
private MockResponse jsonResponse(String body) {
return new MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body);
}
private String extractField(String decoded, String key) {
return Arrays.stream(decoded.split("&"))
.filter(part -> part.startsWith(key + "="))
.map(part -> part.substring(key.length() + 1))
.findFirst()
.orElse("");
}
private IWorkProperties buildProperties() {
IWorkProperties properties = new IWorkProperties();
properties.setBaseUrl(mockWebServer.url("/").toString());
properties.getClient().setConnectTimeout(Duration.ofSeconds(5));
properties.getClient().setResponseTimeout(Duration.ofSeconds(5));
properties.getOrg().setTokenSeed("test-seed");
IWorkProperties.OrgPaths paths = properties.getOrg().getPaths();
paths.setSubcompanyPage("/api/hrm/resful/getHrmsubcompanyWithPage");
paths.setDepartmentPage("/api/hrm/resful/getHrmdepartmentWithPage");
paths.setJobTitlePage("/api/hrm/resful/getJobtitleInfoWithPage");
paths.setUserPage("/api/hrm/resful/getHrmUserInfoWithPage");
paths.setSyncSubcompany("/api/hrm/resful/synSubcompany");
paths.setSyncDepartment("/api/hrm/resful/synDepartment");
paths.setSyncJobTitle("/api/hrm/resful/synJobtitle");
paths.setSyncUser("/api/hrm/resful/synHrmresource");
return properties;
}
}

View File

@@ -0,0 +1,85 @@
package com.zt.plat.module.system.service.sso;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
import com.zt.plat.module.system.service.logger.LoginLogService;
import com.zt.plat.module.system.service.logger.OperateLogService;
import com.zt.plat.module.system.service.oauth2.OAuth2TokenService;
import com.zt.plat.module.system.service.sso.strategy.ExternalSsoStrategy;
import com.zt.plat.module.system.service.user.AdminUserService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ExternalSsoServiceImplTest {
@Test
void selectStrategy_returnsExactMatch_whenStrategySupportsSource() {
ExternalSsoStrategy exactStrategy = mock(ExternalSsoStrategy.class);
ExternalSsoStrategy fallbackStrategy = mock(ExternalSsoStrategy.class);
when(exactStrategy.supports("BRMS")).thenReturn(true);
ExternalSsoServiceImpl service = newService(List.of(exactStrategy, fallbackStrategy));
ExternalSsoStrategy selected = invokeSelectStrategy(service, "BRMS");
assertThat(selected).isSameAs(exactStrategy);
}
@Test
void selectStrategy_returnsWildcardStrategy_whenNoExactMatch() {
ExternalSsoStrategy strictStrategy = mock(ExternalSsoStrategy.class);
ExternalSsoStrategy wildcardStrategy = mock(ExternalSsoStrategy.class);
when(strictStrategy.supports("ERP")).thenReturn(false);
when(strictStrategy.supports(null)).thenReturn(false);
when(wildcardStrategy.supports("ERP")).thenReturn(false);
when(wildcardStrategy.supports(null)).thenReturn(true);
ExternalSsoServiceImpl service = newService(List.of(strictStrategy, wildcardStrategy));
ExternalSsoStrategy selected = invokeSelectStrategy(service, "ERP");
assertThat(selected).isSameAs(wildcardStrategy);
}
@Test
void selectStrategy_returnsNull_whenNoStrategyAvailable() {
ExternalSsoServiceImpl service = newService(Collections.emptyList());
ExternalSsoStrategy selected = invokeSelectStrategy(service, "ANY");
assertThat(selected).isNull();
}
@Test
void selectStrategy_skipsStrategy_whenSupportsThrowsException() {
ExternalSsoStrategy unstableStrategy = mock(ExternalSsoStrategy.class);
ExternalSsoStrategy fallbackStrategy = mock(ExternalSsoStrategy.class);
when(unstableStrategy.supports("CRM")).thenThrow(new IllegalStateException("boom"));
when(unstableStrategy.supports(null)).thenReturn(false);
when(fallbackStrategy.supports("CRM")).thenReturn(false);
when(fallbackStrategy.supports(null)).thenReturn(true);
ExternalSsoServiceImpl service = newService(List.of(unstableStrategy, fallbackStrategy));
ExternalSsoStrategy selected = invokeSelectStrategy(service, "CRM");
assertThat(selected).isSameAs(fallbackStrategy);
}
@SuppressWarnings("unchecked")
private ExternalSsoStrategy invokeSelectStrategy(ExternalSsoServiceImpl service, String sourceSystem) {
return ReflectionTestUtils.invokeMethod(service, "selectStrategy", sourceSystem);
}
private ExternalSsoServiceImpl newService(List<ExternalSsoStrategy> strategies) {
ExternalSsoProperties properties = new ExternalSsoProperties();
AdminUserService adminUserService = mock(AdminUserService.class);
LoginLogService loginLogService = mock(LoginLogService.class);
OAuth2TokenService oauth2TokenService = mock(OAuth2TokenService.class);
OperateLogService operateLogService = mock(OperateLogService.class);
return new ExternalSsoServiceImpl(properties, strategies, adminUserService,
loginLogService, oauth2TokenService, operateLogService);
}
}

View File

@@ -1,6 +1,9 @@
server:
port: 48080
sync:
encrypt-key: 25@jygk # 中铝 加密 key
--- #################### 数据库相关配置 ####################
spring:
@@ -44,21 +47,21 @@ spring:
primary: master
datasource:
master:
url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
password: 123456
url: jdbc:dm://172.16.46.247:1050?schema=RUOYI-VUE-PRO
username: SYSDBA
password: pgbsci6ddJ6Sqj@e
slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改
lazy: true # 开启懒加载,保证启动速度
url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
password: 123456
url: jdbc:dm://172.16.46.247:1050?schema=RUOYI-VUE-PRO
username: SYSDBA
password: pgbsci6ddJ6Sqj@e
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
data:
redis:
host: 400-infra.server.iocoder.cn # 地址
port: 6379 # 端口
database: 1 # 数据库索引
host: 172.16.46.63 # 地址
port: 30379 # 端口
database: 0 # 数据库索引
# password: 123456 # 密码,建议生产环境开启
--- #################### 定时任务相关配置 ####################

View File

@@ -3,7 +3,7 @@ spring:
name: zt-server
profiles:
active: local
active: ${env.name}
main:
allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。
@@ -37,6 +37,10 @@ server:
charset: UTF-8 # 必须设置 UTF-8避免 WebFlux 流式返回AI 场景)会乱码问题
force: true
# 默认关闭 Seata ,单体部署无需启动分布式事务;分布式场景通过环境变量 SEATA_ENABLED=true 覆盖即可
seata:
enabled: false
--- #################### Spring Cloud 禁用配置 ####################
spring:
@@ -67,6 +71,49 @@ knife4j:
setting:
language: zh_cn
iwork:
base-url:
app-id:
client-public-key:
user-id:
workflow-id:
paths:
register: /api/ec/dev/auth/regist
apply-token: /api/ec/dev/auth/applytoken
user-info: /api/workflow/paService/getUserInfo
create-workflow: /api/workflow/paService/doCreateRequest
void-workflow: /api/workflow/paService/doCancelRequest
token:
ttl-seconds: 3600
refresh-ahead-seconds: 60
client:
connect-timeout: 5s
response-timeout: 30s
org:
token-seed:
paths:
subcompany-page:
department-page:
job-title-page:
user-page:
sync-subcompany:
sync-department:
sync-job-title:
sync-user:
eplat:
share:
url-prefix: http://10.1.7.110
client-id: ztjgj5gsJ2uU20900h9j
client-secret: DC82AD38EA764719B6DC7D71AAB4856C
scope: read
token-cache-key: eplat:cache:shareToken
refresh-token-cache-key: eplat:cache:shareRefreshToken
token-header-name: Xplat-Token
token-endpoint-path: /eplat/oauth/token
token-ttl: 5000s
refresh-token-ttl: 10000s
# 工作流 Flowable 配置
flowable:
# 1. false: 默认值Flowable 启动时,对比数据库表中保存的版本,如果不匹配。将抛出异常