diff --git a/docs/iWork集成说明.md b/docs/iWork集成说明.md new file mode 100644 index 00000000..1cc38410 --- /dev/null +++ b/docs/iWork集成说明.md @@ -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":"","ts":""}`,无需调用方重复计算。 + +--- + +## 请求 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`):主表字段集合。 + - `detailTables` (`List`):明细表集合(可选)。 + - `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<IWorkFormFieldVO>):该明细行下的字段集合(必填)。 + +- IWorkDetailTableVO(明细表) + - `tableDBName` (String):iWork 明细表表名(必填,如 `formtable_main_26_dt1`)。 + - `records` (List<IWorkDetailRecordVO>):明细记录集合(必填)。 + +--- + +## 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. 使用注册返回的密钥申请 token(apply-token),token 会被按 `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`。 diff --git a/docs/主数据同步指南.md b/docs/主数据同步指南.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/分页汇总功能使用说明.md b/docs/分页汇总功能使用说明.md new file mode 100644 index 00000000..885aad05 --- /dev/null +++ b/docs/分页汇总功能使用说明.md @@ -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 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` 基础上新增注解与聚合逻辑。 diff --git a/docs/外部单点登录.md b/docs/外部单点登录.md new file mode 100644 index 00000000..209d3d56 --- /dev/null +++ b/docs/外部单点登录.md @@ -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` 联调接口验证配置是否正确,再接入正式流程。 diff --git a/docs/数据总线模块大致功能与调用介绍.md b/docs/数据总线模块大致功能与调用介绍.md new file mode 100644 index 00000000..b3c24138 --- /dev/null +++ b/docs/数据总线模块大致功能与调用介绍.md @@ -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 Body;Content-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 +×tamp=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` 条件快速定位第三方问题。 diff --git a/zt-dependencies/pom.xml b/zt-dependencies/pom.xml index 89862b86..45be54c4 100644 --- a/zt-dependencies/pom.xml +++ b/zt-dependencies/pom.xml @@ -26,14 +26,11 @@ https://github.com/YunaiV/ruoyi-vue-pro - 3.0.44 1.6.0 3.4.5 2024.0.1 2023.0.3.2 - - 2.4.0 2.8.3 4.6.0 @@ -88,8 +85,6 @@ 4.1.116.Final 1.2.5 0.9.0 - 11.4.7 - 11.4.7 2.15.1 4.5.13 @@ -135,20 +130,6 @@ import - - - - - org.apache.seata - seata-all - ${seata.version} - - - org.apache.seata - seata-spring-boot-starter - ${seata.version} - - io.github.mouzt @@ -488,6 +469,12 @@ ${podam.version} + + com.squareup.okhttp3 + okhttp + ${okhttp3.version} + + org.flowable @@ -724,17 +711,6 @@ org.eclipse.paho.client.mqttv3 ${mqtt.version} - - - org.docx4j - docx4j-JAXB-ReferenceImpl - ${docx4j-jaxb.version} - - - org.docx4j - docx4j-core - ${docx4j.version} - diff --git a/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/tree/TreeUtil.java b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/tree/TreeUtil.java new file mode 100644 index 00000000..97a7ac73 --- /dev/null +++ b/zt-framework/zt-common/src/main/java/com/zt/plat/framework/common/util/tree/TreeUtil.java @@ -0,0 +1,216 @@ +package com.zt.plat.framework.common.util.tree; + +import com.alibaba.fastjson.JSON; +import com.zt.plat.framework.common.util.object.ObjectUtils; +import lombok.Data; + +import java.util.*; +import java.util.function.*; +import java.util.stream.Collectors; + +/** + * 树操作方法工具类 + */ +public class TreeUtil { + /** + * 将list合成树 + *@param list 需要合成树的List + *@param rootCheck 判断E中为根节点的条件,如:x->x.getPId()==-1L,x->x.getParentId()==null,x->x.getParentMenuId()==0 + *@param parentCheck 判断E中为父节点条件,如:(x,y)->x.getId().equals(y.getPId()) + *@param setSubChildren E中设置下级数据方法,如:Menu::setSubMenus + *@param 泛型实体对象 + *@return 合成好的树 + */ + public static List makeTree(List list, Predicate rootCheck, BiFunction parentCheck, BiConsumer> setSubChildren){ + return list.stream().filter(rootCheck).peek(x->setSubChildren.accept(x,makeChildren(x,list,parentCheck,setSubChildren))).collect(Collectors.toList()); + } + + /** + *将树打平成tree + *@paramtree需要打平的树 + *@paramgetSubChildren设置下级数据方法,如:Menu::getSubMenus,x->x.setSubMenus(null) + *@paramsetSubChildren将下级数据置空方法,如:x->x.setSubMenus(null) + *@return打平后的数据 + *@param泛型实体对象 + */ + public static List flat(List tree, Function> getSubChildren, Consumer setSubChildren){ + List res = new ArrayList<>(); + forPostOrder(tree,item->{ + setSubChildren.accept(item); + res.add(item); + },getSubChildren); + return res; + } + + + /** + *前序遍历 + * + *@paramtree需要遍历的树 + *@paramconsumer遍历后对单个元素的处理方法,如:x->System.out.println(x)、System.out::println打印元素 + *@paramsetSubChildren设置下级数据方法,如:Menu::getSubMenus,x->x.setSubMenus(null) + *@param泛型实体对象 + */ + public static void forPreOrder(List tree,Consumer consumer,Function> setSubChildren){ + for(E l : tree){ + consumer.accept(l); + List es = setSubChildren.apply(l); + if(es != null && es.size() > 0){ + forPreOrder(es,consumer,setSubChildren); + } + } + } + + + /** + *层序遍历 + * + *@paramtree需要遍历的树 + *@paramconsumer遍历后对单个元素的处理方法,如:x->System.out.println(x)、System.out::println打印元素 + *@paramsetSubChildren设置下级数据方法,如:Menu::getSubMenus,x->x.setSubMenus(null) + *@param泛型实体对象 + */ + public static void forLevelOrder(List tree,Consumer consumer,Function> setSubChildren){ + Queue queue=new LinkedList<>(tree); + while(!queue.isEmpty()){ + E item = queue.poll(); + consumer.accept(item); + List childList = setSubChildren.apply(item); + if(childList !=null && !childList.isEmpty()){ + queue.addAll(childList); + } + } + } + + + /** + *后序遍历 + * + *@paramtree需要遍历的树 + *@paramconsumer遍历后对单个元素的处理方法,如:x->System.out.println(x)、System.out::println打印元素 + *@paramsetSubChildren设置下级数据方法,如:Menu::getSubMenus,x->x.setSubMenus(null) + *@param泛型实体对象 + */ + public static void forPostOrder(List tree,Consumer consumer,Function> setSubChildren){ + for(E item : tree) { + List childList = setSubChildren.apply(item); + if(childList != null && !childList.isEmpty()){ + forPostOrder(childList,consumer,setSubChildren); + } + consumer.accept(item); + } + } + + /** + *对树所有子节点按comparator排序 + * + *@paramtree需要排序的树 + *@paramcomparator排序规则Comparator,如:Comparator.comparing(MenuVo::getRank)按Rank正序,(x,y)->y.getRank().compareTo(x.getRank()),按Rank倒序 + *@paramgetChildren获取下级数据方法,如:MenuVo::getSubMenus + *@return排序好的树 + *@param泛型实体对象 + */ + public static List sort(List tree, Comparator comparator, Function> getChildren){ + for(E item : tree){ + List childList = getChildren.apply(item); + if(childList != null &&! childList.isEmpty()){ + sort(childList,comparator,getChildren); + } + } + tree.sort(comparator); + return tree; + } + + private static List makeChildren(E parent,List allData,BiFunction parentCheck,BiConsumer> children){ + return allData.stream().filter(x->parentCheck.apply(parent,x)).peek(x->children.accept(x,makeChildren(x,allData,parentCheck,children))).collect(Collectors.toList()); + } + + /** + * 使用样例 + * @param args + */ + public static void main(String[] args) { + MenuVo menu0 = new MenuVo(0L, -1L, "一级菜单", 0); + MenuVo menu1 = new MenuVo(1L, 0L, "二级菜单", 1); + MenuVo menu2 = new MenuVo(2L, 0L, "三级菜单", 2); + MenuVo menu3 = new MenuVo(3L, 1L, "四级菜单", 3); + MenuVo menu4 = new MenuVo(4L, 1L, "五级菜单", 4); + MenuVo menu5 = new MenuVo(5L, 2L, "六级菜单", 5); + MenuVo menu6 = new MenuVo(6L, 2L, "七级菜单", 6); + MenuVo menu7 = new MenuVo(7L, 3L, "八级菜单", 7); + MenuVo menu8 = new MenuVo(8L, 3L, "九级菜单", 8); + MenuVo menu9 = new MenuVo(9L, 4L, "十级菜单", 9); + //基本数据 + List menuList = Arrays.asList(menu0,menu1, menu2,menu3,menu4,menu5,menu6,menu7,menu8,menu9); + //合成树 + /** + * 第1个参数List list,为我们需要合成树的List,如上面Demo中的menuList + * 第2个参数Predicate rootCheck,判断为根节点的条件,如上面Demo中pId==-1就是根节点 + * 第3个参数parentCheck 判断为父节点条件,如上面Demo中 id==pId + * 第4个参数setSubChildren,设置下级数据方法如上面Demo中:Menu::setSubMenus + */ + List tree= TreeUtil.makeTree(menuList, x->x.getPId()==-1L,(x, y)->x.getId().equals(y.getPId()), MenuVo::setSubMenus); + System.out.println(JSON.toJSONString(tree)); + + //先序 + /** + * 遍历数参数解释: + * tree 需要遍历的树,就是makeTree()合 + * 成的对象Consumer consumer 遍历后对单个元素的处理方法,如:x-> System.out.println(x)、 postOrder.append(x.getId().toString()) + * Function getSubChildren,获取下级数据方法,如Menu::getSubMenus + */ + StringBuffer preStr = new StringBuffer(); + TreeUtil.forPreOrder(tree,x-> preStr.append(x.getId().toString()),MenuVo::getSubMenus); + ObjectUtils.equalsAny("0123456789",preStr.toString()); + + //层序 + StringBuffer levelStr=new StringBuffer(); + TreeUtil.forLevelOrder(tree,x-> levelStr.append(x.getId().toString()),MenuVo::getSubMenus); + ObjectUtils.equalsAny("0123456789",levelStr.toString()); + + //后序 + StringBuffer postOrder=new StringBuffer(); + TreeUtil.forPostOrder(tree,x-> postOrder.append(x.getId().toString()),MenuVo::getSubMenus); + ObjectUtils.equalsAny("7839415620",postOrder.toString()); + + // 树平铺 + /** + * flat()参数解释: + * tree 需要打平的树,就是makeTree()合成的对象Function getSubChildren, + * 获取下级数据方法,如Menu::getSubMenusConsumer setSubChildren, + * 设置下级数据方法,如:x->x.setSubMenus(null) + */ + List flat = TreeUtil.flat(tree, MenuVo::getSubMenus,x->x.setSubMenus(null)); + ObjectUtils.equalsAny(flat.size(),menuList.size()); + flat.forEach(x -> { + if (x.getSubMenus() != null) { + throw new RuntimeException("树平铺失败"); + } + }); + + // 按rank正序 + /** + * sort参数解释: + * tree 需要排序的树,就是makeTree()合成的对象Comparator comparator + * 排序规则Comparator,如:Comparator.comparing(MenuVo::getRank) 按Rank正序 ,(x,y)->y.getRank().compareTo(x.getRank()),按Rank倒序Function getChildren + * 获取下级数据方法,如:MenuVo::getSubMenus + */ + List sortTree= TreeUtil.sort(tree, Comparator.comparing(MenuVo::getRank), MenuVo::getSubMenus); + // 按rank倒序 + List sortTreeReverse = TreeUtil.sort(tree, (x,y)->y.getRank().compareTo(x.getRank()), MenuVo::getSubMenus); + } + @Data + static class MenuVo { + private Long id; // 主键id + private Long pId; // 父级id + private String name; // 菜单名称 + private Integer rank = 0; // 排序 + private List subMenus = new ArrayList<>(); // 子菜单 + public MenuVo(Long id, Long pId, String name, Integer rank) { + this.id = id; + this.pId = pId; + this.name = name; + this.rank = rank; + } + } +} diff --git a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/businessfile/BusinessFileController.java b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/businessfile/BusinessFileController.java index 306e44e1..57e82e23 100644 --- a/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/businessfile/BusinessFileController.java +++ b/zt-module-infra/zt-module-infra-server/src/main/java/com/zt/plat/module/infra/controller/admin/businessfile/BusinessFileController.java @@ -92,7 +92,7 @@ public class BusinessFileController { @GetMapping("/page") @Operation(summary = "获得业务附件关联分页") - @PreAuthorize("@ss.hasAnyPermissions({'infra:business-file:query','PurchaseCreditGrantingFormTemplate:query','PurchaseAmountRequestFormTemplate:query'})") + @PreAuthorize("@ss.hasAnyPermissions({'infra:business-file:query','supply:purchase-credit-granting-form-template:query-list','supply:purchase-amount-request-form-template:query-list'})") public CommonResult> getBusinessFilePage(@Valid BusinessFilePageReqVO pageReqVO) { PageResult pageResult = businessFileService.getBusinessFilePage(pageReqVO); return success(BeanUtils.toBean(pageResult, BusinessFileRespVO.class)); diff --git a/zt-module-system/zt-module-system-server/pom.xml b/zt-module-system/zt-module-system-server/pom.xml index 414a983c..ff064776 100644 --- a/zt-module-system/zt-module-system-server/pom.xml +++ b/zt-module-system/zt-module-system-server/pom.xml @@ -67,10 +67,10 @@ zt-spring-boot-starter-redis - + - org.springframework.boot - spring-boot-starter-webflux + com.squareup.okhttp3 + okhttp diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java index 89f742a1..f0d679ad 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/IWorkIntegrationController.java @@ -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 voidWorkflow(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) { return success(integrationService.voidWorkflow(reqVO)); } + + // ----------------- 人力组织接口 ----------------- + + @PostMapping("/hr/subcompany/page") + @Operation(summary = "获取 iWork 分部列表") + public CommonResult listSubcompanies(@Valid @RequestBody IWorkSubcompanyQueryReqVO reqVO) { + return success(orgRestService.listSubcompanies(reqVO)); + } + + @PostMapping("/hr/department/page") + @Operation(summary = "获取 iWork 部门列表") + public CommonResult listDepartments(@Valid @RequestBody IWorkDepartmentQueryReqVO reqVO) { + return success(orgRestService.listDepartments(reqVO)); + } + + @PostMapping("/hr/job-title/page") + @Operation(summary = "获取 iWork 岗位列表") + public CommonResult listJobTitles(@Valid @RequestBody IWorkJobTitleQueryReqVO reqVO) { + return success(orgRestService.listJobTitles(reqVO)); + } + + @PostMapping("/hr/user/page") + @Operation(summary = "获取 iWork 人员列表") + public CommonResult listUsers(@Valid @RequestBody IWorkUserQueryReqVO reqVO) { + return success(orgRestService.listUsers(reqVO)); + } + + @PostMapping("/hr/subcompany/sync") + @Operation(summary = "同步分部信息至 iWork") + public CommonResult syncSubcompanies(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) { + return success(orgRestService.syncSubcompanies(reqVO)); + } + + @PostMapping("/hr/department/sync") + @Operation(summary = "同步部门信息至 iWork") + public CommonResult syncDepartments(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) { + return success(orgRestService.syncDepartments(reqVO)); + } + + @PostMapping("/hr/job-title/sync") + @Operation(summary = "同步岗位信息至 iWork") + public CommonResult syncJobTitles(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) { + return success(orgRestService.syncJobTitles(reqVO)); + } + + @PostMapping("/hr/user/sync") + @Operation(summary = "同步人员信息至 iWork") + public CommonResult syncUsers(@Valid @RequestBody IWorkOrgSyncReqVO reqVO) { + return success(orgRestService.syncUsers(reqVO)); + } } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkDepartmentQueryReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkDepartmentQueryReqVO.java new file mode 100644 index 00000000..059f0962 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkDepartmentQueryReqVO.java @@ -0,0 +1,24 @@ +package com.zt.plat.module.system.controller.admin.integration.iwork.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import 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; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkJobTitleQueryReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkJobTitleQueryReqVO.java new file mode 100644 index 00000000..64760eed --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkJobTitleQueryReqVO.java @@ -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; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgBaseQueryReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgBaseQueryReqVO.java new file mode 100644 index 00000000..58be39c6 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgBaseQueryReqVO.java @@ -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 params; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgQueryReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgQueryReqVO.java new file mode 100644 index 00000000..bd59d628 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgQueryReqVO.java @@ -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 params; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgRespVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgRespVO.java new file mode 100644 index 00000000..63bf2845 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgRespVO.java @@ -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 payload; + + @Schema(description = "原始响应字符串") + private String rawBody; + + @Schema(description = "是否判断为成功") + private boolean success; + + @Schema(description = "提示信息") + private String message; + + @Schema(description = "响应码") + private String code; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgSyncReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgSyncReqVO.java new file mode 100644 index 00000000..52120368 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkOrgSyncReqVO.java @@ -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> data; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkSubcompanyQueryReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkSubcompanyQueryReqVO.java new file mode 100644 index 00000000..781613e7 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkSubcompanyQueryReqVO.java @@ -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; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkUserQueryReqVO.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkUserQueryReqVO.java new file mode 100644 index 00000000..3e4b73fd --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/controller/admin/integration/iwork/vo/IWorkUserQueryReqVO.java @@ -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; +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java index 14d7aa50..63e514b2 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/framework/integration/iwork/config/IWorkProperties.java @@ -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; + } } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkIntegrationErrorCodeConstants.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkIntegrationErrorCodeConstants.java index c4fdd12f..0a0a8324 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkIntegrationErrorCodeConstants.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkIntegrationErrorCodeConstants.java @@ -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 人力组织接口请求失败"); } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkOrgRestService.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkOrgRestService.java new file mode 100644 index 00000000..18fbe3e4 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/IWorkOrgRestService.java @@ -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); +} diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java index 1fd71ec4..f0d944f2 100644 --- a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkIntegrationServiceImpl.java @@ -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_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 sessionCache = Caffeine.newBuilder() .maximumSize(256) .build(); - private final Cache publicKeyCache = Caffeine.newBuilder() + private final Cache 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 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 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 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()); } } diff --git a/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkOrgRestServiceImpl.java b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkOrgRestServiceImpl.java new file mode 100644 index 00000000..c4d29cb1 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/main/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkOrgRestServiceImpl.java @@ -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_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 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 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 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 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 buildBaseParams(IWorkOrgBaseQueryReqVO reqVO) { + Map 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 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 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; + } +} diff --git a/zt-module-system/zt-module-system-server/src/main/resources/application-dev.yaml b/zt-module-system/zt-module-system-server/src/main/resources/application-dev.yaml index 724e62f3..a2331f69 100644 --- a/zt-module-system/zt-module-system-server/src/main/resources/application-dev.yaml +++ b/zt-module-system/zt-module-system-server/src/main/resources/application-dev.yaml @@ -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 \ No newline at end of file diff --git a/zt-module-system/zt-module-system-server/src/main/resources/application.yaml b/zt-module-system/zt-module-system-server/src/main/resources/application.yaml index deb6c709..21863d2f 100644 --- a/zt-module-system/zt-module-system-server/src/main/resources/application.yaml +++ b/zt-module-system/zt-module-system-server/src/main/resources/application.yaml @@ -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 远程调用相关配置 #################### diff --git a/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkOrgRestServiceImplTest.java b/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkOrgRestServiceImplTest.java new file mode 100644 index 00000000..e857117f --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/integration/iwork/impl/IWorkOrgRestServiceImplTest.java @@ -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; + } +} diff --git a/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/sso/ExternalSsoServiceImplTest.java b/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/sso/ExternalSsoServiceImplTest.java new file mode 100644 index 00000000..ad677538 --- /dev/null +++ b/zt-module-system/zt-module-system-server/src/test/java/com/zt/plat/module/system/service/sso/ExternalSsoServiceImplTest.java @@ -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 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); + } +} diff --git a/zt-server/src/main/resources/application-dev.yaml b/zt-server/src/main/resources/application-dev.yaml index b281f985..ffa4253d 100644 --- a/zt-server/src/main/resources/application-dev.yaml +++ b/zt-server/src/main/resources/application-dev.yaml @@ -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 # 密码,建议生产环境开启 --- #################### 定时任务相关配置 #################### diff --git a/zt-server/src/main/resources/application.yaml b/zt-server/src/main/resources/application.yaml index fc8bd152..548d13fd 100644 --- a/zt-server/src/main/resources/application.yaml +++ b/zt-server/src/main/resources/application.yaml @@ -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 启动时,对比数据库表中保存的版本,如果不匹配。将抛出异常