Merge branch 'refs/heads/zt-test' into test
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,6 +61,7 @@ package-lock.json
|
||||
# visual studio code
|
||||
.history
|
||||
*.log
|
||||
logs/**
|
||||
|
||||
functions/mock
|
||||
.temp/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-databus
|
||||
labels:
|
||||
app: zt-module-databus
|
||||
@@ -19,7 +19,7 @@ spec:
|
||||
labels:
|
||||
app: zt-module-databus
|
||||
spec:
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-gateway
|
||||
labels:
|
||||
app: zt-gateway
|
||||
@@ -61,7 +61,7 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-gateway
|
||||
spec:
|
||||
type: NodePort
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-infra
|
||||
labels:
|
||||
app: zt-module-infra
|
||||
@@ -19,7 +19,7 @@ spec:
|
||||
labels:
|
||||
app: zt-module-infra
|
||||
spec:
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-system
|
||||
labels:
|
||||
app: zt-module-system
|
||||
@@ -28,7 +28,7 @@ spec:
|
||||
operator: In
|
||||
values:
|
||||
- node-3
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#apiVersion: apps/v1
|
||||
#kind: Deployment
|
||||
#metadata:
|
||||
# namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
# namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
# name: zt-gateway
|
||||
# labels:
|
||||
# app: zt-gateway
|
||||
@@ -13,6 +13,8 @@
|
||||
# app: zt-gateway
|
||||
# template:
|
||||
# metadata:
|
||||
# annotations:
|
||||
# proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}'
|
||||
# labels:
|
||||
# app: zt-gateway
|
||||
# spec:
|
||||
@@ -51,7 +53,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-gateway
|
||||
spec:
|
||||
type: NodePort
|
||||
@@ -67,7 +69,7 @@ spec:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-infra
|
||||
labels:
|
||||
app: zt-module-infra
|
||||
@@ -82,10 +84,12 @@ spec:
|
||||
app: zt-module-infra
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}'
|
||||
labels:
|
||||
app: zt-module-infra
|
||||
spec:
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
@@ -128,7 +132,7 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-infra
|
||||
spec:
|
||||
type: NodePort
|
||||
@@ -144,7 +148,7 @@ spec:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-system
|
||||
labels:
|
||||
app: zt-module-system
|
||||
@@ -161,6 +165,8 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app: zt-module-system
|
||||
annotations:
|
||||
proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}'
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
@@ -171,7 +177,7 @@ spec:
|
||||
operator: In
|
||||
values:
|
||||
- node-3
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
@@ -214,7 +220,7 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-system
|
||||
spec:
|
||||
type: NodePort
|
||||
@@ -230,7 +236,7 @@ spec:
|
||||
#apiVersion: apps/v1
|
||||
#kind: Deployment
|
||||
#metadata:
|
||||
# namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
# namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
# name: zt-module-bpm
|
||||
# labels:
|
||||
# app: zt-module-bpm
|
||||
@@ -245,6 +251,8 @@ spec:
|
||||
# app: zt-module-bpm
|
||||
# template:
|
||||
# metadata:
|
||||
# annotations:
|
||||
# proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}'
|
||||
# labels:
|
||||
# app: zt-module-bpm
|
||||
# spec:
|
||||
@@ -286,7 +294,7 @@ spec:
|
||||
#apiVersion: v1
|
||||
#kind: Service
|
||||
#metadata:
|
||||
# namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
# namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
# name: zt-module-bpm
|
||||
#spec:
|
||||
# type: NodePort
|
||||
@@ -302,7 +310,7 @@ spec:
|
||||
#apiVersion: apps/v1
|
||||
#kind: Deployment
|
||||
#metadata:
|
||||
# namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
# namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
# name: zt-module-report
|
||||
# labels:
|
||||
# app: zt-module-report
|
||||
@@ -358,7 +366,7 @@ spec:
|
||||
#apiVersion: v1
|
||||
#kind: Service
|
||||
#metadata:
|
||||
# namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
# namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
# name: zt-module-report
|
||||
#spec:
|
||||
# type: NodePort
|
||||
@@ -374,7 +382,7 @@ spec:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-databus
|
||||
labels:
|
||||
app: zt-module-databus
|
||||
@@ -391,6 +399,8 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app: zt-module-databus
|
||||
annotations:
|
||||
proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}'
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
@@ -401,7 +411,7 @@ spec:
|
||||
operator: In
|
||||
values:
|
||||
- node-3
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
@@ -444,7 +454,7 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-databus
|
||||
spec:
|
||||
type: NodePort
|
||||
@@ -462,7 +472,7 @@ spec:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-template
|
||||
labels:
|
||||
app: zt-module-template
|
||||
@@ -477,10 +487,12 @@ spec:
|
||||
app: zt-module-template
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}'
|
||||
labels:
|
||||
app: zt-module-template
|
||||
spec:
|
||||
dnsPolicy: None
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- "172.16.36.16"
|
||||
@@ -523,7 +535,7 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ns-d6a0e78ebd674c279614498e4c57b133
|
||||
namespace: ns-f16a3067ca7b434aad127d15eac82503
|
||||
name: zt-module-template
|
||||
spec:
|
||||
type: NodePort
|
||||
|
||||
31
docs/databus-client使用说明.md
Normal file
31
docs/databus-client使用说明.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Databus Client 使用说明
|
||||
|
||||
databus client 最主要用于调用基于http协议的第三方接口时需要记录调用日志到 databus 的情况, 通过databus client 调用第三方接口会将调用日志记录到databus的访问日志中
|
||||
|
||||
# 使用方法
|
||||
1. 添加依赖:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.zt.plat</groupId>
|
||||
<artifactId>zt-module-databus-client</artifactId>
|
||||
<version>3.0.47-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
2. 注入 DatabusClient
|
||||
```java
|
||||
@Resource
|
||||
private DatabusClient databusClient;
|
||||
```
|
||||
3. 方法说明
|
||||
* get(...) : 发送 get 请求
|
||||
* post(...): 发送 post 请求
|
||||
* put(...): 发送 put 请求
|
||||
* delete(...): 发送 delete 请求
|
||||
* doRequest(...): 发送自定义请求
|
||||
4. 方法参数说明(由于所有方法参数都是一样的,所以在此统一说明)
|
||||
* String urlString: 请求的 http 接口地址(get/delete请求不需要带url参数)
|
||||
* Map<String, Object> data: 请求的参数(post/put方法会转换为json提交, get/delete会拼接到url上)
|
||||
* Map<String, String> headers: 请求头信息
|
||||
* String appId: databus 的appid
|
||||
* String authToken: databus 的访问令牌
|
||||
* Method method: doRequest 方法独有,如果要使用 get/post/put/delete 之外的方法,请使用doRequest方法并通过method参数指定
|
||||
@@ -1,6 +1,6 @@
|
||||
# iWork 统一集成使用说明
|
||||
|
||||
本文档介绍如何在 System 模块中使用项目已实现的统一 iWork 流程发起能力(controller + service + properties)。内容包含:配置项、调用方式(内部 Java 调用 & 外部 HTTP 调用)、请求/响应示例、错误处理、缓存与 Token 生命周期、典型问题与排查步骤。
|
||||
本文档介绍如何在 System 模块中使用项目已实现的统一 iWork 流程发起能力(controller + service + properties)。内容包含:配置项、调用方式(内部 Java 调用 & 外部 HTTP 调用)、请求/响应示例、错误处理、缓存与 Token 生命周期、业务回调分发与重试、流程日志查询,以及典型问题与排查步骤。
|
||||
|
||||
---
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
- 提供 Service 层 `IWorkIntegrationService`,供其它模块以 Spring Bean 注入方式直接调用。
|
||||
- 使用 `IWorkProperties` 绑定 `application.yml` 中 `iwork` 的配置项。
|
||||
- Token / 会话采用本地 Caffeine 缓存缓存(按 appId + operatorUserId 缓存 session),并在到期前按配置提前刷新。
|
||||
- 使用统一配置的 appId、公钥以及默认流程编号,无需再维护多套凭证。
|
||||
- 使用统一配置的 appId、公钥以及默认流程编号,无需再维护多套凭证,所有调用强制使用配置的 appId,不再接受请求覆盖。
|
||||
- 全链路以 requestId 作为唯一业务标识(发起返回、作废入参、日志查询、回调与重试均基于 requestId),workflowId 仅用于指定 iWork 模板。
|
||||
- 支持业务回调标识 bizCallbackKey(字符串,≤255),发起时提交,回调时按标识分发业务回调并带自动重试与手工重试入口。
|
||||
- 回调与日志记录会保存截断后的原始回调请求/响应文本(无需脱敏),无业务附件或处理异常时标记失败。
|
||||
|
||||
---
|
||||
|
||||
@@ -47,6 +50,10 @@ iwork:
|
||||
token:
|
||||
ttl-seconds: 3600 # token 有效期(秒)
|
||||
refresh-ahead-seconds: 60 # 在到期前多少秒认为需要刷新
|
||||
callback:
|
||||
retry:
|
||||
max-attempts: 3 # 业务回调自动重试次数(默认 3 次,可调整)
|
||||
delay-seconds: 5 # 业务回调自动重试间隔秒数(默认 5 秒,可调整)
|
||||
client:
|
||||
connect-timeout: 5s
|
||||
response-timeout: 30s
|
||||
@@ -55,10 +62,11 @@ iwork:
|
||||
说明:
|
||||
|
||||
- `base-url` 为 iWork 网关的基础地址,不能留空。
|
||||
- `app-id` 与 `client-public-key` 共同构成注册/申请 token 所需的凭据信息,由配置统一提供,不再支持多套切换。
|
||||
- `workflow-id` 提供全局默认流程编号,单次调用也可通过 `workflowId` 覆盖。
|
||||
- `app-id` 与 `client-public-key` 共同构成注册/申请 token 所需的凭据信息,由配置统一提供,不再支持多套切换,系统强制使用配置的 appId,忽略请求中的 appId。
|
||||
- `workflow-id` 提供全局默认流程编号,仅用于向 iWork 指定流程模板;流程标识、查询、补偿、回调与重试一律使用 requestId,不再以 workflowId 标识业务。
|
||||
- 请求头键名固定为 `app-id`、`client-public-key`、`secret`、`token`、`time`、`user-id`,无需在配置中重复声明。
|
||||
- `org.*` 配置负责 iWork 人力组织 REST 代理:`token-seed` 为与 iWork 约定的标识,系统会自动将其与毫秒时间戳拼接并计算 MD5 生成 `key`,无需额外传递 token。
|
||||
- `callback.retry.*` 控制业务回调的自动重试次数与间隔,默认为 3 次、5 秒,可按需调整。
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +110,7 @@ Controller 暴露的 REST 接口:
|
||||
## 请求 VO 说明(重要字段)
|
||||
|
||||
- IWorkBaseReqVO(公用字段)
|
||||
- `appId` (String):为兼容历史接口保留,系统始终使用配置项 `iwork.app-id`。
|
||||
- `appId` (String):仅保留字段,系统强制使用配置项 `iwork.app-id`,忽略请求值。
|
||||
- `operatorUserId` (String):在 iWork 内部代表操作人的用户编号(可为空,框架会使用 `properties.userId`)。
|
||||
- `forceRefreshToken` (Boolean):是否强制刷新 token(例如遇到 token 错误时强制刷新)。
|
||||
|
||||
@@ -117,7 +125,7 @@ Controller 暴露的 REST 接口:
|
||||
- `success` / `message`:调用成功标志与提示信息。
|
||||
|
||||
- IWorkWorkflowCreateReqVO(统一用印流程发起)
|
||||
- `workflowId` (String):流程模板 ID,必填,不再回退到配置。
|
||||
- `workflowId` (String):流程模板 ID,必填,通常由配置 `iwork.workflow-id` 提供;仅用于向 iWork 指定模板,不再作为业务标识。
|
||||
- `jbr`:用印申请人(iWork 人员 ID,必填)。
|
||||
- `yybm`:用印部门 ID(必填)。
|
||||
- `fb`:用印单位/分部 ID(必填)。
|
||||
@@ -128,10 +136,11 @@ Controller 暴露的 REST 接口:
|
||||
- `xyywjUrl`:用印材料附件 URL(必填)。
|
||||
- `yysx`:用印事项(必填)。
|
||||
- `ywxtdjbh`:业务系统单据编号(必填,同时用于生成流程标题“用印-{ywxtdjbh}”)。
|
||||
- `bizCallbackKey` (String):业务回调标识,≤255 字符,回调时按该标识分发到对应业务回调入口(可选但推荐)。
|
||||
- 额外字段不再支持,Service 会根据以上字段自动补齐固定流程类型 (`lclx=2979600781334966993`) 与签署动作 (`qsdz=CORPORATE`)。
|
||||
|
||||
- IWorkWorkflowVoidReqVO(作废)
|
||||
- `requestId` (String):流程请求编号(必填)。
|
||||
- `requestId` (String):流程请求编号(必填,唯一标识)。
|
||||
- `reason`、`extraParams`、`formExtras` 等用于传递作废原因或额外字段。
|
||||
|
||||
- IWorkFormFieldVO(表单字段)
|
||||
@@ -188,8 +197,9 @@ public class MyService {
|
||||
|
||||
说明:
|
||||
|
||||
- 若需使用特定凭证,可设置 `req.setAppId("my-iwork-app")`。
|
||||
- 若需覆盖默认流程模板,可调用 `req.setWorkflowId(123L)` 指定。
|
||||
- 无需设置 appId,系统强制使用配置项 `iwork.app-id`。
|
||||
- `workflowId` 通常来自配置 `iwork.workflow-id`,也可在请求中指定模板编号,但流程标识一律以返回的 requestId 为准。
|
||||
- 可设置 `bizCallbackKey` 以便回调时分发到对应业务处理;建议在发起后立即记录响应中的 requestId 及 bizCallbackKey。
|
||||
- 若希望以特定 iWork 操作人发起,可设置 `req.setOperatorUserId("1001")`。
|
||||
|
||||
---
|
||||
@@ -202,7 +212,6 @@ public class MyService {
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appId":"my-iwork-app",
|
||||
"identifierKey":"loginid",
|
||||
"identifierValue":"zhangsan"
|
||||
}' \
|
||||
@@ -238,19 +247,20 @@ curl -X POST -H "Content-Type: application/json" -d '{
|
||||
"yysy":"与客户合同用印",
|
||||
"xyywjUrl":"https://files.example.com/contract.pdf",
|
||||
"yysx":"合同用印",
|
||||
"ywxtdjbh":"DJ-2025-0001"
|
||||
"ywxtdjbh":"DJ-2025-0001",
|
||||
"bizCallbackKey":"seal-flow-callback"
|
||||
}' https://your-zt-server/admin-api/system/integration/iwork/workflow/create
|
||||
```
|
||||
|
||||
> 说明:外部仍以 JSON 请求调用本服务,系统在向 iWork 转发时会自动将负载转换为 `application/x-www-form-urlencoded` 表单(含 `requestName`、`workflowId`、`mainData` 等字段)。
|
||||
> 响应的 `data.requestId` 为唯一业务标识,请在业务侧保存;appId 始终取配置;`xyywjFileName` 不存储。
|
||||
|
||||
1. Void workflow
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Content-Type: application/json" -d '{
|
||||
"requestId":"REQ-001",
|
||||
"reason":"作废原因",
|
||||
"appId":"my-iwork-app"
|
||||
"reason":"作废原因"
|
||||
}' https://your-zt-server/admin-api/system/integration/iwork/workflow/void
|
||||
```
|
||||
|
||||
@@ -259,34 +269,98 @@ curl -X POST -H "Content-Type: application/json" -d '{
|
||||
## 核心逻辑与细节
|
||||
|
||||
1. 基础参数解析
|
||||
appId:始终取配置 `iwork.app-id`,忽略请求值。
|
||||
主标识:全链路仅使用 `requestId`(发起返回、作废入参、日志查询、回调分发、重试都基于 requestId)。
|
||||
模板:`workflowId` 仅用于选择 iWork 模板,不作为业务标识。
|
||||
业务回调标识:`bizCallbackKey`(字符串,≤255),发起时提交,回调按该标识分发到具体业务回调入口。
|
||||
附件文件名:`xyywjFileName` 不存储也不参与处理;仅使用 `xyywjUrl`。
|
||||
|
||||
系统始终使用 `application.yml` 中配置的 `app-id` 与 `client-public-key` 与 iWork 通信。
|
||||
请求体中的 `appId` 字段仅为兼容历史调用而保留,框架内部不会使用该值做切换。
|
||||
|
||||
1. Workflow 模板解析
|
||||
|
||||
调用时优先使用请求体中的 `workflowId`。
|
||||
若未显式传入,则回退到全局 `iwork.workflow-id`,若仍为空则抛出 `IWORK_WORKFLOW_ID_MISSING`。
|
||||
|
||||
1. 注册 + RSA + Token
|
||||
|
||||
- 在首次或 token 过期时,会按以下步骤获取 session:
|
||||
调用时可在请求体中指定 `workflowId` 以选择模板;若未显式传入,则回退到全局 `iwork.workflow-id`,若仍为空则抛出 `IWORK_WORKFLOW_ID_MISSING`。
|
||||
POST `/system/integration/iwork/workflow/create`:发起用印流程,返回 `requestId`。
|
||||
POST `/system/integration/iwork/workflow/void`:作废流程,请求需携带 `requestId`。
|
||||
POST `/system/integration/iwork/callback/file`(上游回调):接收 iWork 回调,校验 `requestId`,若缺 `bizCallbackKey` 则记录失败但不中断附件保存;下载并落库附件,记录回调日志(截断原文),并在提供 `bizCallbackKey` 时通过 RocketMQ 分发业务回调(自动/手工重试)。
|
||||
POST `/system/integration/iwork/log/page`:分页查询用印回调日志(requestId / 业务单号 / bizCallbackKey / 状态 / 时间段)。
|
||||
POST `/system/integration/iwork/log/retry`:手工重试业务回调,需权限,入参 `requestId`(状态为失败/超重试时可用)。
|
||||
1. 向 iWork 的 `register` 接口发起请求(Headers 包含 appId 与 clientPublicKey)。
|
||||
2. 从注册响应中获取 `secret` 与 `spk`(服务端公钥),使用本地的 client 公钥做 RSA 加密(`spk` 用于加密),得到加密后的 secret 与 encryptedUserId。
|
||||
3. 使用注册返回的密钥申请 token(apply-token),token 会被按 `ttl-seconds` 缓存。
|
||||
|
||||
- `IWorkIntegrationServiceImpl` 中维护一个 Caffeine `sessionCache`,缓存 key 为 `appId::operatorUserId`。
|
||||
发起:`workflowId`、`jbr`、`yybm`、`fb`、`sqsj`、`yyqx`、`xyywjUrl`、`yysx`、`ywxtdjbh` 必填;`bizCallbackKey` 可选但建议提供;appId 忽略。
|
||||
作废:`requestId` 必填;`reason` 可选。
|
||||
回调:若无法找到业务附件(或不存在),标记用印失败并记日志,不抛出未捕获异常;记录原始回调文本(截断)。
|
||||
- 当 token 接近到期(`refresh-ahead-seconds`)时会在下一次请求触发刷新。
|
||||
|
||||
1. 请求构造
|
||||
|
||||
- 用户解析、作废等场景继续以 `application/json` 调用 iWork。
|
||||
- 用印流程发起在转发至 iWork 时改为 `application/x-www-form-urlencoded` 表单,请求正文包含 `requestName`、`workflowId` 及字符串化后的 `mainData`,与 iWork 网关当前要求保持一致。
|
||||
- 认证 Header:由 `IWorkProperties.Headers` 中的常量控制,固定键名为 `app-id`、`client-public-key`、`secret`、`token`、`time`、`user-id`。
|
||||
1) 发起:校验必填 → 使用配置 appId 与模板 workflowId → 申请/缓存 token → 以 form-urlencoded 调 iWork → 返回 `requestId`,记录发起日志状态。
|
||||
2) 回调(/callback/file):校验 requestId + bizCallbackKey → 校验租户/必备字段 → 下载/保存附件(若缺业务附件则直接标记失败并记日志)→ 写入回调日志,保存原始回调请求/响应(截断)→ 通过 RocketMQ 发送业务回调消息(topic=`SYSTEM_IWORK_BIZ_CALLBACK`,tag=`bizCallbackKey`)。
|
||||
3) 业务处理 & 结果上报(跨模块/跨进程):业务模块订阅 `SYSTEM_IWORK_BIZ_CALLBACK` 对应 tag 处理后,将结果发布到 `SYSTEM_IWORK_BIZ_CALLBACK_RESULT`(tag 同 bizCallbackKey,携带 requestId / bizCallbackKey / success / errorMessage / payload / attempt / maxAttempts),system 模块消费结果并更新日志,失败且未超限时由 system 端按配置延迟重试再次投递回调消息。
|
||||
4) 重试:默认 3 次、间隔 5 秒,可配置(`iwork.callback.retry.*`),手工重试仍使用 `/log/retry`,由 system 模块重新投递 MQ 消息。
|
||||
5) 手工重试:需具备 iWork 模块权限;根据 requestId 将任务重新投递至回调分发器,不增加自动重试计数,可在日志详情中发起。
|
||||
|
||||
1. 响应解析
|
||||
`CREATE_PENDING` / `CREATE_SUCCESS` / `CREATE_FAILED`
|
||||
`CALLBACK_PENDING` / `CALLBACK_SUCCESS` / `CALLBACK_FAILED`
|
||||
`CALLBACK_RETRYING` / `CALLBACK_RETRY_FAILED`
|
||||
|
||||
- 实现里对响应成功的判定比较宽松:检查 `code`、`status`、`success`、`errno` 等字段(支持布尔、字符串 ‘0’/‘1’/‘success’)以判断是否成功,并解析常见的 message 字段 `msg|message|errmsg`。
|
||||
---
|
||||
|
||||
## 用印流程日志与业务回调
|
||||
|
||||
### 范围与标识
|
||||
|
||||
- appId:始终取配置 `iwork.app-id`,忽略请求值。
|
||||
- 主标识:全链路仅使用 `requestId`(发起返回、作废入参、日志查询、回调分发、重试都基于 requestId)。
|
||||
- 模板:`workflowId` 仅用于选择 iWork 模板,不作为业务标识。
|
||||
- 业务回调标识:`bizCallbackKey`(字符串,≤255),发起时提交,回调按该标识分发到具体业务回调入口。
|
||||
- 附件文件名:`xyywjFileName` 不存储也不参与处理;仅使用 `xyywjUrl`。
|
||||
|
||||
### 入口(REST)
|
||||
|
||||
- POST `/system/integration/iwork/workflow/create`:发起用印流程,返回 `requestId`。
|
||||
- POST `/system/integration/iwork/workflow/void`:作废流程,请求需携带 `requestId`。
|
||||
- POST `/system/integration/iwork/callback/file`(上游回调):接收 iWork 回调,校验 `requestId`,若缺 `bizCallbackKey` 则记录失败但不中断附件保存;下载并落库附件,记录回调日志(截断原文),并在提供 `bizCallbackKey` 时通过 RocketMQ 分发业务回调(自动/手工重试)。
|
||||
- POST `/system/integration/iwork/log/page`:分页查询用印回调日志(requestId / 业务单号 / bizCallbackKey / 状态 / 时间段)。
|
||||
- POST `/system/integration/iwork/log/retry`:手工重试业务回调,需权限,入参 `requestId`(状态为失败/超重试时可用)。
|
||||
|
||||
### 必填/校验要点
|
||||
|
||||
- 发起:`workflowId`、`jbr`、`yybm`、`fb`、`sqsj`、`yyqx`、`xyywjUrl`、`yysx`、`ywxtdjbh` 必填;`bizCallbackKey` 可选但建议提供;appId 忽略。
|
||||
- 作废:`requestId` 必填;`reason` 可选。
|
||||
- 回调:若无法找到业务附件(或不存在),标记用印失败并记日志,不抛出未捕获异常;记录原始回调文本(截断)。
|
||||
|
||||
### 处理流程(摘要)
|
||||
|
||||
1) 发起:校验必填 → 使用配置 appId 与模板 workflowId → 申请/缓存 token → 以 form-urlencoded 调 iWork → 返回 `requestId`,记录发起日志状态。
|
||||
2) 回调(/callback/file):校验 requestId + bizCallbackKey → 校验租户/必备字段 → 下载/保存附件(若缺业务附件则直接标记失败并记日志)→ 写入回调日志,保存原始回调请求/响应(截断)→ 通过 RocketMQ 发送业务回调消息(topic=`SYSTEM_IWORK_BIZ_CALLBACK`,tag=`bizCallbackKey`)。
|
||||
3) 业务处理 & 结果上报(跨模块/跨进程):业务模块订阅 `SYSTEM_IWORK_BIZ_CALLBACK` 对应 tag 处理后,将结果发布到 `SYSTEM_IWORK_BIZ_CALLBACK_RESULT`(tag 同 bizCallbackKey,携带 requestId / bizCallbackKey / success / errorMessage / payload / attempt / maxAttempts),system 模块消费结果并更新日志,失败且未超限时由 system 端按配置延迟重试再次投递回调消息。
|
||||
4) 重试:默认 3 次、间隔 5 秒,可配置(`iwork.callback.retry.*`),手工重试仍使用 `/log/retry`,由 system 模块重新投递 MQ 消息。
|
||||
5) 手工重试:需具备 iWork 模块权限;根据 requestId 将任务重新投递至回调分发器,不增加自动重试计数,可在日志详情中发起。
|
||||
|
||||
### 状态字面量(示例)
|
||||
|
||||
- `CREATE_PENDING` / `CREATE_SUCCESS` / `CREATE_FAILED`
|
||||
- `CALLBACK_PENDING` / `CALLBACK_SUCCESS` / `CALLBACK_FAILED`
|
||||
- `CALLBACK_RETRYING` / `CALLBACK_RETRY_FAILED`
|
||||
|
||||
> 实际实现可根据需要细化,唯一标识均为 requestId。
|
||||
|
||||
### 重试与配置
|
||||
|
||||
- 自动重试:默认 `maxAttempts=3`、`delaySeconds=5`,可配置(示例键:`iwork.callback.retry.max-attempts`、`iwork.callback.retry.delay-seconds`)。
|
||||
- 手工重试:权限校验(沿用 iWork 模块权限前缀);仅在失败/超重试状态下开放。
|
||||
- 幂等:按 requestId + bizCallbackKey 进行幂等检查,成功后不再重复分发,除非手工强制重试。
|
||||
|
||||
### 日志存储
|
||||
|
||||
- 表:`system_iwork_seal_callback_log`(示例字段)
|
||||
- `requestId`(PK)、`businessCode`(ywxtdjbh)、`bizCallbackKey`、`status`、`retryCount`、`lastErrorMessage`、`fileUrl`、`fileId`、`businessFileId`、`requestBody`、`responseBody`、`rawCallback`(截断保存原文)、`lastCallbackTime`、`creator`、`createTime`、`updateTime`。
|
||||
- 字段要点:
|
||||
- `rawCallback`:保存回调原始文本,截断存储(无需脱敏,不注明上限)。
|
||||
- 无业务附件或保存失败:写 `status=CREATE_FAILED` / `CALLBACK_FAILED` 并记录错误原因。
|
||||
|
||||
### 查询与展示
|
||||
|
||||
- 分页:沿用 `PageParam` 约束,`pageNo` 默认 1、`pageSize` 默认 10、上限 10000,仅分页浏览不支持导出。
|
||||
- 查询条件:`requestId`、`businessCode`(ywxtdjbh)、`bizCallbackKey`、`status`、时间范围(createTime / lastCallbackTime)。
|
||||
- 列表字段:requestId、业务单号、bizCallbackKey、状态、重试次数、最后错误、更新时间。
|
||||
- 详情:展示截断的回调原文(请求/响应)、错误原因、附件信息;提供“手工重试”按钮(需权限)。
|
||||
|
||||
---
|
||||
|
||||
@@ -326,8 +400,8 @@ curl -X POST -H "Content-Type: application/json" -d '{
|
||||
|
||||
## 小结与建议
|
||||
|
||||
- 在配置中补齐 `iwork.app-id`、`iwork.client-public-key`、`iwork.user-id`、`iwork.workflow-id` 等关键字段。
|
||||
- 优先在本地通过 `IWorkIntegrationService` Java API 调试,成功后再通过 Controller 的 REST 接口对外暴露。
|
||||
- 若遇到请求失败,查看应用日志(`[iWork]` 前缀的日志)与 iWork 网关返回 body,定位是注册、申请 token,还是业务接口(user-info/create/void)失败。
|
||||
- 在配置中补齐 `iwork.app-id`、`iwork.client-public-key`、`iwork.user-id`、`iwork.workflow-id` 等关键字段,并按需配置回调重试参数(默认 3 次,5 秒间隔)。
|
||||
- 优先在本地通过 `IWorkIntegrationService` Java API 调试,成功后再通过 Controller 的 REST 接口对外暴露;发起后请记录返回的 requestId(唯一标识)与 bizCallbackKey。
|
||||
- 若遇到请求失败,查看应用日志(`[iWork]` 前缀的日志)与 iWork 网关返回 body;若业务回调失败,可在日志页面查看截断原文并按权限发起手工重试。
|
||||
|
||||
文档已生成并保存到:`docs/iWork集成说明.md`。
|
||||
|
||||
BIN
docs/iwork 人力资源组织机构RESTFUL接口说明.pdf
Normal file
BIN
docs/iwork 人力资源组织机构RESTFUL接口说明.pdf
Normal file
Binary file not shown.
202
docs/数据总线用户使用指南.md
Normal file
202
docs/数据总线用户使用指南.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# 数据总线(Databus)用户使用指南
|
||||
|
||||
> **适用范围**:`zt-module-databus-server`(管理与运行时)、`zt-module-databus-api`(对外协议),面向已部署 `ztcloud` 后端 + `zt-vue-element` 前端的环境。文档区分 **业务管理员** 与 **第三方开发者** 两类读者,帮助您从零到一完成配置、发布与调用。
|
||||
|
||||
## 1. 角色视角与总体流程
|
||||
|
||||
| 角色 | 核心目标 | 关键系统入口 |
|
||||
| --- | --- | --- |
|
||||
| 业务管理员 | 在后台可视化界面中定义/编排 API,配置凭证、限流、访问日志,并确保版本可控 | 后台路径:`系统管理 → 数据总线`(或直接访问 `/#/databus`)|
|
||||
| 第三方开发者 | 按照后台下发的 `apiCode + version + appId`,满足安全约束后调用统一网关,解析响应并接入自身系统 | 对外入口:`{protocol}://{host}{basePath}/{apiCode}/{version}`(默认 Base Path `/admin-api/databus/api/portal`)|
|
||||
|
||||
完整流程示意:
|
||||
1. **管理员初始化**(执行 DM8 脚本、开通菜单与权限)。
|
||||
2. **管理员在“API 定义”中建模**(步骤、变换、策略、版本发布)。
|
||||
3. **管理员为业务方创建凭证**(`ZT-App-Id`、密钥、IP 白名单、匿名用户等)。
|
||||
4. **第三方拿到凭证后发起调用**(签名 → 加密 → 发送 → 解密响应)。
|
||||
5. **管理员通过限流/日志功能持续运维**,必要时回滚版本或刷新缓存。
|
||||
|
||||
## 2. 通用准备事项
|
||||
|
||||
### 2.1 服务与配置
|
||||
|
||||
- **运行中服务**:`zt-server` 网关、`zt-module-databus-*` 相关微服务、Redis(用于 nonce、防重放、限流计数)。
|
||||
- **关键配置**(摘自 `application.yml`):
|
||||
- `databus.api-portal.base-path`:默认 `/admin-api/databus/api/portal`,如需兼容旧系统可保留 `/databus/api/portal` 别名。
|
||||
- `allowed-ips/denied-ips`:无白名单时留空即放行全部;建议第三方出口 IP 全量登记。
|
||||
- `security`:`signature-type(MD5/SHA256)`、`encryption-type(AES/DES)`、`nonce-ttl`、`allowed-clock-skew-seconds`、是否强制请求/响应加密。
|
||||
- `enable-tenant-header`:如开启多租,会自动从 `ZT-Tenant-Id`(可自定义)透传。
|
||||
|
||||
### 2.2 数据库与菜单初始化(DM8)
|
||||
|
||||
| 场景 | 脚本(目录 `sql/dm/`) | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 初始化菜单与按钮权限 | `统一外部网关菜单_20251010.sql` | 建立“数据总线”一级菜单及 API/凭证/策略/访问日志页面的所有操作权限,支持重复执行。 |
|
||||
| 访问日志菜单补充 | `数据总线API访问日志菜单权限_20251028.sql` | 若已执行主菜单脚本仍找不到访问日志入口,可追加该脚本。 |
|
||||
| API 版本历史表 | `数据总线API版本历史表结构_备忘录模式_20251030.sql` | DM8 兼容语法,包含索引与字段注释,确保支持版本快照/回滚。 |
|
||||
| 访问日志表 | `数据总线API访问日志表结构_20251028.sql` | 记录追踪 ID、请求/响应、步骤结果等,供后台“访问日志”页面检索。 |
|
||||
| 示例/演示数据 | `../mysql/databus_sample_data.sql`(MySQL) | 可作为建模参考,字段一致,迁移到 DM 时仅需替换数据类型即可。 |
|
||||
|
||||
> **执行顺序建议**:先运行菜单脚本 → 赋予管理员角色对应按钮权限 → 执行表结构脚本 → 根据需要导入示例数据。
|
||||
|
||||
## 3. 业务管理员操作路线
|
||||
|
||||
### 3.1 角色与权限校验
|
||||
|
||||
1. 通过 DM8 脚本写入菜单/按钮后,将 `数据总线` 相关权限分配给目标角色(如 `system_admin`)。
|
||||
2. 确认角色拥有以下最少权限:`databus:gateway:*`、`databus:credential:*`、`databus:policy:*`、`databus:gateway:access-log:query`。
|
||||
|
||||
### 3.2 创建 API 定义
|
||||
|
||||
进入 **数据总线 → API 定义**,按以下步骤操作:
|
||||
|
||||
1. 点击“新建”填写基础信息:`API 编码 (apiCode)`、`版本 (version)`、`描述`、`HTTP Method`、`URI Pattern`(运行期仅用于文档,可自由描述)。
|
||||
2. 选择 **认证策略** 与 **限流策略**:默认策略可复用,也可在对应菜单先新建。
|
||||
3. 选择 **状态 = 草稿**,避免未完成配置就被调度。
|
||||
|
||||
> **提示**:Start/End 步骤系统会自动校验唯一性,必须至少存在一条 Start 与 End 步骤。
|
||||
|
||||
### 3.3 配置编排步骤与变换
|
||||
|
||||
1. 在 API 详情页新增步骤:
|
||||
- **类型**:HTTP / RPC / Script / Start / End。
|
||||
- **目标地址**:HTTP 需写 `METHOD http://host/path`,并按需填写 Header/Body 映射表达式。
|
||||
- **映射表达式**:支持 JSON 表达式,常用写法示例同 `databus_sample_data.sql` 中 `JSON::(...)`。
|
||||
- **重试/超时**:以 JSON 形式配置 `maxAttempts`、`delayMs`。
|
||||
2. 配置步骤级或 API 级 **Transform**:
|
||||
- 场景示例:请求前补链路追踪 ID(`REQUEST_PRE`)、响应前组装统一结构(`RESPONSE_PRE`)。
|
||||
- 校验规则:同一 `phase` 仅允许一条记录。
|
||||
|
||||
### 3.4 版本与发布
|
||||
|
||||
1. **保存即可生成版本快照**:系统会在 `databus_api_version` 中写入 JSON 快照并标记 `is_current=1`。
|
||||
2. **调试**:在 API 列表点击“调试”,通过 `POST /databus/gateway/invoke` 模拟真实请求,可自带 Header/Body。
|
||||
3. **上线**:修改 `状态=ONLINE` 后保存,系统自动刷新运行时 Flow。若需全量刷新,可在列表右上点击“刷新缓存”。
|
||||
4. **回滚**:在“版本历史”侧栏选择目标版本 → 点击“回滚”,系统将历史快照恢复为现有配置并重新发布。
|
||||
|
||||
### 3.5 凭证与策略运营
|
||||
|
||||
1. **客户端凭证**:
|
||||
- 进入“客户端凭证” → 新建 → 生成 `appId`、密钥、签名/加密算法。
|
||||
- 可绑定匿名用户(后台会在调用时自动模拟该用户登录)。
|
||||
- 支持 IP 白名单、描述及状态维护。
|
||||
2. **限流策略**:
|
||||
- 在“限流策略”菜单中新增 `FIXED_WINDOW` 类型,配置 `limit`、`windowSeconds`、`keyTemplate`。
|
||||
- 常见做法:`${apiCode}:${header.X-Client-Id}`,以不同客户端维度做计数。
|
||||
|
||||
### 3.6 监控与排障
|
||||
|
||||
1. **访问日志**:菜单路径“访问日志”,支持按 `traceId / apiCode / 时间` 检索详情,字段对应 `databus_api_access_log`。
|
||||
2. **常见问题定位**:
|
||||
- 401 → 校验凭证/签名/时间戳。
|
||||
- 429 → 限流策略触发,可在日志中观察 `status=1`/`errorCode=API_RATE_LIMIT_EXCEEDED`。
|
||||
- 5xx → 检查步骤 `stepResults` 中的 `error` 描述、Integration Flow 日志。
|
||||
|
||||
## 4. 第三方开发者操作路线
|
||||
|
||||
### 4.1 凭证申请
|
||||
|
||||
向业务管理员提供以下信息:调用系统名称、出口 IP 列表、是否需要匿名账号、预期调用 API 列表。管理员创建凭证后会得到:
|
||||
|
||||
- `ZT-App-Id`
|
||||
- `encryptionKey` + `encryptionType`(AES/DES)
|
||||
- `signatureType`(MD5/SHA256)
|
||||
- 可选:匿名内部用户、租户 ID
|
||||
|
||||
### 4.2 网络与安全要求
|
||||
|
||||
- 请求必须来自允许的 IP,且使用 HTTPS(推荐)或可信的内网段。
|
||||
- 时间戳与服务器偏差 ≤ `allowed-clock-skew-seconds`(默认 300 秒)。
|
||||
- `nonce` 在 `nonce-ttl-seconds` 内不得重复(默认 600 秒,服务器使用 Redis 去重)。
|
||||
- 若开启 `require-body-encryption`,请求体必须是 **加密后再 Base64 编码** 的字符串。
|
||||
|
||||
### 4.3 构建请求的 7 个步骤
|
||||
|
||||
| 步骤 | 动作 | 关键点 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 生成 `timestamp` | `long` 类型毫秒值;建议每次现取。 |
|
||||
| 2 | 生成 `nonce` | ≥8 位随机字符串,推荐 `UUID` 去掉 `-`。 |
|
||||
| 3 | 准备明文 Body | JSON 文本,记为 `plainBody`。GET 请求仍建议传空 JSON `{}`,以便签名。 |
|
||||
| 4 | 计算签名 | 将 `appId`、`timestamp`、`nonce`、`plainBody`(或 Query)按 key 排序拼接 `key=value`,使用约定算法得出 `signature`。 |
|
||||
| 5 | 加密 Body | 以凭证密钥对 `plainBody` 执行 AES/DES 加密,再 Base64(需与服务端配置一致)。 |
|
||||
| 6 | 组装请求头 | 至少包含 `ZT-App-Id`、`ZT-Timestamp`、`ZT-Nonce`、`ZT-Signature`、`Content-Type`,可附带 `ZT-Tenant-Id`、`X-Client-Id`。 |
|
||||
| 7 | 发送请求 | URL = `{basePath}/{apiCode}/{version}`,HTTP 方法与后台配置一致;响应若被加密需反向解密。 |
|
||||
|
||||
#### 请求示例(伪代码)
|
||||
|
||||
```text
|
||||
POST https://gw.example.com/admin-api/databus/api/portal/order.create/v1
|
||||
Headers:
|
||||
ZT-App-Id: demo-app
|
||||
ZT-Timestamp: 1732070400000
|
||||
ZT-Nonce: 0c5e2df9a1
|
||||
ZT-Signature: 8e377...
|
||||
X-Client-Id: mall
|
||||
Body (Base64):Q2hhcnNldGV4dC1CYXNlNjQgZW5jcnlwdGVkIGJvZHk=
|
||||
```
|
||||
|
||||
#### 响应处理
|
||||
|
||||
1. 读取 HTTP 状态与 `code/message/traceId` 字段。
|
||||
2. 若后台开启响应加密,需使用同一密钥解密得到真正的 `response` JSON。
|
||||
3. 保留 `traceId` 以便与管理员对齐日志。
|
||||
|
||||
### 4.4 常见故障排查
|
||||
|
||||
| 症状 | 常见原因 | 自助检查 |
|
||||
| --- | --- | --- |
|
||||
| 401 + `签名校验失败` | 拼接顺序错误 / 对象未序列化一致 | 确认按字典序拼接 `key=value`,body 使用明文。 |
|
||||
| 401 + `请求到达时间超出` | 客户端时间漂移 | 同步 NTP,或在发送前刷新服务器时间。 |
|
||||
| 401 + `重复请求` | `nonce` 重复或被重放 | 确保每次生成唯一随机串。 |
|
||||
| 403 | IP/应用被禁用 | 让管理员检查凭证状态、白名单。 |
|
||||
| 429 | 限流策略触发 | 调整并发或与管理员协商提升 `limit`。 |
|
||||
| 5xx + `步骤执行失败` | 后端步骤调用异常 | 提供 `traceId`,管理员可在访问日志中查看 `stepResults` 详细报错。 |
|
||||
|
||||
## 5. 策略、监控与日志
|
||||
|
||||
### 5.1 限流与审计
|
||||
|
||||
- 策略以 JSON 存储在 `databus_policy_rate_limit`,字段:`limit`(次数)、`windowSeconds`(窗口秒)及可选 `keyTemplate`。
|
||||
- 当前默认策略实现为 **Redis 固定窗口**:Key 格式 `databus:api:rl:{apiCode}:{version}:{X-Client-Id}`。
|
||||
- 触发限流时返回 `HTTP 429`,日志中 `status=1` 且 `errorCode=API_RATE_LIMIT_EXCEEDED`。
|
||||
- 如需关闭限流,管理员可在 `application.yml` 中设置 `databus.api-portal.enable-rate-limit=false` 或在 API 上解除策略绑定。
|
||||
- 审计日志由 `ApiGatewayAccessLogger` 写入 `databus_api_access_log`,可结合 ELK/BI 做分析。
|
||||
|
||||
### 5.2 访问日志使用
|
||||
|
||||
1. **建表**:执行 `sql/dm/数据总线API访问日志表结构_20251028.sql`(已含主键、索引、字段注释)。
|
||||
2. **后台入口**:`数据总线 → 访问日志`,需 `databus:gateway:access-log:query` 权限。
|
||||
3. **常见字段解释**:
|
||||
- `REQUEST_*`:存储原始请求;如启用请求加密,会记录解密后的明文。
|
||||
- `STEP_RESULTS`:JSON 数组,展示每个步骤的 `elapsed`、`request/response`、`error`,用于定位链路问题。
|
||||
- `TRACE_ID`:可与链路追踪或日志平台联动。
|
||||
4. **TODO**:如目标环境尚未执行该表脚本,请尽快在 DM 数据库运行,以免访问日志页面无法查询。
|
||||
|
||||
## 6. 演示数据与调试建议
|
||||
|
||||
- 使用 `sql/mysql/databus_sample_data.sql` 可一次性生成:
|
||||
- 3 个示例 API(聚合查询 / 用户画像 / 快速登录)。
|
||||
- 对应的步骤、变换、限流策略、发布记录。
|
||||
- 若目标环境为 DM,可参考该脚本结构,将 `AUTO_INCREMENT` 改为手动 ID、`datetime` 改 `timestamp`,即可导入。
|
||||
- 通过示例数据,管理员可演练以下操作:
|
||||
1. 打开 API 列表验证导入内容;
|
||||
2. 使用“调试”通过管理端发起调用,确认步骤链路;
|
||||
3. 让第三方按照真实流程调用,观察访问日志、限流表现。
|
||||
|
||||
## 7. 附录:常用 REST 接口速查
|
||||
|
||||
| 模块 | 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| API 定义 | GET | `/databus/gateway/definition/page` | 列表检索 |
|
||||
| | POST | `/databus/gateway/definition` | 新建/更新 API(PUT 更新) |
|
||||
| | DELETE | `/databus/gateway/definition/{id}` | 删除并注销 Flow |
|
||||
| API 版本 | GET | `/databus/gateway/version/list?apiId=` | 历史版本 |
|
||||
| | PUT | `/databus/gateway/version/rollback` | 回滚 |
|
||||
| 客户端凭证 | POST | `/databus/gateway/credential/create` | 创建凭证 |
|
||||
| 限流策略 | POST | `/databus/gateway/policy/rate-limit` | 创建/更新策略 |
|
||||
| 调试/刷新 | POST | `/databus/gateway/invoke` | 管理端调试 |
|
||||
| | POST | `/databus/gateway/cache/refresh` | 全量刷新 Flow |
|
||||
| 日志 | GET | `/databus/gateway/access-log/page` | 查看访问日志 |
|
||||
|
||||
---
|
||||
如仍有未覆盖的业务场景,可在 `docs/数据总线模块大致功能与调用介绍.md` 中查阅更底层的架构说明,再结合本文步骤完成落地。祝使用顺利!
|
||||
617
docs/菜单数据权限使用文档.md
Normal file
617
docs/菜单数据权限使用文档.md
Normal file
@@ -0,0 +1,617 @@
|
||||
# 菜单数据权限使用文档
|
||||
|
||||
## 📖 目录
|
||||
|
||||
1. [功能介绍](#功能介绍)
|
||||
2. [架构说明](#架构说明)
|
||||
3. [开发指南](#开发指南)
|
||||
4. [配置指南](#配置指南)
|
||||
5. [注意事项](#注意事项)
|
||||
6. [完整示例](#完整示例)
|
||||
7. [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 功能介绍
|
||||
|
||||
### 什么是菜单数据权限?
|
||||
|
||||
菜单数据权限是一种**基于菜单的动态数据过滤机制**,允许管理员为不同的角色配置不同的数据查询规则,实现细粒度的数据权限控制。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- ✅ **动态配置**:无需修改代码,通过页面配置即可实现数据过滤
|
||||
- ✅ **基于角色**:不同角色可以看到不同的数据
|
||||
- ✅ **灵活规则**:支持多种条件(等于、大于、IN、LIKE等)
|
||||
- ✅ **变量支持**:支持动态变量(如当前用户ID、部门ID等)
|
||||
- ✅ **SQL级别**:在SQL层面过滤,性能高效
|
||||
|
||||
### 使用场景
|
||||
|
||||
1. **部门数据隔离**:用户只能查看自己部门的数据
|
||||
2. **创建人过滤**:用户只能查看自己创建的数据
|
||||
3. **状态过滤**:某些角色只能查看特定状态的数据
|
||||
4. **自定义规则**:根据业务需求配置任意过滤条件
|
||||
|
||||
---
|
||||
|
||||
## 架构说明
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
zt-framework/
|
||||
└── zt-spring-boot-starter-biz-data-permission/ # 框架模块
|
||||
└── menudatapermission/
|
||||
├── annotation/ # @PermissionData 注解
|
||||
├── aop/ # AOP 切面
|
||||
├── context/ # ThreadLocal 上下文
|
||||
├── handler/ # MyBatis 拦截器
|
||||
├── model/ # DTO 模型
|
||||
└── util/ # 工具类
|
||||
|
||||
zt-module-system/
|
||||
└── zt-module-system-server/ # 业务模块
|
||||
└── framework/permission/
|
||||
└── MenuDataRuleLoaderImpl.java # 规则加载器实现
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
```
|
||||
1. 用户访问页面(如:角色管理)
|
||||
↓
|
||||
2. Controller 方法上有 @PermissionData 注解
|
||||
↓
|
||||
3. AOP 切面拦截,根据 pageComponent 查询菜单ID
|
||||
↓
|
||||
4. 加载该菜单下用户角色关联的数据规则
|
||||
↓
|
||||
5. 将规则存入 ThreadLocal
|
||||
↓
|
||||
6. MyBatis 执行查询时,拦截器读取规则
|
||||
↓
|
||||
7. 构建 SQL WHERE 条件并添加到查询中
|
||||
↓
|
||||
8. 返回过滤后的数据
|
||||
↓
|
||||
9. finally 块清理 ThreadLocal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
### ✅ 检查 Maven 依赖
|
||||
|
||||
在使用菜单数据权限功能前,**必须确保业务模块已引入框架依赖**。
|
||||
|
||||
#### 1. 检查依赖是否存在
|
||||
|
||||
打开业务模块的 `pom.xml` 文件(如 `zt-module-xxx-server/pom.xml`),检查是否包含以下依赖:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.zt.plat</groupId>
|
||||
<artifactId>zt-spring-boot-starter-biz-data-permission</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
#### 2. 如何检查?
|
||||
|
||||
**方法1:查看 pom.xml**
|
||||
```bash
|
||||
# 在项目根目录执行
|
||||
grep -r "zt-spring-boot-starter-biz-data-permission" zt-module-xxx/zt-module-xxx-server/pom.xml
|
||||
```
|
||||
|
||||
**方法2:在 IDE 中查看**
|
||||
- IDEA:打开 `pom.xml`,搜索 `zt-spring-boot-starter-biz-data-permission`
|
||||
- 或者查看 Maven 依赖树
|
||||
|
||||
#### 3. 如果没有依赖,如何添加?
|
||||
|
||||
在业务模块的 `pom.xml` 中添加:
|
||||
|
||||
```xml
|
||||
<dependencies>
|
||||
<!-- 其他依赖 -->
|
||||
|
||||
<!-- 菜单数据权限框架 -->
|
||||
<dependency>
|
||||
<groupId>com.zt.plat</groupId>
|
||||
<artifactId>zt-spring-boot-starter-biz-data-permission</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
#### 4. 已包含该依赖的模块
|
||||
|
||||
以下模块已默认包含该依赖,可直接使用:
|
||||
- ✅ `zt-module-system-server`
|
||||
- ⚠️ 其他业务模块需自行检查
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 步骤1:在 Controller 方法上添加注解
|
||||
|
||||
在需要数据过滤的查询方法上添加 `@PermissionData` 注解:
|
||||
|
||||
```java
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.annotation.PermissionData;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/system/role")
|
||||
public class RoleController {
|
||||
|
||||
@GetMapping("/page")
|
||||
@PermissionData(pageComponent = "system/role/index") // 指定页面组件路径
|
||||
public CommonResult<PageResult<RoleRespVO>> getRolePage(RolePageReqVO pageReqVO) {
|
||||
PageResult<RoleDO> pageResult = roleService.getRolePage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, RoleRespVO.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤2:确定 pageComponent 值
|
||||
|
||||
`pageComponent` 是前端页面组件的路径,用于关联菜单:
|
||||
|
||||
- **格式**:`模块名/功能名/页面名`
|
||||
- **示例**:
|
||||
- `system/role/index` - 角色管理页面
|
||||
- `system/user/index` - 用户管理页面
|
||||
- `system/dept/index` - 部门管理页面
|
||||
|
||||
**如何查找 pageComponent?**
|
||||
|
||||
1. 打开前端项目,找到对应的 Vue 文件路径
|
||||
2. 例如:`src/views/system/role/index.vue`
|
||||
3. pageComponent 就是:`system/role/index`
|
||||
|
||||
### 步骤3:在菜单表中配置 component 字段
|
||||
|
||||
确保数据库 `system_menu` 表中,该菜单的 `component` 字段值与 `pageComponent` 一致:
|
||||
|
||||
```sql
|
||||
-- 示例:角色管理菜单
|
||||
UPDATE system_menu
|
||||
SET component = 'system/role/index'
|
||||
WHERE id = 角色管理菜单ID;
|
||||
```
|
||||
|
||||
### 注解参数说明
|
||||
|
||||
```java
|
||||
@PermissionData(
|
||||
pageComponent = "system/role/index", // 必填:页面组件路径
|
||||
enable = true // 可选:是否启用,默认 true
|
||||
)
|
||||
```
|
||||
|
||||
**何时设置 `enable = false`?**
|
||||
|
||||
当某个方法不需要数据权限过滤时(如管理员查看所有数据):
|
||||
|
||||
```java
|
||||
@GetMapping("/all")
|
||||
@PermissionData(pageComponent = "system/role/index", enable = false)
|
||||
public CommonResult<List<RoleRespVO>> getAllRoles() {
|
||||
// 返回所有角色,不受数据权限限制
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置指南
|
||||
|
||||
### 步骤1:配置菜单
|
||||
|
||||
1. 登录系统,进入 **系统管理 > 菜单管理**
|
||||
2. 找到需要配置数据权限的菜单(如:角色管理)
|
||||
3. 确认该菜单的 **组件路径** 字段与代码中的 `pageComponent` 一致
|
||||
|
||||
### 步骤2:配置数据规则
|
||||
|
||||
1. 在菜单列表中,点击对应菜单的 **"数据规则"** 按钮
|
||||
2. 点击 **"新增规则"** 按钮
|
||||
3. 填写规则信息:
|
||||
|
||||
#### 规则字段说明
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **规则名称** | 规则的描述性名称 | `只看自己创建的角色` |
|
||||
| **规则字段** | 数据库表的字段名 | `creator`、`dept_id`、`status` |
|
||||
| **规则条件** | 比较条件 | `=`、`IN`、`LIKE` 等 |
|
||||
| **规则值** | 比较的值,支持变量 | `#{userId}`、`#{deptId}` |
|
||||
| **状态** | 启用/禁用 | 启用 |
|
||||
| **排序** | 规则执行顺序 | 0 |
|
||||
|
||||
#### 支持的规则条件
|
||||
|
||||
| 条件 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `=` | 等于 | `creator = #{userId}` |
|
||||
| `!=` | 不等于 | `status != 0` |
|
||||
| `>` | 大于 | `create_time > '2024-01-01'` |
|
||||
| `<` | 小于 | `sort < 100` |
|
||||
| `>=` | 大于等于 | `age >= 18` |
|
||||
| `<=` | 小于等于 | `price <= 1000` |
|
||||
| `IN` | 包含 | `dept_id IN (#{deptIds})` |
|
||||
| `NOT_IN` | 不包含 | `status NOT_IN (0,1)` |
|
||||
| `LIKE` | 模糊匹配 | `name LIKE '张'` |
|
||||
| `NOT_LIKE` | 不匹配 | `name NOT_LIKE '测试'` |
|
||||
| `IS_NULL` | 为空 | `deleted_time IS_NULL` |
|
||||
| `IS_NOT_NULL` | 不为空 | `phone IS_NOT_NULL` |
|
||||
| `BETWEEN` | 区间 | `age BETWEEN 18,60` |
|
||||
| `NOT_BETWEEN` | 不在区间 | `score NOT_BETWEEN 0,60` |
|
||||
| `SQL_RULE` | 自定义SQL | `(dept_id = 1 OR creator = 2)` |
|
||||
|
||||
#### 支持的变量
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `#{userId}` | 当前用户ID | `creator = #{userId}` |
|
||||
| `#{username}` | 当前用户名 | `create_by = #{username}` |
|
||||
| `#{deptId}` | 当前部门ID | `dept_id = #{deptId}` |
|
||||
| `#{companyId}` | 当前公司ID | `company_id = #{companyId}` |
|
||||
| `#{tenantId}` | 当前租户ID | `tenant_id = #{tenantId}` |
|
||||
| `#{deptIds}` | 用户所有部门ID | `dept_id IN (#{deptIds})` |
|
||||
| `#{companyIds}` | 用户所有公司ID | `company_id IN (#{companyIds})` |
|
||||
| `#{postIds}` | 用户所有岗位ID | `post_id IN (#{postIds})` |
|
||||
|
||||
#### SQL_RULE 类型详解
|
||||
|
||||
`SQL_RULE` 是一种特殊的规则类型,允许你直接编写自定义 SQL 表达式,实现更复杂的数据过滤逻辑。
|
||||
|
||||
**什么时候使用 SQL_RULE?**
|
||||
|
||||
- ✅ 需要 `OR` 逻辑连接多个条件
|
||||
- ✅ 需要组合多个字段的复杂判断
|
||||
- ✅ 需要嵌套条件或括号分组
|
||||
- ✅ 标准规则条件无法满足业务需求
|
||||
|
||||
**配置方法**
|
||||
|
||||
| 字段 | 配置值 |
|
||||
|------|--------|
|
||||
| 规则条件 | 选择 `SQL_RULE` |
|
||||
| 规则值 | 直接填写 SQL WHERE 条件表达式 |
|
||||
| 规则字段 | 可以留空(不使用) |
|
||||
|
||||
**配置示例**
|
||||
|
||||
1. **OR 逻辑 - 查看自己创建的或自己部门的数据**
|
||||
|
||||
```
|
||||
规则条件:SQL_RULE
|
||||
规则值:(creator = #{userId} OR dept_id = #{deptId})
|
||||
```
|
||||
|
||||
生成的 SQL:
|
||||
```sql
|
||||
WHERE (creator = '123' OR dept_id = '456')
|
||||
```
|
||||
|
||||
2. **多字段组合 - 特定部门的已启用数据**
|
||||
|
||||
```
|
||||
规则条件:SQL_RULE
|
||||
规则值:(dept_id IN (#{deptIds}) AND status = 1)
|
||||
```
|
||||
|
||||
生成的 SQL:
|
||||
```sql
|
||||
WHERE (dept_id IN ('100', '101', '102') AND status = 1)
|
||||
```
|
||||
|
||||
3. **复杂嵌套条件 - 管理员或本部门负责人**
|
||||
|
||||
```
|
||||
规则条件:SQL_RULE
|
||||
规则值:(role_type = 'admin' OR (dept_id = #{deptId} AND is_leader = 1))
|
||||
```
|
||||
|
||||
生成的 SQL:
|
||||
```sql
|
||||
WHERE (role_type = 'admin' OR (dept_id = '456' AND is_leader = 1))
|
||||
```
|
||||
|
||||
4. **时间范围 + 状态过滤**
|
||||
|
||||
```
|
||||
规则条件:SQL_RULE
|
||||
规则值:(create_time >= '2024-01-01' AND status IN (1, 2))
|
||||
```
|
||||
|
||||
生成的 SQL:
|
||||
```sql
|
||||
WHERE (create_time >= '2024-01-01' AND status IN (1, 2))
|
||||
```
|
||||
|
||||
**工作原理**
|
||||
|
||||
当规则条件为 `SQL_RULE` 时:
|
||||
|
||||
1. 系统会忽略"规则字段"和"规则条件"
|
||||
2. 直接使用"规则值"中的 SQL 表达式
|
||||
3. 先替换表达式中的变量(如 `#{userId}`)
|
||||
4. 将替换后的表达式直接添加到 SQL WHERE 子句中
|
||||
|
||||
代码实现(MenuDataPermissionRule.java:64-67):
|
||||
```java
|
||||
// 处理 SQL_RULE 类型(自定义 SQL)
|
||||
if ("SQL_RULE".equals(ruleConditions)) {
|
||||
return actualValue; // 直接返回替换变量后的 SQL 表达式
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 重要警告**
|
||||
|
||||
1. **SQL 注入风险**
|
||||
- SQL_RULE 直接拼接到 SQL 中,存在注入风险
|
||||
- ✅ **安全做法**:只使用预定义变量(`#{userId}` 等)
|
||||
- ❌ **危险做法**:不要在规则值中拼接用户输入的内容
|
||||
|
||||
2. **字段名必须正确**
|
||||
- SQL_RULE 中的字段名必须与数据库表字段完全一致
|
||||
- 错误的字段名会导致 SQL 查询报错
|
||||
- 建议先在数据库中测试 SQL 语句
|
||||
|
||||
3. **括号很重要**
|
||||
- 建议始终用括号包裹整个表达式:`(condition1 OR condition2)`
|
||||
- 避免与其他规则或系统条件产生优先级问题
|
||||
|
||||
4. **变量替换**
|
||||
- 变量会被替换为带引号的字符串值
|
||||
- 例如:`#{userId}` → `'123'`
|
||||
- 数据库会自动处理类型转换
|
||||
|
||||
**SQL_RULE vs 普通规则对比**
|
||||
|
||||
| 特性 | 普通规则 | SQL_RULE |
|
||||
|------|---------|----------|
|
||||
| 配置难度 | 简单,选择即可 | 需要 SQL 知识 |
|
||||
| 灵活性 | 有限,单一条件 | 非常灵活,任意表达式 |
|
||||
| 安全性 | 高,系统控制 | 需要注意 SQL 注入 |
|
||||
| OR 逻辑 | ❌ 不支持 | ✅ 支持 |
|
||||
| 嵌套条件 | ❌ 不支持 | ✅ 支持 |
|
||||
| 多规则组合 | AND 连接 | 单个规则内实现 |
|
||||
| 错误提示 | 友好 | SQL 错误信息 |
|
||||
|
||||
**最佳实践**
|
||||
|
||||
1. **优先使用普通规则**:能用普通规则解决的,不要用 SQL_RULE
|
||||
2. **测试后再上线**:在测试环境验证 SQL 语句正确性
|
||||
3. **添加注释**:在规则名称中说明 SQL_RULE 的用途
|
||||
4. **定期审查**:定期检查 SQL_RULE 规则,删除不再使用的
|
||||
5. **权限控制**:限制能配置 SQL_RULE 的管理员权限
|
||||
|
||||
### 步骤3:关联角色
|
||||
|
||||
1. 配置完规则后,进入 **系统管理 > 角色管理**
|
||||
2. 编辑需要应用规则的角色
|
||||
3. 在 **"数据权限"** 标签页中,勾选对应的菜单数据规则
|
||||
4. 保存角色配置
|
||||
|
||||
### 步骤4:测试验证
|
||||
|
||||
1. 使用该角色的用户登录系统
|
||||
2. 访问配置了数据规则的页面
|
||||
3. 验证数据是否按规则过滤
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### ⚠️ 重要提醒
|
||||
|
||||
#### 1. 规则字段必须与数据库表字段一致
|
||||
|
||||
**错误示例**:
|
||||
```
|
||||
规则字段:dept_id_xxx
|
||||
数据库字段:dept_id
|
||||
结果:SQL 查询报错!
|
||||
```
|
||||
|
||||
**正确做法**:
|
||||
- 在配置规则前,先确认数据库表结构
|
||||
- 字段名必须完全一致(包括大小写)
|
||||
- 前端已添加警告提示,请仔细阅读
|
||||
|
||||
#### 2. 只在菜单/页面级别配置规则
|
||||
|
||||
- ✅ **菜单/页面**(type=2):需要配置数据规则
|
||||
- ❌ **目录**(type=1):不需要配置
|
||||
- ❌ **按钮**(type=3):不需要配置
|
||||
|
||||
前端已自动隐藏目录和按钮的"数据规则"按钮。
|
||||
|
||||
#### 3. 多个规则使用 AND 连接
|
||||
|
||||
如果为同一个菜单配置了多个规则,它们会用 `AND` 连接:
|
||||
|
||||
```sql
|
||||
-- 规则1:creator = #{userId}
|
||||
-- 规则2:status = 1
|
||||
-- 最终SQL:
|
||||
WHERE creator = '当前用户ID' AND status = 1
|
||||
```
|
||||
|
||||
#### 4. 变量不存在时的处理
|
||||
|
||||
如果配置了不存在的变量(如 `#{unknownVar}`),系统会:
|
||||
- 记录警告日志
|
||||
- 将变量替换为空字符串
|
||||
- 可能导致查询结果为空
|
||||
|
||||
**建议**:使用前端下拉框选择变量,避免手动输入错误。
|
||||
|
||||
#### 5. 性能考虑
|
||||
|
||||
- 数据规则在 SQL 层面过滤,性能较好
|
||||
- 但过多的规则会增加 SQL 复杂度
|
||||
- 建议每个菜单不超过 5 条规则
|
||||
|
||||
#### 6. 禁用数据权限的场景
|
||||
|
||||
某些查询不应该受数据权限限制,需要添加 `@DataPermission(enable = false)`:
|
||||
|
||||
```java
|
||||
// 示例:查询所有根级部门(不受数据权限限制)
|
||||
@Override
|
||||
@DataPermission(enable = false)
|
||||
public List<DeptDO> getTopLevelDeptList() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 场景:角色管理 - 只看自己创建的角色
|
||||
|
||||
#### 1. 后端代码
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/system/role")
|
||||
public class RoleController {
|
||||
|
||||
@Resource
|
||||
private RoleService roleService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获得角色分页")
|
||||
@PreAuthorize("@ss.hasPermission('system:role:query')")
|
||||
@PermissionData(pageComponent = "system/role/index") // 添加数据权限注解
|
||||
public CommonResult<PageResult<RoleRespVO>> getRolePage(RolePageReqVO pageReqVO) {
|
||||
PageResult<RoleDO> pageResult = roleService.getRolePage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, RoleRespVO.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 菜单配置
|
||||
|
||||
确保 `system_menu` 表中角色管理菜单的配置:
|
||||
|
||||
```sql
|
||||
SELECT id, name, component
|
||||
FROM system_menu
|
||||
WHERE name = '角色管理';
|
||||
|
||||
-- 结果:
|
||||
-- id: 101
|
||||
-- name: 角色管理
|
||||
-- component: system/role/index ✅ 与代码中的 pageComponent 一致
|
||||
```
|
||||
|
||||
#### 3. 数据规则配置
|
||||
|
||||
在页面上配置规则:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|------|
|
||||
| 规则名称 | 只看自己创建的角色 |
|
||||
| 规则字段 | `creator` |
|
||||
| 规则条件 | `=` |
|
||||
| 规则值 | `#{userId}` |
|
||||
| 状态 | 启用 |
|
||||
| 排序 | 0 |
|
||||
|
||||
#### 4. 角色关联
|
||||
|
||||
1. 进入角色管理,编辑"普通用户"角色
|
||||
2. 在"数据权限"标签页,勾选"只看自己创建的角色"规则
|
||||
3. 保存
|
||||
|
||||
#### 5. 生成的 SQL
|
||||
|
||||
当普通用户(ID=123)查询角色列表时,实际执行的 SQL:
|
||||
|
||||
```sql
|
||||
SELECT * FROM system_role
|
||||
WHERE deleted = 0
|
||||
AND tenant_id = 1
|
||||
AND creator = '123' -- 自动添加的数据权限条件
|
||||
ORDER BY sort ASC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么配置了规则但不生效?
|
||||
|
||||
**可能原因**:
|
||||
|
||||
1. ✅ 检查 Controller 方法是否添加了 `@PermissionData` 注解
|
||||
2. ✅ 检查 `pageComponent` 是否与菜单的 `component` 字段一致
|
||||
3. ✅ 检查规则是否启用(状态=启用)
|
||||
4. ✅ 检查角色是否关联了该规则
|
||||
5. ✅ 检查用户是否拥有该角色
|
||||
|
||||
### Q2: 如何查看实际执行的 SQL?
|
||||
|
||||
在 `application.yaml` 中开启 SQL 日志:
|
||||
|
||||
```yaml
|
||||
# 方式1:MyBatis Plus SQL 日志
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
# 方式2:Logback 日志
|
||||
logging:
|
||||
level:
|
||||
com.zt.plat.module.system.dal.mysql: debug
|
||||
```
|
||||
|
||||
### Q3: 多个规则如何组合?
|
||||
|
||||
多个规则使用 `AND` 连接,如果需要 `OR` 逻辑,使用 `SQL_RULE` 类型:
|
||||
|
||||
```
|
||||
规则条件:SQL_RULE
|
||||
规则值:(dept_id = #{deptId} OR creator = #{userId})
|
||||
```
|
||||
|
||||
### Q4: 如何配置"查看本部门及下级部门"的规则?
|
||||
|
||||
这需要在业务层实现,菜单数据权限只支持简单的字段过滤。建议使用原有的部门数据权限功能。
|
||||
|
||||
### Q5: 规则字段配置错误会怎样?
|
||||
|
||||
会导致 SQL 查询报错,因为数据库找不到该字段。前端已添加警告提示,请仔细检查。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
菜单数据权限提供了一种灵活、高效的数据过滤机制:
|
||||
|
||||
✅ **开发简单**:只需添加一个注解
|
||||
✅ **配置灵活**:通过页面配置,无需修改代码
|
||||
✅ **性能高效**:SQL 层面过滤,不影响性能
|
||||
✅ **易于维护**:规则集中管理,便于调整
|
||||
|
||||
**最佳实践**:
|
||||
1. 优先使用预定义变量,避免手动输入
|
||||
2. 规则字段必须与数据库表字段一致
|
||||
3. 合理使用规则,避免过度复杂
|
||||
4. 定期review规则配置,删除无用规则
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请联系:
|
||||
- 开发团队:ZT
|
||||
- 文档版本:v1.0
|
||||
- 更新日期:2026-01-27
|
||||
152
docs/附件上传注解使用说明.md
Normal file
152
docs/附件上传注解使用说明.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# @FileUploadController 注解使用说明
|
||||
|
||||
本文档说明 `@FileUploadController` 的使用方式、调用流程、以及报文结构示例。适用范围为业务创建接口在请求体中携带附件列表的场景,并在业务创建成功后自动建立“业务-附件”的关联关系。
|
||||
|
||||
## 1. 适用前提
|
||||
|
||||
- **附件列表在业务创建请求体中传入**。
|
||||
- **业务创建返回结构统一为**:`{ code, data: { id, code } }`。
|
||||
- 不涉及二次请求与补充请求头流程(文档不包含相关内容)。
|
||||
|
||||
## 2. 注解字段说明
|
||||
|
||||
位置:`com.zt.plat.framework.business.annotation.FileUploadController`
|
||||
|
||||
| 字段 | 说明 | 默认值 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `filesKey` | 请求体中附件数组的路径 | `files` | 支持多层级路径,如 `data.files` |
|
||||
| `fileNameKey` | 附件名称字段 | `name` | 解析附件名称使用 |
|
||||
| `fileIdKey` | 附件ID字段 | `id` | 解析附件ID使用 |
|
||||
| `source` | 业务来源标识 | `default` | 例如 `bpm` / `oa` / `hr` 等 |
|
||||
| `primaryKey` | 响应中业务主键路径 | `data.id` | 支持多层级路径 |
|
||||
| `codeKey` | 响应中业务编码路径 | `data.code` | 支持多层级路径 |
|
||||
|
||||
## 3. 关联处理机制(简述)
|
||||
|
||||
当 Controller 标注 `@FileUploadController` 后:
|
||||
|
||||
1. 请求进入时会读取注解配置并写入 request attribute。
|
||||
2. 请求处理完成后,过滤器会读取:
|
||||
- 请求体中的附件数组
|
||||
- 响应体中的业务主键与业务编码
|
||||
3. 当响应 `code == 0` 时,自动调用 `BusinessFileApi.batchCreateBusinessFile(...)` 建立附件关联。
|
||||
|
||||
> 注意:如果请求体中未包含附件数组,或响应中未返回业务主键,则不会产生关联。
|
||||
|
||||
## 4. 标准调用流程
|
||||
|
||||
### 步骤 1:业务 Controller 标注注解
|
||||
|
||||
```java
|
||||
@FileUploadController(source = "template.contract")
|
||||
public class DemoContractController extends AbstractFileUploadController implements BusinessControllerMarker {
|
||||
static {
|
||||
FileUploadController annotation = DemoContractController.class.getAnnotation(FileUploadController.class);
|
||||
if (annotation != null) {
|
||||
setFileUploadInfo(annotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2:可选获取 source 信息
|
||||
|
||||
请求:
|
||||
|
||||
```
|
||||
GET /template/demo-contract/upload-info
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { "source": "template.contract" },
|
||||
"msg": "成功"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 3:业务创建请求携带附件列表
|
||||
|
||||
请求体示例(默认字段):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "测试合同",
|
||||
"amount": 2000,
|
||||
"files": [
|
||||
{ "id": 10125, "name": "合同附件.pdf" },
|
||||
{ "id": 10126, "name": "补充材料.docx" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 4:业务创建成功返回
|
||||
|
||||
响应体示例(统一结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": 90001,
|
||||
"code": "HT-2026-0001"
|
||||
},
|
||||
"msg": "成功"
|
||||
}
|
||||
```
|
||||
|
||||
系统会自动读取:
|
||||
|
||||
- `files` 中的 `id/name`
|
||||
- `data.id` 和 `data.code`
|
||||
|
||||
并建立业务附件关联。
|
||||
|
||||
## 5. 多层级字段示例(可选配置)
|
||||
|
||||
如果业务请求体与响应体存在嵌套结构,可通过注解自定义路径:
|
||||
|
||||
```java
|
||||
@FileUploadController(
|
||||
source = "template.contract",
|
||||
filesKey = "data.files",
|
||||
fileIdKey = "fileId",
|
||||
fileNameKey = "fileName",
|
||||
primaryKey = "data.businessId",
|
||||
codeKey = "data.businessCode"
|
||||
)
|
||||
```
|
||||
|
||||
对应请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"files": [
|
||||
{ "fileId": 1001, "fileName": "合同.pdf" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对应响应体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"businessId": 90001,
|
||||
"businessCode": "HT-2026-0001"
|
||||
},
|
||||
"msg": "成功"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 常见注意事项
|
||||
|
||||
- 响应 `code != 0` 时不会执行附件关联。
|
||||
- `files` 必须是数组,否则会被忽略。
|
||||
- 若业务返回未包含 `primaryKey` 对应的字段,附件不会关联。
|
||||
- `source` 建议以业务模块唯一标识命名,便于后续查询与归档。
|
||||
44
pom.xml
44
pom.xml
@@ -172,50 +172,6 @@
|
||||
</resources>
|
||||
</build>
|
||||
|
||||
<!-- 使用 huawei / aliyun 的 Maven 源,提升下载速度 -->
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>huaweicloud</id>
|
||||
<name>huawei</name>
|
||||
<url>https://mirrors.huaweicloud.com/repository/maven/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>aliyunmaven</id>
|
||||
<name>aliyun</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
</repository>
|
||||
|
||||
<repository>
|
||||
<id>spring-milestones</id>
|
||||
<name>Spring Milestones</name>
|
||||
<url>https://repo.spring.io/milestone</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spring-snapshots</id>
|
||||
<name>Spring Snapshots</name>
|
||||
<url>https://repo.spring.io/snapshot</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>ZT</id>
|
||||
<name>中铜 ZStack 私服</name>
|
||||
<url>http://172.16.46.63:30708/repository/zt-cloud/</url>
|
||||
<releases>
|
||||
<updatePolicy>always</updatePolicy>
|
||||
<checksumPolicy>warn</checksumPolicy>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
<updatePolicy>always</updatePolicy>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<distributionManagement>
|
||||
<repository>
|
||||
<id>ZT</id>
|
||||
|
||||
73
sql/dm/20260126菜单数据规则表.sql
Normal file
73
sql/dm/20260126菜单数据规则表.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- ----------------------------
|
||||
-- Table structure for system_menu_data_rule
|
||||
-- ----------------------------
|
||||
CREATE TABLE system_menu_data_rule (
|
||||
id bigint NOT NULL PRIMARY KEY,
|
||||
menu_id bigint NOT NULL,
|
||||
rule_name varchar(100) NOT NULL,
|
||||
rule_column varchar(100) DEFAULT NULL NULL,
|
||||
rule_conditions varchar(20) NOT NULL,
|
||||
rule_value varchar(500) NOT NULL,
|
||||
status smallint DEFAULT 1 NOT NULL,
|
||||
sort int DEFAULT 0 NOT NULL,
|
||||
remark varchar(500) DEFAULT NULL NULL,
|
||||
creator varchar(64) DEFAULT '' NULL,
|
||||
create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater varchar(64) DEFAULT '' NULL,
|
||||
update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted bit DEFAULT '0' NOT NULL,
|
||||
tenant_id bigint DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
-- CREATE INDEX idx_menu_data_rule_menu ON system_menu_data_rule (menu_id);
|
||||
-- CREATE INDEX idx_menu_data_rule_tenant ON system_menu_data_rule (tenant_id);
|
||||
|
||||
COMMENT ON COLUMN system_menu_data_rule.id IS '规则ID';
|
||||
COMMENT ON COLUMN system_menu_data_rule.menu_id IS '菜单ID';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_name IS '规则名称';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_column IS '规则字段(数据库列名)';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_conditions IS '规则条件(=、>、<、IN、LIKE等)';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_value IS '规则值(支持变量如#{userId}、#{deptId})';
|
||||
COMMENT ON COLUMN system_menu_data_rule.status IS '状态(0=禁用 1=启用)';
|
||||
COMMENT ON COLUMN system_menu_data_rule.sort IS '排序';
|
||||
COMMENT ON COLUMN system_menu_data_rule.remark IS '备注';
|
||||
COMMENT ON COLUMN system_menu_data_rule.creator IS '创建者';
|
||||
COMMENT ON COLUMN system_menu_data_rule.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN system_menu_data_rule.updater IS '更新者';
|
||||
COMMENT ON COLUMN system_menu_data_rule.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN system_menu_data_rule.deleted IS '是否删除';
|
||||
COMMENT ON COLUMN system_menu_data_rule.tenant_id IS '租户编号';
|
||||
COMMENT ON TABLE system_menu_data_rule IS '菜单数据规则表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for system_role_menu_data_rule
|
||||
-- ----------------------------
|
||||
CREATE TABLE system_role_menu_data_rule (
|
||||
id bigint NOT NULL PRIMARY KEY,
|
||||
role_id bigint NOT NULL,
|
||||
menu_id bigint NOT NULL,
|
||||
data_rule_id bigint NOT NULL,
|
||||
creator varchar(64) DEFAULT '' NULL,
|
||||
create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater varchar(64) DEFAULT '' NULL,
|
||||
update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted bit DEFAULT '0' NOT NULL,
|
||||
tenant_id bigint DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
-- CREATE INDEX idx_rmdr_role ON system_role_menu_data_rule (role_id);
|
||||
-- CREATE INDEX idx_rmdr_menu ON system_role_menu_data_rule (menu_id);
|
||||
-- CREATE INDEX idx_rmdr_tenant ON system_role_menu_data_rule (tenant_id);
|
||||
-- CREATE INDEX idx_rmdr_role_menu_rule ON system_role_menu_data_rule (role_id, menu_id, data_rule_id);
|
||||
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.id IS '自增主键';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.role_id IS '角色ID';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.menu_id IS '菜单ID';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.data_rule_id IS '数据规则ID';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.creator IS '创建者';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.updater IS '更新者';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.deleted IS '是否删除';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.tenant_id IS '租户编号';
|
||||
COMMENT ON TABLE system_role_menu_data_rule IS '角色菜单数据规则关联表';
|
||||
@@ -340,7 +340,8 @@ CREATE TABLE infra_file (
|
||||
updater varchar(64) DEFAULT '' NULL,
|
||||
update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted bit DEFAULT '0' NOT NULL,
|
||||
DOWNLOAD_COUNT INT DEFAULT 0 NOT NULL
|
||||
DOWNLOAD_COUNT INT DEFAULT 0 NOT NULL,
|
||||
DOWNLOADABLE SMALLINT DEFAULT 1 NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN infra_file.id IS '文件编号';
|
||||
@@ -358,6 +359,7 @@ COMMENT ON COLUMN infra_file.updater IS '更新者';
|
||||
COMMENT ON COLUMN infra_file.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN infra_file.deleted IS '是否删除';
|
||||
COMMENT ON COLUMN INFRA_FILE.DOWNLOAD_COUNT IS '下载次数';
|
||||
COMMENT ON COLUMN INFRA_FILE.DOWNLOADABLE IS '是否可下载(1是,0否)';
|
||||
COMMENT ON TABLE infra_file IS '文件表';
|
||||
|
||||
CREATE INDEX idx_infra_file_hash ON infra_file(hash);
|
||||
@@ -4313,3 +4315,74 @@ VALUES
|
||||
(5022, 2, '日期格式', 'DATE', 'system_sequence_detail_rule_type', 0, 'success', '', '日期格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0),
|
||||
(5023, 3, '数字格式', 'NUMBER', 'system_sequence_detail_rule_type', 0, 'info', '', '数字格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0),
|
||||
(5024, 4, '自定义格式', 'CUSTOM', 'system_sequence_detail_rule_type', 0, 'warning', '', '自定义格式规则', 'admin', SYSDATE, 'admin', SYSDATE, 0);
|
||||
|
||||
/*
|
||||
增加菜单规则(system_menu_data_rule)和规则角色关联表(system_role_menu_data_rule),同增量脚本:sql/dm/20260126菜单数据规则表.sql
|
||||
*/
|
||||
-- ----------------------------
|
||||
-- Table structure for system_menu_data_rule
|
||||
-- ----------------------------
|
||||
CREATE TABLE system_menu_data_rule (
|
||||
id bigint NOT NULL PRIMARY KEY,
|
||||
menu_id bigint NOT NULL,
|
||||
rule_name varchar(100) NOT NULL,
|
||||
rule_column varchar(100) DEFAULT NULL NULL,
|
||||
rule_conditions varchar(20) NOT NULL,
|
||||
rule_value varchar(500) NOT NULL,
|
||||
status smallint DEFAULT 1 NOT NULL,
|
||||
sort int DEFAULT 0 NOT NULL,
|
||||
remark varchar(500) DEFAULT NULL NULL,
|
||||
creator varchar(64) DEFAULT '' NULL,
|
||||
create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater varchar(64) DEFAULT '' NULL,
|
||||
update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted bit DEFAULT '0' NOT NULL,
|
||||
tenant_id bigint DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
|
||||
COMMENT ON COLUMN system_menu_data_rule.id IS '规则ID';
|
||||
COMMENT ON COLUMN system_menu_data_rule.menu_id IS '菜单ID';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_name IS '规则名称';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_column IS '规则字段(数据库列名)';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_conditions IS '规则条件(=、>、<、IN、LIKE等)';
|
||||
COMMENT ON COLUMN system_menu_data_rule.rule_value IS '规则值(支持变量如#{userId}、#{deptId})';
|
||||
COMMENT ON COLUMN system_menu_data_rule.status IS '状态(0=禁用 1=启用)';
|
||||
COMMENT ON COLUMN system_menu_data_rule.sort IS '排序';
|
||||
COMMENT ON COLUMN system_menu_data_rule.remark IS '备注';
|
||||
COMMENT ON COLUMN system_menu_data_rule.creator IS '创建者';
|
||||
COMMENT ON COLUMN system_menu_data_rule.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN system_menu_data_rule.updater IS '更新者';
|
||||
COMMENT ON COLUMN system_menu_data_rule.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN system_menu_data_rule.deleted IS '是否删除';
|
||||
COMMENT ON COLUMN system_menu_data_rule.tenant_id IS '租户编号';
|
||||
COMMENT ON TABLE system_menu_data_rule IS '菜单数据规则表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for system_role_menu_data_rule
|
||||
-- ----------------------------
|
||||
CREATE TABLE system_role_menu_data_rule (
|
||||
id bigint NOT NULL PRIMARY KEY,
|
||||
role_id bigint NOT NULL,
|
||||
menu_id bigint NOT NULL,
|
||||
data_rule_id bigint NOT NULL,
|
||||
creator varchar(64) DEFAULT '' NULL,
|
||||
create_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater varchar(64) DEFAULT '' NULL,
|
||||
update_time datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted bit DEFAULT '0' NOT NULL,
|
||||
tenant_id bigint DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.id IS '自增主键';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.role_id IS '角色ID';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.menu_id IS '菜单ID';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.data_rule_id IS '数据规则ID';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.creator IS '创建者';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.updater IS '更新者';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.deleted IS '是否删除';
|
||||
COMMENT ON COLUMN system_role_menu_data_rule.tenant_id IS '租户编号';
|
||||
COMMENT ON TABLE system_role_menu_data_rule IS '角色菜单数据规则关联表';
|
||||
|
||||
43
sql/dm/system_iwork_seal_log_dm8.sql
Normal file
43
sql/dm/system_iwork_seal_log_dm8.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- iWork 用印回调日志(DM8)
|
||||
-- 表:system_iwork_seal_log
|
||||
-- 序列:system_iwork_seal_log_seq
|
||||
|
||||
-- 清理旧对象(若存在)
|
||||
DROP TABLE IF EXISTS system_iwork_seal_log;
|
||||
|
||||
CREATE TABLE system_iwork_seal_log (
|
||||
id BIGINT NOT NULL,
|
||||
request_id VARCHAR(128) NOT NULL,
|
||||
business_code VARCHAR(128),
|
||||
biz_callback_key VARCHAR(255),
|
||||
status INTEGER,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
max_retry INTEGER,
|
||||
last_error_message VARCHAR(512),
|
||||
raw_callback VARCHAR(2000),
|
||||
last_callback_time DATETIME,
|
||||
creator VARCHAR(64),
|
||||
create_time DATETIME DEFAULT SYSDATE,
|
||||
updater VARCHAR(64),
|
||||
update_time DATETIME DEFAULT SYSDATE,
|
||||
deleted SMALLINT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (request_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE system_iwork_seal_log IS 'iWork 用印回调日志';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.id IS '主键';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.request_id IS 'iWork requestId 唯一标识';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.business_code IS '业务单号';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.biz_callback_key IS '业务回调标识';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.status IS '状态枚举';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.retry_count IS '已重试次数';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.max_retry IS '最大重试次数快照';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.last_error_message IS '最后错误信息';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.raw_callback IS '回调原文截断';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.last_callback_time IS '最近回调时间';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.creator IS '创建者';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.updater IS '更新者';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.update_time IS '最后更新时间';
|
||||
COMMENT ON COLUMN system_iwork_seal_log.deleted IS '是否删除';
|
||||
132
sql/dm/外部系统推送配置初始化_DM8_20260120.sql
Normal file
132
sql/dm/外部系统推送配置初始化_DM8_20260120.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
-- DM8 外部系统推送配置初始化脚本
|
||||
-- 用于配置不同公司/部门/业务类型下的外部系统推送开关
|
||||
-- 创建日期:2026-01-20
|
||||
|
||||
-- 重复执行时请先备份数据
|
||||
-- DROP TABLE IF EXISTS system_external_push_config;
|
||||
|
||||
-- 创建表
|
||||
CREATE TABLE system_external_push_config (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
company_id BIGINT DEFAULT NULL NULL,
|
||||
dept_id BIGINT DEFAULT NULL NULL,
|
||||
business_type VARCHAR(32) DEFAULT NULL NULL,
|
||||
external_system VARCHAR(64) DEFAULT NULL NULL,
|
||||
enable_push BIT DEFAULT '1' NOT NULL,
|
||||
remark VARCHAR(512) DEFAULT NULL NULL,
|
||||
tenant_id BIGINT DEFAULT 0 NOT NULL,
|
||||
creator VARCHAR(64) DEFAULT '' NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater VARCHAR(64) DEFAULT '' NULL,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted TINYINT DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
-- 表和字段注释
|
||||
COMMENT ON TABLE system_external_push_config IS '外部系统推送配置';
|
||||
COMMENT ON COLUMN system_external_push_config.id IS '主键编号';
|
||||
COMMENT ON COLUMN system_external_push_config.company_id IS '公司编号(关联 system_dept 表的 is_company=1 的记录,为空表示不限制公司)';
|
||||
COMMENT ON COLUMN system_external_push_config.dept_id IS '部门编号(关联 system_dept 表的 is_company=0 的记录,为空表示公司级配置)';
|
||||
COMMENT ON COLUMN system_external_push_config.business_type IS '业务类型(PURCHASE/SALE/PRODUCTION,为空表示所有业务类型)';
|
||||
COMMENT ON COLUMN system_external_push_config.external_system IS '外部系统标识(ERP/IWORK/等,为空表示所有外部系统)';
|
||||
COMMENT ON COLUMN system_external_push_config.enable_push IS '是否启用推送(1启用 0停用)';
|
||||
COMMENT ON COLUMN system_external_push_config.remark IS '备注';
|
||||
COMMENT ON COLUMN system_external_push_config.tenant_id IS '租户编号';
|
||||
COMMENT ON COLUMN system_external_push_config.creator IS '创建者';
|
||||
COMMENT ON COLUMN system_external_push_config.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN system_external_push_config.updater IS '更新者';
|
||||
COMMENT ON COLUMN system_external_push_config.update_time IS '更新时间';
|
||||
COMMENT ON COLUMN system_external_push_config.deleted IS '删除标记(0未删除 1已删除)';
|
||||
|
||||
/*-- 唯一索引:租户+公司+部门+业务类型+外部系统的组合唯一
|
||||
-- 注意:因为 dept_id 和 external_system 可以为 NULL,使用 COALESCE 处理
|
||||
CREATE UNIQUE INDEX uk_external_push_config_unique
|
||||
ON system_external_push_config (tenant_id, company_id, COALESCE(dept_id, 0), business_type, COALESCE(external_system, ''));
|
||||
|
||||
-- 辅助索引:按公司查询
|
||||
CREATE INDEX idx_external_push_config_company
|
||||
ON system_external_push_config (tenant_id, company_id);
|
||||
|
||||
-- 辅助索引:按业务类型查询
|
||||
CREATE INDEX idx_external_push_config_biz_type
|
||||
ON system_external_push_config (tenant_id, business_type);
|
||||
|
||||
|
||||
|
||||
-- 初始化菜单权限数据
|
||||
-- 主菜单
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id,
|
||||
path, icon, component, component_name, status,
|
||||
visible, keep_alive, always_show, creator, create_time,
|
||||
updater, update_time, deleted
|
||||
)
|
||||
SELECT
|
||||
20060, '外部系统推送配置', '', 2, 60, 1,
|
||||
'external-push-config', 'setting', 'system/push/config/index', 'SystemExternalPushConfig', 0,
|
||||
'1', '1', '1', 'admin', SYSDATE,
|
||||
'admin', SYSDATE, 0
|
||||
FROM dual
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE id = 20060
|
||||
);
|
||||
|
||||
-- 查询权限
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id,
|
||||
path, icon, component, status, visible, keep_alive,
|
||||
creator, create_time, updater, update_time, deleted
|
||||
)
|
||||
SELECT
|
||||
2006001, '推送配置查询', 'system:external-push-config:query', 3, 1, 20060,
|
||||
'', '', '', 0, '1', '1',
|
||||
'admin', SYSDATE, 'admin', SYSDATE, 0
|
||||
FROM dual
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE id = 2006001
|
||||
);
|
||||
|
||||
-- 创建权限
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id,
|
||||
path, icon, component, status, visible, keep_alive,
|
||||
creator, create_time, updater, update_time, deleted
|
||||
)
|
||||
SELECT
|
||||
2006002, '推送配置创建', 'system:external-push-config:create', 3, 2, 20060,
|
||||
'', '', '', 0, '1', '1',
|
||||
'admin', SYSDATE, 'admin', SYSDATE, 0
|
||||
FROM dual
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE id = 2006002
|
||||
);
|
||||
|
||||
-- 修改权限
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id,
|
||||
path, icon, component, status, visible, keep_alive,
|
||||
creator, create_time, updater, update_time, deleted
|
||||
)
|
||||
SELECT
|
||||
2006003, '推送配置修改', 'system:external-push-config:update', 3, 3, 20060,
|
||||
'', '', '', 0, '1', '1',
|
||||
'admin', SYSDATE, 'admin', SYSDATE, 0
|
||||
FROM dual
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE id = 2006003
|
||||
);
|
||||
|
||||
-- 删除权限
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id,
|
||||
path, icon, component, status, visible, keep_alive,
|
||||
creator, create_time, updater, update_time, deleted
|
||||
)
|
||||
SELECT
|
||||
2006004, '推送配置删除', 'system:external-push-config:delete', 3, 4, 20060,
|
||||
'', '', '', 0, '1', '1',
|
||||
'admin', SYSDATE, 'admin', SYSDATE, 0
|
||||
FROM dual
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_menu WHERE id = 2006004
|
||||
);*/
|
||||
@@ -218,6 +218,7 @@ CREATE TABLE databus_api_client_credential (
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updater VARCHAR(64) DEFAULT '' NOT NULL,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
enable_encryption BIT DEFAULT '1' NOT NULL,
|
||||
deleted BIT DEFAULT '0' NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<jsoup.version>1.18.1</jsoup.version>
|
||||
<lombok.version>1.18.36</lombok.version>
|
||||
<mapstruct.version>1.6.3</mapstruct.version>
|
||||
<hutool-5.version>5.8.35</hutool-5.version>
|
||||
<hutool-5.version>5.8.43</hutool-5.version>
|
||||
<hutool-6.version>6.0.0-M19</hutool-6.version>
|
||||
<easyexcel.version>4.0.3</easyexcel.version>
|
||||
<velocity.version>2.4.1</velocity.version>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.zt.plat.framework.common.enums;
|
||||
|
||||
/**
|
||||
* 验证码发送方式
|
||||
*/
|
||||
public enum VerifyCodeSendType {
|
||||
SMS, // 短信验证码
|
||||
E_OFFICE // e办消息推送
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.zt.plat.framework.common.util.asyncTask;
|
||||
|
||||
import com.alibaba.ttl.TransmittableThreadLocal;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.*;
|
||||
@@ -9,7 +11,7 @@ import java.util.concurrent.*;
|
||||
* 多次提交,一次等待
|
||||
*/
|
||||
public class AsyncLatchUtils {
|
||||
private static final ThreadLocal<List<TaskInfo>> THREAD_LOCAL = ThreadLocal.withInitial(LinkedList::new);
|
||||
private static final TransmittableThreadLocal<List<TaskInfo>> THREAD_LOCAL = TransmittableThreadLocal.withInitial(LinkedList::new);
|
||||
|
||||
/**
|
||||
* 提交一个异步任务
|
||||
|
||||
@@ -59,13 +59,15 @@ public class BusinessDeptHandleUtil {
|
||||
}
|
||||
// 如果有 deptId,校验其是否属于该 companyId
|
||||
if (deptIdHeader != null) {
|
||||
boolean valid = companyDeptSetByCompanyId.stream().anyMatch(info -> String.valueOf(info.getDeptId()).equals(deptIdHeader));
|
||||
if (!valid) {
|
||||
Optional<CompanyDeptInfo> matched = companyDeptSetByCompanyId.stream()
|
||||
.filter(info -> String.valueOf(info.getDeptId()).equals(deptIdHeader))
|
||||
.findFirst();
|
||||
if (matched.isEmpty()) {
|
||||
return null;
|
||||
} else {
|
||||
// 部门存在,放行
|
||||
return new HashSet<>();
|
||||
}
|
||||
// 部门存在,先设置登录信息再放行
|
||||
applyAutoSelection(currentLoginUser, request, matched.get());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
if (companyDeptSetByCompanyId.size() == 1) {
|
||||
CompanyDeptInfo singleCompanyDept = companyDeptSetByCompanyId.iterator().next();
|
||||
@@ -183,10 +185,10 @@ public class BusinessDeptHandleUtil {
|
||||
if (loginUser != null) {
|
||||
loginUser.setVisitCompanyId(Long.valueOf(info.getCompanyId()));
|
||||
loginUser.setVisitCompanyName(info.getCompanyName());
|
||||
loginUser.setVisitCompanyCode(info.getCompanyName());
|
||||
loginUser.setVisitCompanyCode(info.getCompanyCode());
|
||||
loginUser.setVisitDeptId(Long.valueOf(info.getDeptId()));
|
||||
loginUser.setVisitDeptName(info.getDeptName());
|
||||
loginUser.setVisitDeptCode(info.getDeptName());
|
||||
loginUser.setVisitDeptCode(info.getDeptCode());
|
||||
}
|
||||
request.setAttribute(WebFrameworkUtils.HEADER_VISIT_COMPANY_ID, info.getCompanyId());
|
||||
if (info.getCompanyName() != null) {
|
||||
|
||||
@@ -4,12 +4,15 @@ import com.zt.plat.framework.datapermission.core.aop.CompanyDataPermissionIgnore
|
||||
import com.zt.plat.framework.datapermission.core.aop.DataPermissionAnnotationAdvisor;
|
||||
import com.zt.plat.framework.datapermission.core.aop.DeptDataPermissionIgnoreAspect;
|
||||
import com.zt.plat.framework.datapermission.core.db.DataPermissionRuleHandler;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.aop.MenuDataPermissionAspect;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.handler.MenuDataPermissionHandler;
|
||||
import com.zt.plat.framework.datapermission.core.rule.DataPermissionRule;
|
||||
import com.zt.plat.framework.datapermission.core.rule.DataPermissionRuleFactory;
|
||||
import com.zt.plat.framework.datapermission.core.rule.DataPermissionRuleFactoryImpl;
|
||||
import com.zt.plat.framework.mybatis.core.util.MyBatisUtils;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@@ -21,6 +24,7 @@ import java.util.List;
|
||||
* @author ZT
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@MapperScan("com.zt.plat.framework.datapermission.core.menudatapermission.dal.mapper")
|
||||
public class ZtDataPermissionAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@@ -40,6 +44,21 @@ public class ZtDataPermissionAutoConfiguration {
|
||||
return handler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MenuDataPermissionHandler menuDataPermissionHandler(MybatisPlusInterceptor interceptor) {
|
||||
// 创建菜单数据权限处理器
|
||||
MenuDataPermissionHandler handler = new MenuDataPermissionHandler();
|
||||
DataPermissionInterceptor inner = new DataPermissionInterceptor(handler);
|
||||
// 添加到 interceptor 中,放在部门数据权限之后
|
||||
MyBatisUtils.addInterceptor(interceptor, inner, 1);
|
||||
return handler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MenuDataPermissionAspect menuDataPermissionAspect() {
|
||||
return new MenuDataPermissionAspect();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DataPermissionAnnotationAdvisor dataPermissionAnnotationAdvisor() {
|
||||
return new DataPermissionAnnotationAdvisor();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 数据权限注解
|
||||
* 标注在Controller方法上,表示该方法需要应用菜单数据规则
|
||||
*
|
||||
* 参考JeecgBoot的实现方式
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Target({ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PermissionData {
|
||||
|
||||
/**
|
||||
* 页面组件路径
|
||||
* 用于匹配菜单表中的component字段
|
||||
* 例如:system/role 对应角色管理菜单
|
||||
*/
|
||||
String pageComponent();
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
boolean enable() default true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.aop;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.annotation.PermissionData;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.context.MenuDataRuleContextHolder;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.model.MenuDataRuleDTO;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.service.MenuDataRuleLoader;
|
||||
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单数据权限切面
|
||||
* 拦截 @PermissionData 注解的方法,加载并应用菜单数据规则
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MenuDataPermissionAspect {
|
||||
|
||||
@Resource
|
||||
private MenuDataRuleLoader menuDataRuleLoader;
|
||||
|
||||
@Around("@annotation(com.zt.plat.framework.datapermission.core.menudatapermission.annotation.PermissionData)")
|
||||
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
// 获取方法签名
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
PermissionData annotation = method.getAnnotation(PermissionData.class);
|
||||
|
||||
// 如果未启用,直接执行
|
||||
if (!annotation.enable()) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前用户ID
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (userId == null) {
|
||||
log.debug("[MenuDataPermissionAspect][未登录,跳过菜单数据权限]");
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// 从注解获取pageComponent
|
||||
String pageComponent = annotation.pageComponent();
|
||||
if (pageComponent == null || pageComponent.isEmpty()) {
|
||||
log.warn("[MenuDataPermissionAspect][未指定pageComponent,跳过菜单数据权限]");
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// 根据pageComponent查询菜单ID
|
||||
Long menuId = menuDataRuleLoader.getMenuIdByPageComponent(pageComponent);
|
||||
if (menuId == null) {
|
||||
log.warn("[MenuDataPermissionAspect][未找到匹配的菜单: {},跳过菜单数据权限]", pageComponent);
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
log.debug("[MenuDataPermissionAspect][pageComponent: {} 对应菜单ID: {}]", pageComponent, menuId);
|
||||
|
||||
// 加载用户的菜单数据规则
|
||||
List<MenuDataRuleDTO> rules = menuDataRuleLoader.getUserMenuDataRules(userId, menuId);
|
||||
|
||||
if (CollUtil.isEmpty(rules)) {
|
||||
log.debug("[MenuDataPermissionAspect][用户 {} 在菜单 {} 下无数据规则]", userId, menuId);
|
||||
} else {
|
||||
log.debug("[MenuDataPermissionAspect][用户 {} 在菜单 {} 下加载了 {} 条数据规则]",
|
||||
userId, menuId, rules.size());
|
||||
// 将规则存入 ThreadLocal
|
||||
MenuDataRuleContextHolder.setRules(rules);
|
||||
}
|
||||
|
||||
// 执行目标方法
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
// 清理 ThreadLocal
|
||||
MenuDataRuleContextHolder.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.config;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.handler.MenuDataPermissionHandler;
|
||||
import com.zt.plat.framework.mybatis.core.util.MyBatisUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 菜单数据权限配置类
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Configuration
|
||||
@ComponentScan("com.zt.plat.framework.datapermission.core.menudatapermission")
|
||||
public class MenuDataPermissionConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(MybatisPlusInterceptor.class)
|
||||
public MenuDataPermissionHandler menuDataPermissionHandler(MybatisPlusInterceptor interceptor) {
|
||||
// 创建菜单数据权限处理器
|
||||
MenuDataPermissionHandler handler = new MenuDataPermissionHandler();
|
||||
|
||||
// 创建 DataPermissionInterceptor 拦截器
|
||||
DataPermissionInterceptor inner = new DataPermissionInterceptor(handler);
|
||||
|
||||
// 添加到 interceptor 中
|
||||
// 添加在索引1的位置,在部门数据权限之后,但在分页插件之前
|
||||
MyBatisUtils.addInterceptor(interceptor, inner, 1);
|
||||
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.context;
|
||||
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.model.MenuDataRuleDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单数据规则上下文持有者
|
||||
* 使用 ThreadLocal 存储当前请求的菜单数据规则
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
public class MenuDataRuleContextHolder {
|
||||
|
||||
private static final ThreadLocal<List<MenuDataRuleDTO>> CONTEXT = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 设置当前请求的菜单数据规则
|
||||
*
|
||||
* @param rules 规则列表
|
||||
*/
|
||||
public static void setRules(List<MenuDataRuleDTO> rules) {
|
||||
CONTEXT.set(rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的菜单数据规则
|
||||
*
|
||||
* @return 规则列表
|
||||
*/
|
||||
public static List<MenuDataRuleDTO> getRules() {
|
||||
return CONTEXT.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前请求的菜单数据规则
|
||||
*/
|
||||
public static void clear() {
|
||||
CONTEXT.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.dal.mapper;
|
||||
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.model.MenuDataRuleDTO;
|
||||
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单数据权限 Mapper
|
||||
* 用于查询菜单和菜单数据规则
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Mapper
|
||||
public interface MenuDataPermissionMapper {
|
||||
|
||||
/**
|
||||
* 根据页面组件路径获取菜单ID
|
||||
*
|
||||
* @param component 页面组件路径,如:system/role/index
|
||||
* @return 菜单ID,如果未找到返回null
|
||||
*/
|
||||
@Select("SELECT id FROM system_menu WHERE component = #{component} AND deleted = 0 LIMIT 1")
|
||||
Long selectMenuIdByComponent(@Param("component") String component);
|
||||
|
||||
/**
|
||||
* 获取用户在指定菜单下的有效数据规则
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param menuId 菜单ID
|
||||
* @return 数据规则列表
|
||||
*/
|
||||
@TenantIgnore
|
||||
@Select("<script>" +
|
||||
"SELECT mdr.rule_column, mdr.rule_conditions, mdr.rule_value, mdr.status " +
|
||||
"FROM system_menu_data_rule mdr " +
|
||||
"INNER JOIN system_role_menu_data_rule rmdr ON mdr.id = rmdr.data_rule_id " +
|
||||
"INNER JOIN system_user_role ur ON rmdr.role_id = ur.role_id " +
|
||||
"WHERE mdr.menu_id = #{menuId} " +
|
||||
"AND ur.user_id = #{userId} " +
|
||||
"AND mdr.status = 1 " +
|
||||
"AND mdr.deleted = 0 " +
|
||||
"AND rmdr.deleted = 0 " +
|
||||
"AND ur.deleted = 0" +
|
||||
"</script>")
|
||||
List<MenuDataRuleDTO> selectUserMenuDataRules(@Param("userId") Long userId,
|
||||
@Param("menuId") Long menuId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.handler;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.util.MenuDataPermissionRule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.JSQLParserException;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.schema.Table;
|
||||
|
||||
/**
|
||||
* 菜单数据权限处理器
|
||||
* 基于 MyBatis Plus 的数据权限插件,应用菜单数据规则到 SQL 查询
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Slf4j
|
||||
public class MenuDataPermissionHandler implements MultiDataPermissionHandler {
|
||||
|
||||
@Override
|
||||
public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
|
||||
try {
|
||||
// 从 ThreadLocal 获取菜单数据规则并构建 SQL 条件
|
||||
String sqlCondition = MenuDataPermissionRule.buildSqlCondition();
|
||||
|
||||
if (StrUtil.isBlank(sqlCondition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将 SQL 字符串解析为 Expression 对象
|
||||
Expression expression = CCJSqlParserUtil.parseCondExpression(sqlCondition);
|
||||
log.debug("[MenuDataPermissionHandler][表: {}, 添加条件: {}]", table.getName(), sqlCondition);
|
||||
return expression;
|
||||
|
||||
} catch (JSQLParserException e) {
|
||||
log.error("[MenuDataPermissionHandler][解析 SQL 条件失败]", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 菜单数据规则 DTO
|
||||
* 用于在框架层传递菜单数据规则信息,避免依赖业务模块的数据库实体
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Data
|
||||
public class MenuDataRuleDTO {
|
||||
|
||||
/**
|
||||
* 规则字段(数据库列名)
|
||||
*/
|
||||
private String ruleColumn;
|
||||
|
||||
/**
|
||||
* 规则条件(=、>、<、IN、LIKE等)
|
||||
*/
|
||||
private String ruleConditions;
|
||||
|
||||
/**
|
||||
* 规则值(支持变量如#{userId}、#{deptId})
|
||||
*/
|
||||
private String ruleValue;
|
||||
|
||||
/**
|
||||
* 状态(0=禁用 1=启用)
|
||||
*/
|
||||
private Integer status;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.service;
|
||||
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.model.MenuDataRuleDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单数据规则加载器接口
|
||||
* 负责加载菜单和菜单数据规则
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
public interface MenuDataRuleLoader {
|
||||
|
||||
/**
|
||||
* 根据页面组件路径获取菜单ID
|
||||
*
|
||||
* @param pageComponent 页面组件路径,如:system/role/index
|
||||
* @return 菜单ID,如果未找到返回null
|
||||
*/
|
||||
Long getMenuIdByPageComponent(String pageComponent);
|
||||
|
||||
/**
|
||||
* 获取用户在指定菜单下的数据规则
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param menuId 菜单ID
|
||||
* @return 数据规则列表
|
||||
*/
|
||||
List<MenuDataRuleDTO> getUserMenuDataRules(Long userId, Long menuId);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.service.impl;
|
||||
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.dal.mapper.MenuDataPermissionMapper;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.model.MenuDataRuleDTO;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.service.MenuDataRuleLoader;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单数据规则加载器默认实现
|
||||
* 直接从数据库加载菜单数据规则
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MenuDataRuleLoaderImpl implements MenuDataRuleLoader {
|
||||
|
||||
@Resource
|
||||
private MenuDataPermissionMapper menuDataPermissionMapper;
|
||||
|
||||
@Override
|
||||
public Long getMenuIdByPageComponent(String pageComponent) {
|
||||
try {
|
||||
return menuDataPermissionMapper.selectMenuIdByComponent(pageComponent);
|
||||
} catch (Exception e) {
|
||||
log.error("[MenuDataRuleLoaderImpl][根据pageComponent查询菜单ID失败: {}]", pageComponent, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MenuDataRuleDTO> getUserMenuDataRules(Long userId, Long menuId) {
|
||||
try {
|
||||
return menuDataPermissionMapper.selectUserMenuDataRules(userId, menuId);
|
||||
} catch (Exception e) {
|
||||
log.error("[MenuDataRuleLoaderImpl][查询用户菜单数据规则失败: userId={}, menuId={}]",
|
||||
userId, menuId, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.security.core.LoginUser;
|
||||
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 数据规则变量替换工具类
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Slf4j
|
||||
public class DataRuleVariableUtils {
|
||||
|
||||
private static final Pattern VARIABLE_PATTERN = Pattern.compile("#\\{([^}]+)}");
|
||||
|
||||
/**
|
||||
* 替换规则值中的变量
|
||||
*
|
||||
* @param ruleValue 规则值,如 "#{userId}" 或 "#{deptId}"
|
||||
* @return 替换后的值
|
||||
*/
|
||||
public static String replaceVariables(String ruleValue) {
|
||||
if (StrUtil.isBlank(ruleValue)) {
|
||||
return ruleValue;
|
||||
}
|
||||
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return ruleValue;
|
||||
}
|
||||
|
||||
Matcher matcher = VARIABLE_PATTERN.matcher(ruleValue);
|
||||
StringBuffer result = new StringBuffer();
|
||||
|
||||
while (matcher.find()) {
|
||||
String variable = matcher.group(1);
|
||||
String replacement = getVariableValue(variable, loginUser);
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变量对应的值
|
||||
*
|
||||
* @param variable 变量名,如 "userId", "deptId"
|
||||
* @param loginUser 当前登录用户
|
||||
* @return 变量值
|
||||
*/
|
||||
private static String getVariableValue(String variable, LoginUser loginUser) {
|
||||
if (loginUser == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (variable) {
|
||||
case "userId":
|
||||
return loginUser.getId() != null ? loginUser.getId().toString() : "";
|
||||
case "username":
|
||||
return loginUser.getInfo() != null ?
|
||||
loginUser.getInfo().getOrDefault(LoginUser.INFO_KEY_USERNAME, "") : "";
|
||||
case "deptId":
|
||||
return loginUser.getVisitDeptId() != null ?
|
||||
loginUser.getVisitDeptId().toString() : "";
|
||||
case "companyId":
|
||||
return loginUser.getVisitCompanyId() != null ?
|
||||
loginUser.getVisitCompanyId().toString() : "";
|
||||
case "tenantId":
|
||||
return loginUser.getTenantId() != null ?
|
||||
loginUser.getTenantId().toString() : "";
|
||||
case "deptIds":
|
||||
return loginUser.getInfo() != null ?
|
||||
loginUser.getInfo().getOrDefault(LoginUser.INFO_KEY_DEPT_IDS, "") : "";
|
||||
case "companyIds":
|
||||
return loginUser.getInfo() != null ?
|
||||
loginUser.getInfo().getOrDefault(LoginUser.INFO_KEY_COMPANY_IDS, "") : "";
|
||||
case "postIds":
|
||||
return loginUser.getInfo() != null ?
|
||||
loginUser.getInfo().getOrDefault(LoginUser.INFO_KEY_POST_IDS, "") : "";
|
||||
default:
|
||||
// 未知变量,记录警告并返回空字符串
|
||||
log.warn("[DataRuleVariableUtils][未知的变量: {},请检查数据规则配置]", variable);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.zt.plat.framework.datapermission.core.menudatapermission.util;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.context.MenuDataRuleContextHolder;
|
||||
import com.zt.plat.framework.datapermission.core.menudatapermission.model.MenuDataRuleDTO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 菜单数据权限规则
|
||||
* 用于构建 SQL WHERE 条件
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Slf4j
|
||||
public class MenuDataPermissionRule {
|
||||
|
||||
/**
|
||||
* 构建 SQL WHERE 条件
|
||||
*
|
||||
* @return SQL 条件字符串,如 "dept_id = 1 AND status = 1"
|
||||
*/
|
||||
public static String buildSqlCondition() {
|
||||
List<MenuDataRuleDTO> rules = MenuDataRuleContextHolder.getRules();
|
||||
if (CollUtil.isEmpty(rules)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> conditions = rules.stream()
|
||||
.filter(rule -> rule.getStatus() == 1) // 只处理启用的规则
|
||||
.map(MenuDataPermissionRule::buildSingleCondition)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (CollUtil.isEmpty(conditions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 多个规则用 AND 连接
|
||||
return "(" + String.join(" AND ", conditions) + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个规则的 SQL 条件
|
||||
*
|
||||
* @param rule 规则
|
||||
* @return SQL 条件字符串
|
||||
*/
|
||||
private static String buildSingleCondition(MenuDataRuleDTO rule) {
|
||||
String ruleColumn = rule.getRuleColumn();
|
||||
String ruleConditions = rule.getRuleConditions();
|
||||
String ruleValue = rule.getRuleValue();
|
||||
|
||||
// 替换变量
|
||||
String actualValue = DataRuleVariableUtils.replaceVariables(ruleValue);
|
||||
|
||||
if (StrUtil.isBlank(ruleColumn) || StrUtil.isBlank(ruleConditions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理 SQL_RULE 类型(自定义 SQL)
|
||||
if ("SQL_RULE".equals(ruleConditions)) {
|
||||
return actualValue;
|
||||
}
|
||||
|
||||
// 处理 IS_NULL 和 IS_NOT_NULL
|
||||
if ("IS_NULL".equals(ruleConditions)) {
|
||||
return ruleColumn + " IS NULL";
|
||||
}
|
||||
if ("IS_NOT_NULL".equals(ruleConditions)) {
|
||||
return ruleColumn + " IS NOT NULL";
|
||||
}
|
||||
|
||||
// 其他条件需要有值
|
||||
if (StrUtil.isBlank(actualValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建条件
|
||||
switch (ruleConditions) {
|
||||
case "=":
|
||||
return ruleColumn + " = " + formatValue(actualValue);
|
||||
case "!=":
|
||||
return ruleColumn + " != " + formatValue(actualValue);
|
||||
case ">":
|
||||
return ruleColumn + " > " + formatValue(actualValue);
|
||||
case "<":
|
||||
return ruleColumn + " < " + formatValue(actualValue);
|
||||
case ">=":
|
||||
return ruleColumn + " >= " + formatValue(actualValue);
|
||||
case "<=":
|
||||
return ruleColumn + " <= " + formatValue(actualValue);
|
||||
case "IN":
|
||||
return ruleColumn + " IN (" + formatInValues(actualValue) + ")";
|
||||
case "NOT_IN":
|
||||
return ruleColumn + " NOT IN (" + formatInValues(actualValue) + ")";
|
||||
case "LIKE":
|
||||
return ruleColumn + " LIKE '%" + escapeSql(actualValue) + "%'";
|
||||
case "NOT_LIKE":
|
||||
return ruleColumn + " NOT LIKE '%" + escapeSql(actualValue) + "%'";
|
||||
case "BETWEEN":
|
||||
return buildBetweenCondition(ruleColumn, actualValue);
|
||||
case "NOT_BETWEEN":
|
||||
return "NOT " + buildBetweenCondition(ruleColumn, actualValue);
|
||||
default:
|
||||
log.warn("[buildSingleCondition][未知的规则条件: {}]", ruleConditions);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化值(添加引号)
|
||||
* 统一给所有值加引号,数据库会自动处理类型转换
|
||||
*/
|
||||
private static String formatValue(String value) {
|
||||
// 统一添加单引号,避免达梦数据库等对字符串类型字段的类型转换错误
|
||||
return "'" + escapeSql(value) + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 IN 条件的值
|
||||
*/
|
||||
private static String formatInValues(String value) {
|
||||
String[] values = value.split(",");
|
||||
return java.util.Arrays.stream(values)
|
||||
.map(String::trim)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.map(MenuDataPermissionRule::formatValue)
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 BETWEEN 条件
|
||||
*/
|
||||
private static String buildBetweenCondition(String column, String value) {
|
||||
String[] values = value.split(",");
|
||||
if (values.length != 2) {
|
||||
log.warn("[buildBetweenCondition][BETWEEN 条件需要两个值,用逗号分隔: {}]", value);
|
||||
return null;
|
||||
}
|
||||
return column + " BETWEEN " + formatValue(values[0].trim()) + " AND " + formatValue(values[1].trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 转义,防止 SQL 注入
|
||||
*/
|
||||
private static String escapeSql(String value) {
|
||||
if (StrUtil.isBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
return value.replace("'", "''");
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,24 @@ import com.zt.plat.framework.tenant.core.context.CompanyContextHolder;
|
||||
import com.zt.plat.framework.tenant.core.context.DeptContextHolder;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.JSQLParserException;
|
||||
import net.sf.jsqlparser.expression.Alias;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import net.sf.jsqlparser.expression.NullValue;
|
||||
import net.sf.jsqlparser.expression.StringValue;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
|
||||
import net.sf.jsqlparser.expression.operators.relational.*;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.schema.Column;
|
||||
import net.sf.jsqlparser.schema.Table;
|
||||
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
|
||||
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||
import net.sf.jsqlparser.statement.select.SelectItem;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
@@ -67,7 +72,16 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
||||
private static final String DEPT_COLUMN_NAME = "dept_id";
|
||||
private static final String USER_COLUMN_NAME = "user_id";
|
||||
|
||||
static final Expression EXPRESSION_NULL = new NullValue();
|
||||
static final Expression EXPRESSION_NULL;
|
||||
|
||||
static {
|
||||
try {
|
||||
EXPRESSION_NULL = CCJSqlParserUtil.parseCondExpression("1 = 0");
|
||||
} catch (JSQLParserException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static final String SYSTEM_USERS = "system_users";
|
||||
|
||||
private final PermissionCommonApi permissionApi;
|
||||
@@ -177,7 +191,9 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
||||
// 情况三,拼接 Dept 和 Company User 的条件,最后组合
|
||||
Expression deptExpression = buildDeptExpression(tableName, tableAlias, effectiveDeptIds);
|
||||
// Expression deptExpression = buildDeptExpression(tableName, tableAlias, deptDataPermission.getDeptIds());
|
||||
Expression userExpression = buildUserExpression(tableName, tableAlias, effectiveSelf, loginUser.getId());
|
||||
// 使用工号替换 UserId
|
||||
String userWorkCode = SecurityFrameworkUtils.getLoginUserWorkCode();
|
||||
Expression userExpression = buildUserExpression(tableName, tableAlias, effectiveSelf, loginUser.getId(), userWorkCode);
|
||||
if (deptExpression == null && userExpression == null) {
|
||||
// TODO ZT:获得不到条件的时候,暂时不抛出异常,而是不返回数据
|
||||
log.warn("[getExpression][LoginUser({}) Table({}/{}) DeptDataPermission({}) 构建的条件为空]",
|
||||
@@ -241,7 +257,7 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
||||
new ParenthesedExpressionList(new ExpressionList<LongValue>(CollectionUtils.convertList(deptIds, LongValue::new))));
|
||||
}
|
||||
|
||||
private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) {
|
||||
private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId, String workCode) {
|
||||
// 如果不查看自己,则无需作为条件
|
||||
if (Boolean.FALSE.equals(self)) {
|
||||
return null;
|
||||
@@ -250,8 +266,13 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
||||
if (StrUtil.isEmpty(columnName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 拼接条件
|
||||
return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId));
|
||||
if (StrUtil.isNotBlank(workCode) && "system_users".equals(tableName)) {
|
||||
return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "workcode"), new StringValue(workCode));
|
||||
} else {
|
||||
return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 添加配置 ====================
|
||||
|
||||
@@ -2,3 +2,4 @@ com.zt.plat.framework.datapermission.config.ZtDataPermissionAutoConfiguration
|
||||
com.zt.plat.framework.datapermission.config.ZtDeptDataPermissionAutoConfiguration
|
||||
com.zt.plat.framework.datapermission.config.ZtBusinessDataPermissionAutoConfiguration
|
||||
com.zt.plat.framework.datapermission.config.ZtDataPermissionRpcAutoConfiguration
|
||||
com.zt.plat.framework.datapermission.core.menudatapermission.config.MenuDataPermissionConfiguration
|
||||
|
||||
@@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author chenbowen
|
||||
*/
|
||||
@@ -45,7 +47,6 @@ public class CompanyVisitContextInterceptor implements HandlerInterceptor {
|
||||
}
|
||||
|
||||
Long deptId = WebFrameworkUtils.getDeptId(request);
|
||||
// 部门信息同样遵循“请求头 -> 请求属性 -> 登录缓存”的回退顺序
|
||||
if (deptId == null || deptId <= 0L) {
|
||||
Long attrDeptId = resolveLong(request.getAttribute(WebFrameworkUtils.HEADER_VISIT_DEPT_ID));
|
||||
if (attrDeptId != null && attrDeptId > 0L) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.zt.plat.framework.security.core.LoginUser;
|
||||
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
@@ -48,14 +49,16 @@ public class DefaultDBFieldHandler implements MetaObjectHandler {
|
||||
}
|
||||
|
||||
Long userId = getUserId();
|
||||
String userWorkCode = SecurityFrameworkUtils.getLoginUserWorkCode();
|
||||
String savedUserWorkCodeOrUserId = StringUtils.isNotEmpty(userWorkCode) ? userWorkCode : userId == null ? null : userId.toString();
|
||||
String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
|
||||
// 当前登录用户不为空,创建人为空,则当前登录用户为创建人
|
||||
if (Objects.nonNull(userId) && Objects.isNull(baseDO.getCreator())) {
|
||||
baseDO.setCreator(userId.toString());
|
||||
baseDO.setCreator(savedUserWorkCodeOrUserId);
|
||||
}
|
||||
// 当前登录用户不为空,更新人为空,则当前登录用户为更新人
|
||||
if (Objects.nonNull(userId) && Objects.isNull(baseDO.getUpdater())) {
|
||||
baseDO.setUpdater(userId.toString());
|
||||
baseDO.setUpdater(savedUserWorkCodeOrUserId);
|
||||
}
|
||||
}
|
||||
if (Objects.nonNull(metaObject) && metaObject.getOriginalObject() instanceof BusinessBaseDO businessBaseDO) {
|
||||
|
||||
@@ -53,6 +53,20 @@ public class LambdaQueryWrapperX<T> extends LambdaQueryWrapper<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
public LambdaQueryWrapperX<T> eqIfNotBlank(SFunction<T, ?> column, String val) {
|
||||
if (StringUtils.hasText(val)) {
|
||||
return (LambdaQueryWrapperX<T>) super.eq(column, val);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public LambdaQueryWrapperX<T> neIfNotBlank(SFunction<T, ?> column, String val) {
|
||||
if (StringUtils.hasText(val)) {
|
||||
return (LambdaQueryWrapperX<T>) super.ne(column, val);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public LambdaQueryWrapperX<T> gtIfPresent(SFunction<T, ?> column, Object val) {
|
||||
if (val != null) {
|
||||
return (LambdaQueryWrapperX<T>) super.gt(column, val);
|
||||
|
||||
@@ -31,6 +31,9 @@ public class LoginUser {
|
||||
// 用户关联的岗位信息
|
||||
public static final String INFO_KEY_POST_IDS = "postIds";
|
||||
|
||||
// 工号
|
||||
public static final String INFO_KEY_WORK_CODE = "workCode";
|
||||
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsS
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 安全服务工具类
|
||||
@@ -93,6 +94,19 @@ public class SecurityFrameworkUtils {
|
||||
return loginUser != null ? loginUser.getVisitCompanyId() : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getLoginUserWorkCode() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> info = loginUser.getInfo();
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
return MapUtil.getStr(info, LoginUser.INFO_KEY_WORK_CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的编号,从上下文中
|
||||
*
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<module>zt-module-databus-api</module>
|
||||
<module>zt-module-databus-server</module>
|
||||
<module>zt-module-databus-server-app</module>
|
||||
<module>zt-module-databus-client</module>
|
||||
</modules>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.zt.plat.module.databus.api.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.zt.plat.framework.common.util.json.databind.TimestampLocalDateTimeSerializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 新增Databus API 访问日志请求体。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class ApiAccessLogCreateReq {
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@Schema(description = "主键")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* HTTP 方法
|
||||
*/
|
||||
@Schema(description = "HTTP 方法")
|
||||
@NotNull(message = "HTTP 方法不能为空")
|
||||
private String requestMethod;
|
||||
|
||||
/**
|
||||
* 请求路径
|
||||
*/
|
||||
@Schema(description = "请求路径")
|
||||
@NotNull(message = "请求路径不能为空")
|
||||
private String requestPath;
|
||||
|
||||
/**
|
||||
* 调用使用的应用标识
|
||||
*/
|
||||
@Schema(description = "调用使用的应用标识")
|
||||
@NotNull(message = "调用使用的应用标识不能为空")
|
||||
private String credentialAppId;
|
||||
|
||||
/**
|
||||
* 多租户编号
|
||||
*/
|
||||
@Schema(description = "多租户编号")
|
||||
@NotNull(message = "多租户编号不能为空")
|
||||
private Long tenantId;
|
||||
|
||||
/**
|
||||
* 查询参数(JSON 字符串)
|
||||
*/
|
||||
@Schema(description = "查询参数(JSON 字符串)")
|
||||
private String requestQuery;
|
||||
|
||||
/**
|
||||
* 请求头信息(JSON 字符串)
|
||||
*/
|
||||
@Schema(description = "请求头信息(JSON 字符串)")
|
||||
private String requestHeaders;
|
||||
|
||||
/**
|
||||
* 请求体(JSON 字符串)
|
||||
*/
|
||||
@Schema(description = "请求体(JSON 字符串)")
|
||||
private String requestBody;
|
||||
|
||||
/**
|
||||
* 响应 HTTP 状态码
|
||||
*/
|
||||
@Schema(description = "响应 HTTP 状态码")
|
||||
private Integer responseStatus;
|
||||
|
||||
/**
|
||||
* 响应提示信息
|
||||
*/
|
||||
@Schema(description = "响应提示信息")
|
||||
private String responseMessage;
|
||||
|
||||
/**
|
||||
* 响应体(JSON 字符串)
|
||||
*/
|
||||
@Schema(description = "响应体(JSON 字符串)")
|
||||
private String responseBody;
|
||||
|
||||
/**
|
||||
* 访问状态:0-成功 1-客户端错误 2-服务端错误 3-未知
|
||||
*/
|
||||
@Schema(description = "访问状态:0-成功 1-客户端错误 2-服务端错误 3-未知")
|
||||
@NotNull(message = "访问状态不能为空")
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 业务错误码
|
||||
*/
|
||||
@Schema(description = "业务错误码")
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 异常堆栈
|
||||
*/
|
||||
@Schema(description = "异常堆栈")
|
||||
private String exceptionStack;
|
||||
|
||||
/**
|
||||
* 客户端 IP
|
||||
*/
|
||||
@Schema(description = "客户端 IP")
|
||||
private String clientIp;
|
||||
|
||||
/**
|
||||
* User-Agent
|
||||
*/
|
||||
@Schema(description = "User-Agent")
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 请求耗时(毫秒)
|
||||
*/
|
||||
@Schema(description = "请求耗时(毫秒)")
|
||||
private Long duration;
|
||||
|
||||
/**
|
||||
* 请求时间
|
||||
*/
|
||||
@Schema(description = "请求时间")
|
||||
@JsonSerialize(using = TimestampLocalDateTimeSerializer.class)
|
||||
private LocalDateTime requestTime;
|
||||
|
||||
/**
|
||||
* 响应时间
|
||||
*/
|
||||
@Schema(description = "响应时间")
|
||||
@JsonSerialize(using = TimestampLocalDateTimeSerializer.class)
|
||||
private LocalDateTime responseTime;
|
||||
|
||||
/**
|
||||
* 额外调试信息(JSON 字符串)
|
||||
*/
|
||||
@Schema(description = "额外调试信息(JSON 字符串)")
|
||||
private String extra;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.zt.plat.module.databus.api.provider;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.module.databus.api.dto.ApiAccessLogCreateReq;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Databus API访问日志接口
|
||||
* 2026/1/20 16:26
|
||||
*/
|
||||
@FeignClient(name = "${databus.provider.log.service:databus-server}")
|
||||
@Tag(name = "RPC 服务 - Databus API访问日志接口")
|
||||
public interface DatabusAccessLogProviderApi {
|
||||
|
||||
String PREFIX = "/databus/api/portal/access-log";
|
||||
|
||||
@PostMapping(PREFIX + "/add")
|
||||
@Operation(summary = "新增访问日志")
|
||||
CommonResult<Boolean> add(@RequestHeader Map<String, String> headers, @RequestBody ApiAccessLogCreateReq req);
|
||||
|
||||
}
|
||||
82
zt-module-databus/zt-module-databus-client/pom.xml
Normal file
82
zt-module-databus/zt-module-databus-client/pom.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<artifactId>zt-module-databus</artifactId>
|
||||
<groupId>com.zt.plat</groupId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>zt-module-databus-client</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<name>${project.artifactId}</name>
|
||||
<description>
|
||||
Databus client, 提供调用第三方服务的能力并记录调用日志。
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>com.zt.plat</groupId>
|
||||
<artifactId>zt-module-databus-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.zt.plat.module.databus.client;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.http.Method;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.module.databus.api.dto.ApiAccessLogCreateReq;
|
||||
import com.zt.plat.module.databus.api.provider.DatabusAccessLogProviderApi;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 数据总线提供的 http 客户端, 通过此客户端发起接口调用,会自动记录请求日志到数据总线
|
||||
* 2026/1/20 09:44
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class DatabusClient {
|
||||
|
||||
/**
|
||||
* 多租户编号
|
||||
*/
|
||||
@Value("${zt.plat.databus.client.tenantId:1}")
|
||||
private Long tenantId;
|
||||
|
||||
@Resource
|
||||
private DatabusAccessLogProviderApi databusAccessLogProviderApi;
|
||||
|
||||
private static final int MAX_TEXT_LENGTH = 4000;
|
||||
|
||||
/**
|
||||
* 发送 get 请求
|
||||
* @param urlString 仅接口地址,不带参数,参数由data提供
|
||||
* @param data 请求参数
|
||||
* @param headers 请求头
|
||||
* @return 响应结果
|
||||
*/
|
||||
public String get(String urlString, Map<String, Object> data, Map<String, String> headers, String appId, String authToken) {
|
||||
return doRequest(urlString, data, headers, Method.GET, appId, authToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 post 请求
|
||||
* @param urlString 仅接口地址,不带参数,参数由data提供
|
||||
* @param data 请求数据
|
||||
* @param headers 请求头
|
||||
* @return 响应结果
|
||||
*/
|
||||
public String post(String urlString, Map<String, Object> data, Map<String, String> headers, String appId, String authToken) {
|
||||
return doRequest(urlString, data, headers, Method.POST, appId, authToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 put 请求
|
||||
* @param urlString 仅接口地址,不带参数,参数由data提供
|
||||
* @param data 请求数据
|
||||
* @param headers 请求头
|
||||
* @return 响应结果
|
||||
*/
|
||||
public String put(String urlString, Map<String, Object> data, Map<String, String> headers, String appId, String authToken) {
|
||||
return doRequest(urlString, data, headers, Method.PUT, appId, authToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 delete 请求
|
||||
* @param urlString 仅接口地址,不带参数,参数由data提供
|
||||
* @param data 请求数据
|
||||
* @param headers 请求头
|
||||
* @return 响应结果
|
||||
*/
|
||||
public String delete(String urlString, Map<String, Object> data, Map<String, String> headers, String appId, String authToken) {
|
||||
return doRequest(urlString, data, headers, Method.DELETE, appId, authToken);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送请求到门户(token模式,不证书加密)
|
||||
* @param urlString 仅接口地址,不带参数,参数由data提供
|
||||
* @param data 请求数据
|
||||
* @param headers 请求头
|
||||
* @param method 请求方式,默认为 GET
|
||||
* @return 响应结果
|
||||
*/
|
||||
public String doRequest(String urlString, Map<String, Object> data, Map<String, String> headers, Method method, String appId, String authToken) {
|
||||
if (method == null) {
|
||||
method = Method.GET;
|
||||
}
|
||||
Assert.hasText(urlString, "接口地址不能为空");
|
||||
HttpRequest request;
|
||||
ApiAccessLogCreateReq logReq = new ApiAccessLogCreateReq();
|
||||
if (Method.GET.equals(method) || Method.DELETE.equals(method)) {
|
||||
logReq.setRequestQuery(JSONUtil.toJsonStr(data));
|
||||
} else {
|
||||
logReq.setRequestBody(JSONUtil.toJsonStr(data));
|
||||
}
|
||||
request = HttpUtil.createRequest(method, urlString).form(data);
|
||||
if (headers != null && !headers.isEmpty()) {
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
request.header(entry.getKey(), entry.getValue(), true);
|
||||
}
|
||||
}
|
||||
|
||||
logReq.setTenantId(tenantId);
|
||||
|
||||
logReq.setRequestMethod(method.name());
|
||||
logReq.setRequestPath(urlString);
|
||||
logReq.setCredentialAppId(appId);
|
||||
logReq.setRequestHeaders(JSONUtil.toJsonStr(headers));
|
||||
logReq.setUserAgent(request.header("User-Agent"));
|
||||
String result;
|
||||
logReq.setRequestTime(LocalDateTime.now());
|
||||
long requestTime = System.currentTimeMillis();
|
||||
try (HttpResponse response = request.execute()) {
|
||||
logReq.setDuration(System.currentTimeMillis() - requestTime);
|
||||
logReq.setResponseTime(LocalDateTime.now());
|
||||
result = response.body();
|
||||
logReq.setResponseStatus(response.getStatus());
|
||||
logReq.setResponseBody(result);
|
||||
logReq.setStatus(resolveStatus(response.getStatus()));
|
||||
Map<String, String> errorCodeAndMsg = extractErrorCodeAndMsg(result, response.getStatus());
|
||||
logReq.setErrorCode(errorCodeAndMsg.get("errorCode"));
|
||||
logReq.setErrorMessage(errorCodeAndMsg.get("errorMessage"));
|
||||
addAccessLog(logReq, appId, authToken);
|
||||
} catch (Exception e) {
|
||||
// 错误的日志服务端记录了,这里就不再记录了
|
||||
// logReq.setStatus(1);
|
||||
// logReq.setExceptionStack(buildStackTrace( e));
|
||||
// addAccessLog(logReq, appId, authToken);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void addAccessLog(ApiAccessLogCreateReq logReq, String appId, String authToken) {
|
||||
String nonce = randomNonce();
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("tenant-id", String.valueOf(tenantId));
|
||||
headers.put("ZT-App-Id", appId);
|
||||
headers.put("ZT-Timestamp", Long.toString(System.currentTimeMillis()));
|
||||
headers.put("ZT-Nonce", nonce);
|
||||
headers.put("ZT-Auth-Token", authToken);
|
||||
CommonResult<Boolean> response = databusAccessLogProviderApi.add(headers, logReq);
|
||||
if (response.getCode() != 0) {
|
||||
throw new RuntimeException("添加访问日志失败: " + response);
|
||||
}
|
||||
}
|
||||
|
||||
private static String randomNonce() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private String buildStackTrace(Throwable throwable) {
|
||||
try (StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw)) {
|
||||
throwable.printStackTrace(pw);
|
||||
return truncate(sw.toString());
|
||||
} catch (Exception ex) {
|
||||
return throwable.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private Integer resolveStatus(Integer httpStatus) {
|
||||
if (httpStatus == null) {
|
||||
return 3;
|
||||
}
|
||||
if (httpStatus >= 200 && httpStatus < 400) {
|
||||
return 0;
|
||||
}
|
||||
if (httpStatus >= 400 && httpStatus < 500) {
|
||||
return 1;
|
||||
}
|
||||
if (httpStatus >= 500) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
private Map<String, String> extractErrorCodeAndMsg(String responseBody, Integer responseStatus) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (!isErrorStatus(responseStatus)) {
|
||||
return result;
|
||||
}
|
||||
if (JSONUtil.isTypeJSONObject(responseBody)) {
|
||||
JSONObject map = JSONUtil.parseObj(responseBody);
|
||||
Object errorCode = firstNonNull(map.get("errorCode"), map.get("code"));
|
||||
errorCode = errorCode == null ? null : truncate(String.valueOf(errorCode));
|
||||
if (errorCode != null) {
|
||||
result.put("errorCode", errorCode.toString());
|
||||
}
|
||||
Object message = firstNonNull(map.get("errorMessage"), map.get("message"));
|
||||
if (message != null) {
|
||||
message = truncate(String.valueOf(message));
|
||||
result.put("errorMessage", message.toString());
|
||||
}
|
||||
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private boolean isErrorStatus(Integer responseStatus) {
|
||||
return responseStatus != null && responseStatus >= 400;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private <T> T firstNonNull(T... candidates) {
|
||||
if (candidates == null) {
|
||||
return null;
|
||||
}
|
||||
for (T candidate : candidates) {
|
||||
if (candidate != null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncate(String text) {
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return text;
|
||||
}
|
||||
if (text.length() <= MAX_TEXT_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.zt.plat.module.databus.client;
|
||||
|
||||
/**
|
||||
*
|
||||
* 2026/1/21 10:48
|
||||
*/
|
||||
|
||||
import com.zt.plat.module.databus.api.provider.DatabusAccessLogProviderApi;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = {DatabusAccessLogProviderApi.class})
|
||||
public class RpcConfiguration {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||
com.zt.plat.module.databus.client.DatabusClient,\
|
||||
com.zt.plat.module.databus.client.RpcConfiguration
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.zt.plat.module.databus;
|
||||
|
||||
import com.zt.plat.module.databus.client.DatabusClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
*
|
||||
* 2026/1/20 14:29
|
||||
*/
|
||||
@SpringBootTest(classes = TestApplication.class)
|
||||
public class DatabusClientTest {
|
||||
|
||||
@Autowired
|
||||
private DatabusClient databusClient;
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
String result = databusClient.get("https://www.baidu.com/", null, null, "jwyw2", "a5d7cf609c0b47038ea405c660726ee9");
|
||||
System.out.println(result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.zt.plat.module.databus;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
*
|
||||
* 2026/1/20 14:26
|
||||
*/
|
||||
@SpringBootTest
|
||||
@SpringBootApplication(exclude = {
|
||||
DataSourceAutoConfiguration.class,
|
||||
DataSourceTransactionManagerAutoConfiguration.class,
|
||||
HibernateJpaAutoConfiguration.class,
|
||||
JdbcTemplateAutoConfiguration.class,
|
||||
})
|
||||
public class TestApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TestApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: 172.16.46.63:30848 # Nacos 服务器地址
|
||||
username: nacos # Nacos 账号
|
||||
password: P@ssword25 # Nacos 密码
|
||||
discovery: # 【配置中心】配置项
|
||||
namespace: klw # 命名空间。这里使用 maven Profile 资源过滤进行动态替换
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
metadata:
|
||||
version: 1.0.0 # 服务实例的版本号,可用于灰度发布
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.zt.plat.module.databus.api;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.common.util.monitor.TracerUtils;
|
||||
import com.zt.plat.framework.common.util.object.BeanUtils;
|
||||
import com.zt.plat.framework.common.util.servlet.ServletUtils;
|
||||
import com.zt.plat.module.databus.api.dto.ApiAccessLogCreateReq;
|
||||
import com.zt.plat.module.databus.api.provider.DatabusAccessLogProviderApi;
|
||||
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
|
||||
import com.zt.plat.module.databus.service.gateway.ApiAccessLogService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
*
|
||||
* 2026/1/21 10:05
|
||||
*/
|
||||
@RestController
|
||||
public class DatabusAccessLogProviderApiImpl implements DatabusAccessLogProviderApi {
|
||||
|
||||
@Resource
|
||||
private ApiAccessLogService apiAccessLogService;
|
||||
|
||||
@Override
|
||||
@PermitAll
|
||||
public CommonResult<Boolean> add(Map<String, String> headers, ApiAccessLogCreateReq req) {
|
||||
ApiAccessLogDO logDO = new ApiAccessLogDO();
|
||||
BeanUtils.copyProperties(req, logDO);
|
||||
logDO.setTraceId(TracerUtils.getTraceId());
|
||||
logDO.setClientIp(ServletUtils.getClientIP());
|
||||
logDO.setCreateTime(LocalDateTime.now());
|
||||
logDO.setUpdateTime(LocalDateTime.now());
|
||||
logDO.setCreator("1");
|
||||
logDO.setUpdater("1");
|
||||
|
||||
apiAccessLogService.create(logDO);
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public class GatewayWebClientConfiguration {
|
||||
@Value("${databus.gateway.web-client.max-in-memory-size:20971520}") int maxInMemorySize,
|
||||
@Value("${databus.gateway.web-client.max-idle-time:45000}") long maxIdleTimeMillis,
|
||||
@Value("${databus.gateway.web-client.evict-in-background-interval:20000}") long evictInBackgroundMillis,
|
||||
@Value("${databus.gateway.web-client.connection-pool-enabled:false}") boolean connectionPoolEnabled) {
|
||||
@Value("${databus.gateway.web-client.connection-pool-enabled:true}") boolean connectionPoolEnabled) {
|
||||
this.maxInMemorySize = maxInMemorySize;
|
||||
this.maxIdleTimeMillis = maxIdleTimeMillis;
|
||||
this.evictInBackgroundMillis = evictInBackgroundMillis;
|
||||
|
||||
@@ -159,6 +159,7 @@ public class ApiGatewayAccessLogger {
|
||||
update.setStatus(resolveStatus(status));
|
||||
update.setResponseTime(LocalDateTime.now());
|
||||
update.setDuration(calculateDuration(request));
|
||||
update.setUpdater("1");
|
||||
apiAccessLogService.update(update);
|
||||
} catch (Exception ex) {
|
||||
log.warn("更新入口 API 访问日志失败, logId={}", logId, ex);
|
||||
|
||||
@@ -50,6 +50,7 @@ public class ApiGatewayExecutionService {
|
||||
private static final String HEADER_REMOTE_ADDRESS = org.springframework.integration.http.HttpHeaders.PREFIX + "remoteAddress";
|
||||
private static final String LOCAL_DEBUG_REMOTE_ADDRESS = "127.0.0.1";
|
||||
private static final String ATTR_DEBUG_INVOKE = "gatewayDebugInvoke";
|
||||
private static final String ATTR_API_AGGREGATE = "apiDefinitionAggregate";
|
||||
|
||||
private final ApiGatewayRequestMapper requestMapper;
|
||||
private final ApiFlowDispatcher apiFlowDispatcher;
|
||||
@@ -78,17 +79,26 @@ public class ApiGatewayExecutionService {
|
||||
ApiInvocationContext context = message.getPayload();
|
||||
accessLogger.onRequest(context);
|
||||
ApiInvocationContext responseContext;
|
||||
ApiDefinitionAggregate debugAggregate = null;
|
||||
ApiDefinitionAggregate aggregate = null;
|
||||
try {
|
||||
enforceCredentialAuthorization(context);
|
||||
if (Boolean.TRUE.equals(context.getAttributes().get(ATTR_DEBUG_INVOKE))) {
|
||||
debugAggregate = resolveDebugAggregate(context);
|
||||
aggregate = resolveDebugAggregate(context);
|
||||
} else {
|
||||
// 对于非调试调用,也需要获取 aggregate 以便后续应用响应模板
|
||||
aggregate = apiDefinitionService.findByCodeAndVersion(context.getApiCode(), context.getApiVersion())
|
||||
.orElse(null);
|
||||
}
|
||||
if (debugAggregate != null) {
|
||||
responseContext = apiFlowDispatcher.dispatchWithAggregate(debugAggregate, context);
|
||||
// 将 aggregate 存储到上下文中,供响应构建时使用
|
||||
if (aggregate != null) {
|
||||
context.getAttributes().put(ATTR_API_AGGREGATE, aggregate);
|
||||
}
|
||||
if (aggregate != null && Boolean.TRUE.equals(context.getAttributes().get(ATTR_DEBUG_INVOKE))) {
|
||||
responseContext = apiFlowDispatcher.dispatchWithAggregate(aggregate, context);
|
||||
} else {
|
||||
responseContext = apiFlowDispatcher.dispatch(context.getApiCode(), context.getApiVersion(), context);
|
||||
}
|
||||
responseContext.setResponseStatus(resolveStatus(responseContext));
|
||||
} catch (ServiceException ex) {
|
||||
errorProcessor.applyServiceException(context, ex);
|
||||
accessLogger.onException(context, ex);
|
||||
@@ -239,12 +249,64 @@ public class ApiGatewayExecutionService {
|
||||
String message = StringUtils.hasText(context.getResponseMessage())
|
||||
? context.getResponseMessage()
|
||||
: HttpStatus.valueOf(status).getReasonPhrase();
|
||||
return ApiGatewayResponse.builder()
|
||||
.code(status)
|
||||
.message(message)
|
||||
.response(context.getResponseBody())
|
||||
.traceId(TracerUtils.getTraceId())
|
||||
.build();
|
||||
|
||||
// 尝试从上下文中获取 API 定义聚合对象
|
||||
ApiDefinitionAggregate aggregate = (ApiDefinitionAggregate) context.getAttributes().get(ATTR_API_AGGREGATE);
|
||||
String responseTemplate = aggregate != null ? aggregate.getDefinition().getResponseTemplate() : null;
|
||||
|
||||
// 如果配置了响应模板,则应用模板;否则使用默认格式(向后兼容)
|
||||
if (StringUtils.hasText(responseTemplate)) {
|
||||
return buildResponseWithTemplate(responseTemplate, context, status, message);
|
||||
}
|
||||
|
||||
// 默认响应格式(向后兼容)
|
||||
return ApiGatewayResponse.builder()
|
||||
.code(status)
|
||||
.message(message)
|
||||
.response(context.getResponseBody())
|
||||
.traceId(TracerUtils.getTraceId())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据响应模板构建响应对象
|
||||
* 模板格式示例:{"code": 0, "message": "success", "data": {...}}
|
||||
* 模板中的 data 字段会被实际响应数据替换,其他字段保持用户配置的原始值
|
||||
*/
|
||||
private ApiGatewayResponse buildResponseWithTemplate(String templateJson, ApiInvocationContext context, int status, String message) {
|
||||
try {
|
||||
// 解析模板 JSON
|
||||
Map<String, Object> template = objectMapper.readValue(templateJson, Map.class);
|
||||
|
||||
// 构建最终响应数据,保留模板中的所有字段
|
||||
Map<String, Object> responseData = new LinkedHashMap<>(template);
|
||||
|
||||
// 只替换 data 字段为实际的响应体数据
|
||||
// 其他字段(如 code、message)保持用户配置的原始值
|
||||
if (responseData.containsKey("data")) {
|
||||
responseData.put("data", context.getResponseBody());
|
||||
}
|
||||
|
||||
// 返回使用模板结构的响应
|
||||
// 注意:ApiGatewayResponse 的 code 和 message 是 HTTP 层面的状态
|
||||
// responseData 中的 code 和 message 是业务层面的状态
|
||||
return ApiGatewayResponse.builder()
|
||||
.code(status)
|
||||
.message(message)
|
||||
.response(responseData)
|
||||
.traceId(TracerUtils.getTraceId())
|
||||
.build();
|
||||
|
||||
} catch (JsonProcessingException ex) {
|
||||
log.warn("[API-PORTAL] 解析响应模板失败,使用默认格式: {}", ex.getMessage());
|
||||
// 解析失败时回退到默认格式
|
||||
return ApiGatewayResponse.builder()
|
||||
.code(status)
|
||||
.message(message)
|
||||
.response(context.getResponseBody())
|
||||
.traceId(TracerUtils.getTraceId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,4 +40,23 @@ public class ApiDefinitionAggregate {
|
||||
return credentialBindings == null ? Collections.emptyList() : credentialBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一个 HTTP 步骤的 ID(order 最大的 HTTP 步骤)
|
||||
* 用于判断是否需要设置 responseBody
|
||||
*/
|
||||
public Long getLastHttpStepId() {
|
||||
return getSteps().stream()
|
||||
.filter(stepDef -> "HTTP".equalsIgnoreCase(stepDef.getStep().getType()))
|
||||
.max((s1, s2) -> {
|
||||
Integer order1 = s1.getStep().getStepOrder();
|
||||
Integer order2 = s2.getStep().getStepOrder();
|
||||
if (order1 == null && order2 == null) return 0;
|
||||
if (order1 == null) return -1;
|
||||
if (order2 == null) return 1;
|
||||
return order1.compareTo(order2);
|
||||
})
|
||||
.map(stepDef -> stepDef.getStep().getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -96,18 +96,50 @@ public final class GatewayExpressionHelper {
|
||||
}
|
||||
|
||||
private static void applyRequestMutations(ApiInvocationContext context, Map<?, ?> map) {
|
||||
Object body = firstNonNull(map.get("requestBody"), map.get("body"));
|
||||
if (body != null) {
|
||||
context.setRequestBody(body);
|
||||
}
|
||||
Object headers = map.get("requestHeaders");
|
||||
if (headers instanceof Map<?, ?> headerMap) {
|
||||
context.getRequestHeaders().putAll(toStringMap(headerMap));
|
||||
}
|
||||
// 处理查询参数
|
||||
Object query = firstNonNull(map.get("requestQuery"), map.get("requestQueryParams"), map.get("query"));
|
||||
if (query instanceof Map<?, ?> queryMap) {
|
||||
context.getRequestQueryParams().putAll(toObjectMap(queryMap));
|
||||
}
|
||||
|
||||
// 处理请求头
|
||||
Object headers = map.get("requestHeaders");
|
||||
if (headers instanceof Map<?, ?> headerMap) {
|
||||
context.getRequestHeaders().putAll(toStringMap(headerMap));
|
||||
}
|
||||
|
||||
// 处理 body - 使用和 HTTP 步骤一致的逻辑
|
||||
// 检查是否显式指定了 body 字段
|
||||
boolean explicitBody = containsKeyIgnoreCase(map, "body", "payload", "requestBody");
|
||||
|
||||
if (explicitBody) {
|
||||
// 如果显式指定了 body,提取并与原有 body 合并
|
||||
Object body = firstNonNull(map.get("requestBody"), map.get("body"), map.get("payload"));
|
||||
Object existingBody = context.getRequestBody();
|
||||
|
||||
if (body instanceof Map && existingBody instanceof Map) {
|
||||
// 两者都是 Map,合并以保留未映射的字段
|
||||
Map<String, Object> mergedBody = new LinkedHashMap<>(toObjectMap((Map<?, ?>) existingBody));
|
||||
mergedBody.putAll(toObjectMap((Map<?, ?>) body));
|
||||
context.setRequestBody(mergedBody);
|
||||
} else if (body != null) {
|
||||
// 直接替换
|
||||
context.setRequestBody(body);
|
||||
}
|
||||
} else if (query == null) {
|
||||
// 如果没有显式 body 字段,也没有 query 字段
|
||||
// 说明整个 map 是新的 body,与原有 body 合并
|
||||
Object existingBody = context.getRequestBody();
|
||||
if (existingBody instanceof Map) {
|
||||
Map<String, Object> mergedBody = new LinkedHashMap<>(toObjectMap((Map<?, ?>) existingBody));
|
||||
mergedBody.putAll(toObjectMap(map));
|
||||
context.setRequestBody(mergedBody);
|
||||
} else {
|
||||
// 原有 body 不是 Map,直接替换
|
||||
context.setRequestBody(toObjectMap(map));
|
||||
}
|
||||
}
|
||||
// 如果有 query 字段但没有显式 body 字段,保留原有 body(不做任何操作)
|
||||
}
|
||||
|
||||
private static void applyResponseMutations(ApiInvocationContext context, Map<?, ?> map) {
|
||||
@@ -165,4 +197,18 @@ public final class GatewayExpressionHelper {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean containsKeyIgnoreCase(Map<?, ?> source, String... keys) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String key : keys) {
|
||||
for (Object entryKey : source.keySet()) {
|
||||
if (key.equalsIgnoreCase(String.valueOf(entryKey))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ public class JsonataExpressionEvaluator implements ExpressionEvaluator {
|
||||
environment.setVariable("vars", objectMapper.valueToTree(context.getVariables() == null ? java.util.Collections.emptyMap() : context.getVariables()));
|
||||
environment.setVariable("headers", objectMapper.valueToTree(context.getHeaders() == null ? java.util.Collections.emptyMap() : context.getHeaders()));
|
||||
environment.setVariable("ctx", objectMapper.valueToTree(context.getInvocation()));
|
||||
|
||||
// 直接绑定查询参数,方便访问
|
||||
if (context.getInvocation() != null && context.getInvocation().getRequestQueryParams() != null) {
|
||||
environment.setVariable("query", objectMapper.valueToTree(context.getInvocation().getRequestQueryParams()));
|
||||
} else {
|
||||
environment.setVariable("query", objectMapper.valueToTree(java.util.Collections.emptyMap()));
|
||||
}
|
||||
} catch (EvaluateRuntimeException e) {
|
||||
throw ServiceExceptionUtil.exception(API_JSONATA_BIND_FAILED);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.zt.plat.framework.common.util.security.CryptoSignatureUtils.SIGNATURE_FIELD;
|
||||
import static com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties.*;
|
||||
@@ -76,6 +77,9 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加日志接口需要特殊处理
|
||||
boolean isNormalProcess = !pathMatcher.match("/databus/api/portal/access-log/**", pathWithinApplication);
|
||||
Long accessLogId = null;
|
||||
ApiGatewayProperties.Security security = properties.getSecurity();
|
||||
ApiClientCredentialDO credential = null;
|
||||
@@ -94,8 +98,10 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
accessLogger.finalizeEarly(request, HttpStatus.FORBIDDEN.value(), "IP 禁止访问");
|
||||
return;
|
||||
}
|
||||
// IP 校验通过后再补录入口日志,避免无租户信息写库
|
||||
accessLogId = accessLogger.logEntrance(request);
|
||||
// IP 校验通过后再补录入口日志,避免无租户信息写库, 非日志添加接口才记录日志
|
||||
if (isNormalProcess) {
|
||||
accessLogId = accessLogger.logEntrance(request);
|
||||
}
|
||||
if (!security.isEnabled()) {
|
||||
byte[] originalBody = StreamUtils.copyToByteArray(request.getInputStream());
|
||||
CachedBodyHttpServletRequest passthroughRequest = new CachedBodyHttpServletRequest(request, originalBody);
|
||||
@@ -127,13 +133,17 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
if (nonce.length() < 8) {
|
||||
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "随机数长度不足");
|
||||
}
|
||||
String signature = requireHeader(request, SIGNATURE_HEADER, "缺少签名");
|
||||
|
||||
// 尝试按凭证配置解密请求体,并构建签名载荷进行校验
|
||||
byte[] decryptedBody = decryptRequestBody(requestBody, credential, security);
|
||||
verifySignature(request, decryptedBody, signature, credential, security, appId, timestampHeader);
|
||||
if (isNormalProcess) {
|
||||
// 非日志添加接口才处理
|
||||
String signature = requireHeader(request, SIGNATURE_HEADER, "缺少签名");
|
||||
// 尝试按凭证配置解密请求体,并构建签名载荷进行校验
|
||||
byte[] decryptedBody = decryptRequestBody(requestBody, credential, security);
|
||||
verifySignature(request, decryptedBody, signature, credential, security, appId, timestampHeader);
|
||||
requestBody = decryptedBody;
|
||||
}
|
||||
ensureNonce(tenantId, appId, nonce, security);
|
||||
requestBody = decryptedBody;
|
||||
|
||||
}
|
||||
|
||||
// 使用可重复读取的请求包装,供后续过滤器继续消费
|
||||
@@ -153,7 +163,9 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
try {
|
||||
filterChain.doFilter(securedRequest, responseWrapper);
|
||||
dispatchedToGateway = true;
|
||||
encryptResponse(responseWrapper, credential, security);
|
||||
if (isNormalProcess) {
|
||||
encryptResponse(responseWrapper, credential, security);
|
||||
}
|
||||
} finally {
|
||||
responseWrapper.copyBodyToResponse();
|
||||
}
|
||||
@@ -162,7 +174,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
accessLogId = accessLogger.logEntrance(request);
|
||||
}
|
||||
log.warn("[API-PORTAL] 安全校验失败: {}", ex.getMessage());
|
||||
writeErrorResponse(response, security, credential, ex.status(), ex.getMessage());
|
||||
writeErrorResponse(response, security, credential, ex.status(), ex.getMessage(), isNormalProcess);
|
||||
if (!dispatchedToGateway) {
|
||||
accessLogger.finalizeEarly(request, ex.status().value(), ex.getMessage());
|
||||
}
|
||||
@@ -171,7 +183,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
accessLogId = accessLogger.logEntrance(request);
|
||||
}
|
||||
log.error("[API-PORTAL] 处理安全校验时出现异常", ex);
|
||||
writeErrorResponse(response, security, credential, HttpStatus.INTERNAL_SERVER_ERROR, "网关安全校验失败");
|
||||
writeErrorResponse(response, security, credential, HttpStatus.INTERNAL_SERVER_ERROR, "网关安全校验失败", isNormalProcess);
|
||||
if (!dispatchedToGateway) {
|
||||
accessLogger.finalizeEarly(request, HttpStatus.INTERNAL_SERVER_ERROR.value(), "网关安全校验失败");
|
||||
}
|
||||
@@ -471,11 +483,14 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
}
|
||||
securedRequest.removeHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN);
|
||||
securedRequest.removeHeader(HttpHeaders.AUTHORIZATION);
|
||||
anonymousUserService.issueAccessToken(anonymousDetails)
|
||||
.ifPresent(token -> {
|
||||
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
securedRequest.setHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, token);
|
||||
});
|
||||
Optional<String> tokenOptional = anonymousUserService.issueAccessToken(anonymousDetails);
|
||||
if (tokenOptional.isEmpty()) {
|
||||
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "匿名访问获取token失败");
|
||||
}
|
||||
String token = tokenOptional.get();
|
||||
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
securedRequest.setHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, token);
|
||||
|
||||
}
|
||||
|
||||
private static final class SecurityValidationException extends RuntimeException {
|
||||
@@ -496,7 +511,8 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
ApiGatewayProperties.Security security,
|
||||
ApiClientCredentialDO credential,
|
||||
HttpStatus status,
|
||||
String message) {
|
||||
String message,
|
||||
boolean isNormalProcess) {
|
||||
if (response.isCommitted()) {
|
||||
log.warn("[API-PORTAL] 响应已提交,无法写入安全校验错误: {}", message);
|
||||
return;
|
||||
@@ -511,7 +527,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
|
||||
.response(null)
|
||||
.traceId(traceId)
|
||||
.build();
|
||||
if (shouldEncryptErrorResponse(security, credential)) {
|
||||
if (shouldEncryptErrorResponse(security, credential) && isNormalProcess) {
|
||||
String encryptionKey = credential.getEncryptionKey();
|
||||
String encryptionType = resolveEncryptionType(credential, security);
|
||||
try {
|
||||
|
||||
@@ -68,6 +68,7 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
|
||||
private HttpRequestPayload coerceRequestPayload(Object evaluated, Object fallbackBody, Map<String, Object> fallbackQuery) {
|
||||
Map<String, Object> querySnapshot = new LinkedHashMap<>(fallbackQuery);
|
||||
|
||||
if (evaluated == null) {
|
||||
return HttpRequestPayload.of(fallbackBody, querySnapshot);
|
||||
}
|
||||
@@ -80,23 +81,47 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
mergeQueryParams(querySnapshot, multiValueMap);
|
||||
return HttpRequestPayload.of(fallbackBody, querySnapshot);
|
||||
}
|
||||
if (evaluated instanceof Map<?, ?> map) {
|
||||
Object queryPart = extractCaseInsensitive(map, "query", "queryParams", "params");
|
||||
if (evaluated instanceof Map<?, ?> evaluatedMap) {
|
||||
// 处理查询参数
|
||||
Object queryPart = extractCaseInsensitive(evaluatedMap, "query", "queryParams", "params");
|
||||
if (queryPart != null) {
|
||||
mergeQueryParams(querySnapshot, queryPart);
|
||||
}
|
||||
boolean explicitBody = containsKeyIgnoreCase(map, "body", "payload");
|
||||
|
||||
// 检查是否显式指定了body字段
|
||||
boolean explicitBody = containsKeyIgnoreCase(evaluatedMap, "body", "payload");
|
||||
Object body = explicitBody
|
||||
? Optional.ofNullable(extractCaseInsensitive(map, "body", "payload")).orElse(fallbackBody)
|
||||
? Optional.ofNullable(extractCaseInsensitive(evaluatedMap, "body", "payload")).orElse(fallbackBody)
|
||||
: (queryPart != null ? fallbackBody : evaluated);
|
||||
if (!explicitBody && queryPart == null) {
|
||||
|
||||
if (explicitBody) {
|
||||
// 如果显式指定了body,将原始body和映射body合并
|
||||
if (body instanceof Map && fallbackBody instanceof Map) {
|
||||
Map<String, Object> mergedBody = new LinkedHashMap<>((Map<String, Object>) fallbackBody);
|
||||
mergedBody.putAll((Map<String, Object>) body);
|
||||
return HttpRequestPayload.of(mergedBody, querySnapshot);
|
||||
}
|
||||
return HttpRequestPayload.of(body, querySnapshot);
|
||||
} else if (queryPart != null) {
|
||||
// 如果有查询参数部分但没有显式body,保留原始body
|
||||
return HttpRequestPayload.of(fallbackBody, querySnapshot);
|
||||
} else {
|
||||
// 如果没有查询参数也没有显式body,说明整个evaluatedMap是新的body
|
||||
if (fallbackBody instanceof Map) {
|
||||
// 将原始body和映射结果合并,保留原始字段
|
||||
Map<String, Object> mergedBody = new LinkedHashMap<>((Map<String, Object>) fallbackBody);
|
||||
((Map<String, Object>) evaluatedMap).forEach((key, value) -> {
|
||||
mergedBody.put((String) key, value);
|
||||
});
|
||||
return HttpRequestPayload.of(mergedBody, querySnapshot);
|
||||
}
|
||||
return HttpRequestPayload.of(evaluated, querySnapshot);
|
||||
}
|
||||
return HttpRequestPayload.of(body, querySnapshot);
|
||||
}
|
||||
return HttpRequestPayload.of(evaluated, querySnapshot);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public GenericHandler<ApiInvocationContext> build(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) {
|
||||
return (payload, headers) -> {
|
||||
@@ -134,7 +159,7 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
.success(true)
|
||||
.elapsed(Duration.between(start, Instant.now()))
|
||||
.build());
|
||||
applyResponseMapping(stepDefinition, payload, headers, response);
|
||||
applyResponseMapping(aggregate, stepDefinition, payload, headers, response);
|
||||
} catch (Exception ex) {
|
||||
payload.addStepResult(ApiStepResult.builder()
|
||||
.stepId(stepDefinition.getStep().getId())
|
||||
@@ -157,22 +182,66 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
ExpressionSpec spec = ExpressionSpecParser.parse(stepDefinition.getStep().getRequestMappingExpr(), ExpressionTypeEnum.JSON);
|
||||
Map<String, Object> baseQuery = new LinkedHashMap<>(context.getRequestQueryParams());
|
||||
Object fallbackBody = context.getRequestBody();
|
||||
|
||||
if (spec == null) {
|
||||
return HttpRequestPayload.of(fallbackBody, baseQuery);
|
||||
}
|
||||
Object evaluated = expressionExecutor.evaluate(spec, context, fallbackBody, headers);
|
||||
return coerceRequestPayload(evaluated, fallbackBody, baseQuery);
|
||||
|
||||
// 将查询参数合并到 payload 中,使表达式可以通过 $.query.k 访问
|
||||
// 这样可以避免 JSONata 环境变量访问的限制
|
||||
Object enrichedPayload = enrichPayloadWithQuery(fallbackBody, baseQuery);
|
||||
|
||||
Object evaluated = expressionExecutor.evaluate(spec, context, enrichedPayload, headers);
|
||||
|
||||
// 如果映射结果是Map且原始body也是Map,合并两个Map以保留未映射的字段
|
||||
// 但如果映射结果包含显式的 body/query 结构,则不进行合并,直接传递
|
||||
HttpRequestPayload result;
|
||||
if (evaluated instanceof Map && fallbackBody instanceof Map) {
|
||||
Map<String, Object> evaluatedMap = (Map<String, Object>) evaluated;
|
||||
|
||||
// 检查是否包含显式的 body 或 query 键(表示结构化映射)
|
||||
boolean hasStructuredMapping = containsKeyIgnoreCase(evaluatedMap, "body", "payload")
|
||||
|| containsKeyIgnoreCase(evaluatedMap, "query", "queryParams", "params");
|
||||
|
||||
if (hasStructuredMapping) {
|
||||
// 有结构化映射,直接传递给 coerceRequestPayload 处理
|
||||
result = coerceRequestPayload(evaluated, fallbackBody, baseQuery);
|
||||
} else {
|
||||
// 没有结构化映射,进行字段级合并以保留未映射的字段
|
||||
Map<String, Object> originalBodyMap = (Map<String, Object>) fallbackBody;
|
||||
Map<String, Object> mergedBody = new LinkedHashMap<>(originalBodyMap);
|
||||
mergedBody.putAll(evaluatedMap);
|
||||
result = coerceRequestPayload(mergedBody, fallbackBody, baseQuery);
|
||||
}
|
||||
} else {
|
||||
result = coerceRequestPayload(evaluated, fallbackBody, baseQuery);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void applyResponseMapping(ApiStepDefinition stepDefinition, ApiInvocationContext context, Map<String, Object> headers, Object response) throws Exception {
|
||||
|
||||
private void applyResponseMapping(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition, ApiInvocationContext context, Map<String, Object> headers, Object response) throws Exception {
|
||||
ExpressionSpec spec = ExpressionSpecParser.parse(stepDefinition.getStep().getResponseMappingExpr(), ExpressionTypeEnum.JSON);
|
||||
if (spec == null) {
|
||||
context.setResponseBody(response);
|
||||
return;
|
||||
}
|
||||
|
||||
Object mapped = expressionExecutor.evaluate(spec, context, response, headers);
|
||||
|
||||
// 判断当前步骤是否是最后一个 HTTP 步骤
|
||||
Long lastHttpStepId = aggregate.getLastHttpStepId();
|
||||
boolean isLastHttpStep = lastHttpStepId != null && lastHttpStepId.equals(stepDefinition.getStep().getId());
|
||||
|
||||
if (mapped instanceof Map<?, ?> map) {
|
||||
// 将映射结果放入 variables
|
||||
map.forEach((key, value) -> context.getVariables().put(String.valueOf(key), value));
|
||||
|
||||
// 如果是最后一个 HTTP 步骤,也要设置 responseBody,供 END 步骤使用
|
||||
if (isLastHttpStep) {
|
||||
context.setResponseBody(mapped);
|
||||
}
|
||||
} else {
|
||||
context.setResponseBody(mapped);
|
||||
}
|
||||
@@ -330,7 +399,8 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
mergeQueryParams(queryParams, requestPayload.body());
|
||||
}
|
||||
applyQueryParams(builder, queryParams);
|
||||
return builder.build(true).toUri();
|
||||
URI uri = builder.build(true).toUri();
|
||||
return uri;
|
||||
}
|
||||
|
||||
private Object extractCaseInsensitive(Map<?, ?> source, String... keys) {
|
||||
@@ -435,4 +505,26 @@ public class HttpStepHandler implements ApiStepHandler {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询参数合并到 payload 中,使表达式可以通过 $.query.k 访问
|
||||
* 这样可以避免 JSONata 环境变量访问的限制
|
||||
*/
|
||||
private Object enrichPayloadWithQuery(Object originalPayload, Map<String, Object> queryParams) {
|
||||
if (queryParams == null || queryParams.isEmpty()) {
|
||||
return originalPayload;
|
||||
}
|
||||
|
||||
Map<String, Object> enriched = new LinkedHashMap<>();
|
||||
|
||||
// 如果原始 payload 是 Map,复制所有字段
|
||||
if (originalPayload instanceof Map) {
|
||||
enriched.putAll((Map<String, Object>) originalPayload);
|
||||
}
|
||||
|
||||
// 添加查询参数到 query 字段
|
||||
enriched.put("query", new LinkedHashMap<>(queryParams));
|
||||
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,19 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_START_EXECUTION_FAILED;
|
||||
|
||||
/**
|
||||
* Handler for START orchestration nodes.
|
||||
*
|
||||
* <p>增强功能:START 节点的映射结果会存入 context.requestBody,
|
||||
* 使得后续 HTTP 步骤可以自动继承这些公共参数,避免重复配置。
|
||||
*
|
||||
* <p>注意:不将参数存入 context.variables,避免与后续步骤的响应结果冲突。
|
||||
* variables 应该保留给步骤执行结果使用。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -40,11 +49,22 @@ public class StartStepHandler implements ApiStepHandler {
|
||||
Instant start = Instant.now();
|
||||
Object snapshotBefore = GatewayExpressionHelper.snapshotRequest(payload);
|
||||
try {
|
||||
ExpressionSpec spec = ExpressionSpecParser.parse(stepDefinition.getStep().getRequestMappingExpr(), ExpressionTypeEnum.JSON);
|
||||
ExpressionSpec spec = ExpressionSpecParser.parse(
|
||||
stepDefinition.getStep().getRequestMappingExpr(),
|
||||
ExpressionTypeEnum.JSON
|
||||
);
|
||||
if (spec != null) {
|
||||
Object evaluated = expressionExecutor.evaluate(spec, payload, payload.getRequestBody(), headers);
|
||||
// 将查询参数合并到 payload 中,使表达式可以通过 $.query.k 访问
|
||||
// 这样和 HTTP 步骤的映射逻辑保持一致
|
||||
Object enrichedPayload = enrichPayloadWithQuery(payload.getRequestBody(), payload.getRequestQueryParams());
|
||||
|
||||
Object evaluated = expressionExecutor.evaluate(spec, payload, enrichedPayload, headers);
|
||||
|
||||
// 应用上下文变更(包括 requestBody、requestQuery 等)
|
||||
// 支持字段级映射,将外部字段名转换为内部字段名
|
||||
GatewayExpressionHelper.applyContextMutations(payload, evaluated, true, false);
|
||||
}
|
||||
|
||||
payload.addStepResult(ApiStepResult.builder()
|
||||
.stepId(stepDefinition.getStep().getId())
|
||||
.stepType(stepDefinition.getStep().getType())
|
||||
@@ -70,4 +90,26 @@ public class StartStepHandler implements ApiStepHandler {
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询参数合并到 payload 中,使表达式可以通过 $.query.k 访问
|
||||
* 这样可以避免 JSONata 环境变量访问的限制
|
||||
*/
|
||||
private Object enrichPayloadWithQuery(Object originalPayload, Map<String, Object> queryParams) {
|
||||
if (queryParams == null || queryParams.isEmpty()) {
|
||||
return originalPayload;
|
||||
}
|
||||
|
||||
Map<String, Object> enriched = new LinkedHashMap<>();
|
||||
|
||||
// 如果原始 payload 是 Map,复制所有字段
|
||||
if (originalPayload instanceof Map) {
|
||||
enriched.putAll((Map<String, Object>) originalPayload);
|
||||
}
|
||||
|
||||
// 添加查询参数到 query 字段
|
||||
enriched.put("query", new LinkedHashMap<>(queryParams));
|
||||
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ public class ApiAnonymousUserService {
|
||||
private final AdminUserApi adminUserApi;
|
||||
private final OAuth2TokenCommonApi oauth2TokenApi;
|
||||
|
||||
private static final int RETRY_ATTEMPTS = 10;
|
||||
private static final Duration RETRY_DELAY = Duration.ofSeconds(5);
|
||||
|
||||
private LoadingCache<Long, Optional<AnonymousUserDetails>> cache;
|
||||
|
||||
@PostConstruct
|
||||
@@ -105,18 +108,33 @@ public class ApiAnonymousUserService {
|
||||
if (details == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
OAuth2AccessTokenCreateReqDTO req = buildAccessTokenRequest(details);
|
||||
OAuth2AccessTokenRespDTO resp = oauth2TokenApi.createAccessToken(req).getCheckedData();
|
||||
if (resp == null || !StringUtils.hasText(resp.getAccessToken())) {
|
||||
log.warn("[ANONYMOUS] 获取用户 {} 的访问令牌失败: 响应为空", details.getUserId());
|
||||
return Optional.empty();
|
||||
OAuth2AccessTokenCreateReqDTO req = buildAccessTokenRequest(details);
|
||||
Exception lastException = null;
|
||||
for (int attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
OAuth2AccessTokenRespDTO resp = oauth2TokenApi.createAccessToken(req).getCheckedData();
|
||||
if (resp == null || !StringUtils.hasText(resp.getAccessToken())) {
|
||||
log.warn("[ANONYMOUS] 获取用户 {} 的访问令牌失败: 响应为空", details.getUserId());
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(resp.getAccessToken());
|
||||
} catch (Exception ex) {
|
||||
lastException = ex;
|
||||
if (attempt < RETRY_ATTEMPTS) {
|
||||
log.warn("[ANONYMOUS] 获取用户 {} 的访问令牌失败,开始第 {} 次重试,原因:{}",
|
||||
details.getUserId(), attempt, ex.getMessage());
|
||||
try {
|
||||
Thread.sleep(RETRY_DELAY.toMillis());
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("[ANONYMOUS] 获取用户 {} 的访问令牌重试被中断", details.getUserId());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.of(resp.getAccessToken());
|
||||
} catch (Exception ex) {
|
||||
log.error("[ANONYMOUS] 获取用户 {} 的访问令牌时发生异常", details.getUserId(), ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
log.error("[ANONYMOUS] 获取用户 {} 的访问令牌时发生异常", details.getUserId(), lastException);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private OAuth2AccessTokenCreateReqDTO buildAccessTokenRequest(AnonymousUserDetails details) {
|
||||
|
||||
@@ -21,4 +21,6 @@ public class FileCreateReqDTO {
|
||||
@NotEmpty(message = "文件内容不能为空")
|
||||
private byte[] content;
|
||||
|
||||
@Schema(description = "是否可下载(true是,false否)", example = "true")
|
||||
private Boolean downloadable;
|
||||
}
|
||||
|
||||
@@ -37,4 +37,7 @@ public class FileRespDTO {
|
||||
@Schema(description = "文件下载次数")
|
||||
private Integer downloadCount;
|
||||
|
||||
@Schema(description = "是否可下载(true是,false否)")
|
||||
private Boolean downloadable;
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.zt.plat.module.infra.controller.admin.file;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.URLUtil;
|
||||
import com.zt.plat.framework.common.enums.VerifyCodeSendType;
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.common.pojo.PageResult;
|
||||
@@ -21,6 +22,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -43,18 +45,48 @@ import static com.zt.plat.module.infra.framework.file.core.utils.FileTypeUtils.w
|
||||
@Slf4j
|
||||
public class FileController {
|
||||
|
||||
@Value("${zt.file.preview-base-url:}")
|
||||
private String previewBaseUrl;
|
||||
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "获取文件预览地址", description = "根据 fileId 返回文件预览 url(kkfile)")
|
||||
public CommonResult<FileRespVO> getPreviewUrl(@RequestParam("fileId") Long fileId) {
|
||||
public CommonResult<FileRespVO> getPreviewUrl(@RequestParam("fileId") Long fileId,
|
||||
@RequestParam(value = "code", required = false) String code,
|
||||
HttpServletRequest request) throws Exception {
|
||||
FileDO fileDO = fileService.getActiveFileById(fileId);
|
||||
if (fileDO == null) {
|
||||
return CommonResult.error(HttpStatus.NOT_FOUND.value(), "文件不存在");
|
||||
}
|
||||
|
||||
// 统计下载次数
|
||||
fileService.incDownloadCount(fileId);
|
||||
|
||||
// FileDO 转换为 FileRespVO
|
||||
FileRespVO fileRespVO = BeanUtils.toBean(fileDO, FileRespVO.class);
|
||||
|
||||
// 加密文件:塞入“临时解密预览 URL”
|
||||
if (Boolean.TRUE.equals(fileRespVO.getIsEncrypted())) { // FileDO 通过 aesIv 判断加密
|
||||
|
||||
if (cn.hutool.core.util.StrUtil.isBlank(code)) {
|
||||
return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "加密文件预览需要验证码 code");
|
||||
}
|
||||
// 验证通过:发放给 kkfile 用的短期 token(kkfile 不带登录态)
|
||||
Long userId = getLoginUserId();
|
||||
boolean flag = fileService.verifyCode(fileId, userId, code);
|
||||
if(!flag){
|
||||
return CommonResult.customize(null, HttpStatus.INTERNAL_SERVER_ERROR.value(), "验证码错误");
|
||||
}
|
||||
|
||||
String token = fileService.generatePreviewToken(fileId, userId);
|
||||
|
||||
String baseUrl = buildPublicBaseUrl(request); // 见下方函数
|
||||
String decryptUrl = baseUrl + "/admin-api/infra/file/preview-decrypt?fileId=" + fileId + "&token=" + token;
|
||||
fileRespVO.setUrl(decryptUrl);
|
||||
}
|
||||
|
||||
return success(fileRespVO);
|
||||
}
|
||||
|
||||
@@ -162,15 +194,32 @@ public class FileController {
|
||||
}
|
||||
@GetMapping("/generate-download-code")
|
||||
@Operation(summary = "获取下载验证码")
|
||||
public CommonResult<FileRespVO> preDownloadEncrypt(@RequestParam("fileId") Long fileId) {
|
||||
public CommonResult<FileRespVO> preDownloadEncrypt(@RequestParam("fileId") Long fileId,
|
||||
@RequestParam(value = "sendType", required = false) String sendType // 可选:SMS / E_OFFICE
|
||||
) {
|
||||
Long userId = getLoginUserId();
|
||||
|
||||
// 解析 sendType(允许为空)
|
||||
VerifyCodeSendType sendTypeEnum = null;
|
||||
if (sendType != null && !sendType.trim().isEmpty()) {
|
||||
try {
|
||||
sendTypeEnum = VerifyCodeSendType.valueOf(sendType.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return CommonResult.error(HttpStatus.BAD_REQUEST.value(),
|
||||
"sendType 参数不合法,可选:SMS / E_OFFICE");
|
||||
}
|
||||
}
|
||||
|
||||
FileDO activeFileById = fileService.getActiveFileById(fileId);
|
||||
if (activeFileById == null) {
|
||||
return CommonResult.error(HttpStatus.NOT_FOUND.value(), "文件不存在");
|
||||
}
|
||||
|
||||
FileRespVO fileRespVO = BeanUtils.toBean(activeFileById, FileRespVO.class);
|
||||
try {
|
||||
fileService.generateFileVerificationCode(fileId, userId);
|
||||
String code = fileService.generateFileVerificationCode(fileId, userId);
|
||||
if(sendTypeEnum != null)
|
||||
fileService.sendVerifyCode(code, sendTypeEnum); // 发送验证码
|
||||
return CommonResult.customize(fileRespVO, HttpStatus.OK.value(), "验证码已生成,请使用验证码下载文件");
|
||||
} catch (ServiceException e) {
|
||||
return CommonResult.customize(fileRespVO, HttpStatus.OK.value(), e.getMessage());
|
||||
@@ -187,4 +236,53 @@ public class FileController {
|
||||
}
|
||||
return CommonResult.customize(null, HttpStatus.OK.value(), "验证码校验通过");
|
||||
}
|
||||
|
||||
@GetMapping("/preview-decrypt")
|
||||
@PermitAll
|
||||
@TenantIgnore
|
||||
@Operation(summary = "加密文件预览解密流(供 kkfile 拉取)")
|
||||
public void previewDecrypt(@RequestParam("fileId") Long fileId,
|
||||
@RequestParam("token") String token,
|
||||
HttpServletResponse response) throws Exception {
|
||||
|
||||
boolean ok = fileService.verifyPreviewToken(fileId, token);
|
||||
if (!ok) {
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
return;
|
||||
}
|
||||
|
||||
FileDO fileDO = fileService.getActiveFileById(fileId);
|
||||
if (fileDO == null) {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
|
||||
// byte[] content = fileService.getDecryptedBytes(fileId);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setContentType(fileDO.getType());
|
||||
|
||||
String filename = java.net.URLEncoder.encode(fileDO.getName(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + filename);
|
||||
|
||||
// cn.hutool.core.io.IoUtil.write(response.getOutputStream(), true, content);
|
||||
fileService.writeDecryptedToStream(fileId, response.getOutputStream());
|
||||
}
|
||||
|
||||
private String buildPublicBaseUrl(HttpServletRequest request) {
|
||||
if (previewBaseUrl != null && !previewBaseUrl.isBlank()) {
|
||||
return previewBaseUrl.endsWith("/")
|
||||
? previewBaseUrl.substring(0, previewBaseUrl.length() - 1)
|
||||
: previewBaseUrl;
|
||||
}
|
||||
// 兜底:从请求推断
|
||||
String scheme = request.getHeader("X-Forwarded-Proto");
|
||||
if (scheme == null) scheme = request.getScheme();
|
||||
|
||||
String host = request.getHeader("X-Forwarded-Host");
|
||||
if (host == null) host = request.getHeader("Host");
|
||||
if (host == null) host = request.getServerName() + ":" + request.getServerPort();
|
||||
|
||||
return scheme + "://" + host;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ import java.util.Date;
|
||||
@Accessors(chain = true)
|
||||
public class FileRespVO {
|
||||
public String getUrl() {
|
||||
// 加密附件不返回 url
|
||||
if (Boolean.TRUE.equals(this.isEncrypted)) {
|
||||
// 不可下载 或 加密附件不返回 url
|
||||
if (Boolean.FALSE.equals(this.downloadable) || Boolean.TRUE.equals(this.isEncrypted)) {
|
||||
return null;
|
||||
}
|
||||
// 如果 url 已经是临时下载地址(如预签名 URL),直接返回
|
||||
@@ -62,8 +62,8 @@ public class FileRespVO {
|
||||
private String previewUrl;
|
||||
|
||||
public String getPreviewUrl() {
|
||||
// 加密附件不返回 previewUrl
|
||||
if (Boolean.TRUE.equals(this.isEncrypted)) {
|
||||
// 不可下载不返回 previewUrl
|
||||
if (Boolean.FALSE.equals(this.downloadable) ) {
|
||||
return null;
|
||||
}
|
||||
// 仅当 url 不为空时生成
|
||||
@@ -75,7 +75,15 @@ public class FileRespVO {
|
||||
if (onlinePreview == null || onlinePreview.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String presignedUrl = this.getUrl();
|
||||
// 添加加密文件预览逻辑
|
||||
String presignedUrl = null;
|
||||
if (Boolean.TRUE.equals(this.isEncrypted)) {
|
||||
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
presignedUrl = url;
|
||||
}
|
||||
}else{
|
||||
presignedUrl = this.getUrl();
|
||||
}
|
||||
if (presignedUrl == null || presignedUrl.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -102,4 +110,6 @@ public class FileRespVO {
|
||||
@Schema(description = "下载次数")
|
||||
private Integer downloadCount;
|
||||
|
||||
@Schema(description = "是否可下载(true是,false否)")
|
||||
private Boolean downloadable;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ public class FileDO extends BaseDO {
|
||||
*/
|
||||
private Integer downloadCount;
|
||||
|
||||
/**
|
||||
* 是否可下载(true是,false否)
|
||||
*/
|
||||
private Boolean downloadable;
|
||||
|
||||
/**
|
||||
* 是否加密
|
||||
* <p>
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件操作 Mapper
|
||||
*
|
||||
@@ -34,7 +36,16 @@ public interface FileMapper extends BaseMapperX<FileDO> {
|
||||
return selectFirstOne(FileDO::getHash, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据configId和path查找文件信息
|
||||
* @param configId
|
||||
* @param path
|
||||
* @return
|
||||
*/
|
||||
default List<FileDO> selectByConfigIdAndPath(Long configId, String path){
|
||||
return selectList(FileDO::getConfigId, configId, FileDO::getPath, path);
|
||||
};
|
||||
|
||||
@Update("UPDATE INFRA_FILE SET DOWNLOAD_COUNT = DOWNLOAD_COUNT + 1 WHERE CONFIG_ID = #{configId} AND PATH = #{path}")
|
||||
int incDownloadCount(@Param("configId") Long configId, @Param("path") String path);
|
||||
@Update("UPDATE INFRA_FILE SET DOWNLOAD_COUNT = DOWNLOAD_COUNT + 1 WHERE ID = #{fileId}")
|
||||
int incDownloadCount(@Param("fileId") Long fileId);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,7 @@ package com.zt.plat.module.infra.dal.redis;
|
||||
public class RedisKeyConstants {
|
||||
public static final String FILE_VERIFICATION_CODE = "infra:file:verification_code:%d:%d";
|
||||
public static final String FILE_VERIFICATION_CODE_USER_SET = "infra:file:verification_code:user:%d";
|
||||
|
||||
// 加密文件预览token
|
||||
public static final String FILE_PREVIEW_TOKEN = "infra:file:preview-token:%s";
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package com.zt.plat.module.infra.framework.rpc.config;
|
||||
|
||||
import com.zt.plat.module.system.api.permission.PermissionApi;
|
||||
import com.zt.plat.module.system.api.permission.RoleApi;
|
||||
import com.zt.plat.module.system.api.sms.SmsSendApi;
|
||||
import com.zt.plat.module.system.api.user.AdminUserApi;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration(value = "infraRpcConfiguration", proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class})
|
||||
@EnableFeignClients(clients = {PermissionApi.class, RoleApi.class, AdminUserApi.class, SmsSendApi.class })
|
||||
public class RpcConfiguration {
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zt.plat.module.infra.service.file;
|
||||
|
||||
import com.zt.plat.framework.common.enums.VerifyCodeSendType;
|
||||
import com.zt.plat.framework.common.pojo.PageResult;
|
||||
import com.zt.plat.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePageReqVO;
|
||||
@@ -10,6 +11,8 @@ import com.zt.plat.module.infra.dal.dataobject.file.FileDO;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* 文件 Service 接口
|
||||
*
|
||||
@@ -72,6 +75,14 @@ public interface FileService {
|
||||
*/
|
||||
String generateFileVerificationCode(Long fileId, Long userId);
|
||||
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* @param code 验证码
|
||||
* @param verifyCodeSendType 发送类型
|
||||
*/
|
||||
void sendVerifyCode(String code, VerifyCodeSendType verifyCodeSendType);
|
||||
|
||||
/**
|
||||
* 校验验证码并返回解密后的文件内容
|
||||
*/
|
||||
@@ -119,4 +130,31 @@ public interface FileService {
|
||||
* @param path
|
||||
*/
|
||||
void incDownloadCount(Long configId, String path);
|
||||
|
||||
/**
|
||||
* 更新文件下载次数
|
||||
* @param fileId
|
||||
*/
|
||||
void incDownloadCount(Long fileId);
|
||||
|
||||
/**
|
||||
* 临时生成文件预览token
|
||||
* @param fileId 文件ID
|
||||
* @param userId 用户ID
|
||||
* @return 临时token
|
||||
*/
|
||||
String generatePreviewToken(Long fileId, Long userId);
|
||||
|
||||
/**
|
||||
* 验证文件预览token
|
||||
* @param fileId 文件ID
|
||||
* @param token 用户ID
|
||||
* @return 临时token
|
||||
*/
|
||||
boolean verifyPreviewToken(Long fileId, String token);
|
||||
|
||||
/**
|
||||
* 校验预览 token 后,将文件内容解密并写入输出流(用于预览)
|
||||
*/
|
||||
void writeDecryptedToStream(Long fileId, OutputStream outputStream) throws Exception;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ package com.zt.plat.module.infra.service.file;
|
||||
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.zt.plat.framework.common.enums.VerifyCodeSendType;
|
||||
import com.zt.plat.framework.common.pojo.PageResult;
|
||||
import com.zt.plat.framework.common.util.object.BeanUtils;
|
||||
import com.zt.plat.framework.security.core.LoginUser;
|
||||
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.zt.plat.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePageReqVO;
|
||||
import com.zt.plat.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
|
||||
@@ -20,6 +25,9 @@ import com.zt.plat.module.infra.framework.file.core.client.FileClient;
|
||||
import com.zt.plat.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
|
||||
import com.zt.plat.module.infra.framework.file.core.utils.FileTypeUtils;
|
||||
import com.zt.plat.module.infra.util.VerificationCodeUtil;
|
||||
import com.zt.plat.module.system.api.permission.RoleApi;
|
||||
import com.zt.plat.module.system.api.sms.SmsSendApi;
|
||||
import com.zt.plat.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.SneakyThrows;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -30,8 +38,9 @@ import org.springframework.stereotype.Service;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.OutputStream;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.*;
|
||||
|
||||
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
|
||||
import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
@@ -50,6 +59,15 @@ public class FileServiceImpl implements FileService {
|
||||
private String aesKey;
|
||||
@Value("${zt.verify-code:}")
|
||||
private String fixedVerifyCode;
|
||||
// 加密文件预览token过期时间
|
||||
@Value("${zt.file.preview-expire-seconds:300}")
|
||||
private Integer previewExpireSeconds;
|
||||
|
||||
@Resource
|
||||
private RoleApi roleApi;
|
||||
|
||||
@Resource
|
||||
private SmsSendApi smsSendApi;
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@@ -62,6 +80,31 @@ public class FileServiceImpl implements FileService {
|
||||
return VerificationCodeUtil.generateCode(codeKey, userSetKey, stringRedisTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendVerifyCode(String code, VerifyCodeSendType verifyCodeSendType) {
|
||||
if(verifyCodeSendType == null) return;
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
Assert.notNull(loginUser,"用户未登录或权限不足!");
|
||||
if(loginUser == null) return;
|
||||
if (VerifyCodeSendType.SMS.equals(verifyCodeSendType)) {
|
||||
Map<String, Object> templateParams = new HashMap<>();
|
||||
templateParams.put("code",code);
|
||||
SmsSendSingleToUserReqDTO smsSendReqDTO = new SmsSendSingleToUserReqDTO();
|
||||
if(loginUser.getInfo().get(LoginUser.INFO_KEY_PHONE)!=null)
|
||||
smsSendReqDTO.setMobile(loginUser.getInfo().get(LoginUser.INFO_KEY_PHONE));
|
||||
smsSendReqDTO.setUserId(loginUser.getId());
|
||||
smsSendReqDTO.setTemplateCode("test_02");
|
||||
smsSendReqDTO.setTemplateParams(templateParams);
|
||||
smsSendApi.sendSingleSmsToAdmin(smsSendReqDTO);
|
||||
return;
|
||||
}
|
||||
|
||||
if (VerifyCodeSendType.E_OFFICE.equals(verifyCodeSendType)) {
|
||||
// TODO 预留实现接口
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] verifyCodeAndGetFile(Long fileId, Long userId, String code) throws Exception {
|
||||
// 开发模式下,验证码直接获取配置进行比对
|
||||
@@ -336,7 +379,46 @@ public class FileServiceImpl implements FileService {
|
||||
|
||||
@Override
|
||||
public void incDownloadCount(Long configId, String path) {
|
||||
fileMapper.incDownloadCount(configId, path);
|
||||
List<FileDO> fileList = fileMapper.selectByConfigIdAndPath(configId, path);
|
||||
if(fileList != null && !fileList.isEmpty())
|
||||
incDownloadCount(fileList.get(0).getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incDownloadCount(Long fileId) {
|
||||
fileMapper.incDownloadCount(fileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePreviewToken(Long fileId, Long userId) {
|
||||
// 你也可以加:validateFileExists(fileId)
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
String key = String.format(RedisKeyConstants.FILE_PREVIEW_TOKEN, token);
|
||||
stringRedisTemplate.opsForValue().set(key, String.valueOf(fileId),
|
||||
java.time.Duration.ofSeconds(previewExpireSeconds));
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyPreviewToken(Long fileId, String token) {
|
||||
String key = String.format(RedisKeyConstants.FILE_PREVIEW_TOKEN, token);
|
||||
String val = stringRedisTemplate.opsForValue().get(key);
|
||||
if (val == null || !val.equals(String.valueOf(fileId))) {
|
||||
return false;
|
||||
}
|
||||
// 可选:单次使用更安全
|
||||
// stringRedisTemplate.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeDecryptedToStream(Long fileId, OutputStream os) throws Exception {
|
||||
FileDO fileDO = getActiveFileById(fileId);
|
||||
if (fileDO == null) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
byte[] decrypted = getDecryptedBytes(fileId);
|
||||
IoUtil.write(os, true, decrypted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.zt.plat.module.system.api.dept;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.common.util.collection.CollectionUtils;
|
||||
import com.zt.plat.framework.common.util.object.BeanUtils;
|
||||
import com.zt.plat.module.system.api.dept.dto.*;
|
||||
import com.zt.plat.module.system.enums.ApiConstants;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -15,6 +16,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@FeignClient(name = ApiConstants.NAME) // TODO ZT:fallbackFactory =
|
||||
@Tag(name = "RPC 服务 - 部门")
|
||||
public interface DeptApi {
|
||||
@@ -86,6 +89,11 @@ public interface DeptApi {
|
||||
@Parameter(name = "userId", description = "用户编号", example = "1", required = true)
|
||||
CommonResult<Set<CompanyDeptInfoRespDTO>> getCompanyDeptInfoListByUserId(@RequestParam("userId") Long userId);
|
||||
|
||||
@GetMapping(PREFIX+"/up-find-company-node")
|
||||
@Operation(summary = "获取公司节点信息", description = "通过部门编号,向上追溯部门信息,直到上级部门是公司,返回追溯到的部门信息列表")
|
||||
@Parameter(name = "deptId", description = "部门编号", required = true, example = "1024")
|
||||
CommonResult<List<DeptRespDTO>> upFindCompanyNode(@RequestParam("deptId") Long deptId);
|
||||
|
||||
// ========== 数据同步专用接口 ==========
|
||||
|
||||
@PostMapping(PREFIX + "/sync")
|
||||
|
||||
@@ -46,18 +46,18 @@ public interface IWorkIntegrationApi {
|
||||
|
||||
@PostMapping(PREFIX + "/hr/subcompany/page")
|
||||
@Operation(summary = "获取 iWork 分部列表")
|
||||
CommonResult<IWorkHrSubcompanyPageRespDTO> listSubcompanies(@RequestBody IWorkOrgPageReqDTO reqDTO);
|
||||
CommonResult<IWorkHrSubcompanyPageRespDTO> listSubcompanies(@RequestBody IWorkSubcompanyQueryReqDTO reqDTO);
|
||||
|
||||
@PostMapping(PREFIX + "/hr/department/page")
|
||||
@Operation(summary = "获取 iWork 部门列表")
|
||||
CommonResult<IWorkHrDepartmentPageRespDTO> listDepartments(@RequestBody IWorkOrgPageReqDTO reqDTO);
|
||||
CommonResult<IWorkHrDepartmentPageRespDTO> listDepartments(@RequestBody IWorkDepartmentQueryReqDTO reqDTO);
|
||||
|
||||
@PostMapping(PREFIX + "/hr/job-title/page")
|
||||
@Operation(summary = "获取 iWork 岗位列表")
|
||||
CommonResult<IWorkHrJobTitlePageRespDTO> listJobTitles(@RequestBody IWorkOrgPageReqDTO reqDTO);
|
||||
CommonResult<IWorkHrJobTitlePageRespDTO> listJobTitles(@RequestBody IWorkJobTitleQueryReqDTO reqDTO);
|
||||
|
||||
@PostMapping(PREFIX + "/hr/user/page")
|
||||
@Operation(summary = "获取 iWork 人员列表")
|
||||
CommonResult<IWorkHrUserPageRespDTO> listUsers(@RequestBody IWorkOrgPageReqDTO reqDTO);
|
||||
CommonResult<IWorkHrUserPageRespDTO> listUsers(@RequestBody IWorkUserQueryReqDTO reqDTO);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.zt.plat.module.system.api.iwork.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
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 IWorkDepartmentQueryReqDTO extends IWorkOrgBaseQueryReqDTO {
|
||||
|
||||
@JsonProperty("departmentcode")
|
||||
@Schema(description = "部门编号")
|
||||
private String departmentCode;
|
||||
|
||||
@JsonProperty("departmentname")
|
||||
@Schema(description = "部门名称")
|
||||
private String departmentName;
|
||||
|
||||
@JsonProperty("subcompanyid1")
|
||||
@Schema(description = "分部 ID")
|
||||
private String subcompanyId1;
|
||||
|
||||
@JsonProperty("created")
|
||||
@Schema(description = "创建时间戳(>=)")
|
||||
private String created;
|
||||
|
||||
@JsonProperty("modified")
|
||||
@Schema(description = "修改时间戳(>=)")
|
||||
private String modified;
|
||||
|
||||
@JsonProperty("canceled")
|
||||
@Schema(description = "封存标志,默认查询非封存数据。1:封存")
|
||||
private String canceled;
|
||||
|
||||
@JsonProperty("custom_data")
|
||||
@Schema(description = "自定义字段列表(逗号分隔)")
|
||||
private String customData;
|
||||
|
||||
@JsonProperty("id")
|
||||
@Schema(description = "OA 部门 ID")
|
||||
private String id;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.zt.plat.module.system.api.iwork.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
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 IWorkJobTitleQueryReqDTO extends IWorkOrgBaseQueryReqDTO {
|
||||
|
||||
@JsonProperty("jobtitlename")
|
||||
@Schema(description = "岗位名称")
|
||||
private String jobTitleName;
|
||||
|
||||
@JsonProperty("created")
|
||||
@Schema(description = "创建时间戳(>=)")
|
||||
private String created;
|
||||
|
||||
@JsonProperty("modified")
|
||||
@Schema(description = "修改时间戳(>=)")
|
||||
private String modified;
|
||||
|
||||
@JsonProperty("id")
|
||||
@Schema(description = "岗位 ID")
|
||||
private String id;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.zt.plat.module.system.api.iwork.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* iWork 人力组织查询基础参数。
|
||||
*/
|
||||
@Data
|
||||
public class IWorkOrgBaseQueryReqDTO {
|
||||
|
||||
@Schema(description = "当前页码", example = "1")
|
||||
private Integer curpage;
|
||||
|
||||
@Schema(description = "每页条数", example = "20")
|
||||
private Integer pagesize;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.zt.plat.module.system.api.iwork.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* iWork 人力组织分页查询通用请求 DTO
|
||||
*/
|
||||
@Data
|
||||
public class IWorkOrgPageReqDTO {
|
||||
|
||||
@Schema(description = "页码", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer pageNo;
|
||||
|
||||
@Schema(description = "每页大小", example = "20", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer pageSize;
|
||||
|
||||
@Schema(description = "关键字过滤")
|
||||
private String keyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.zt.plat.module.system.api.iwork.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
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 IWorkSubcompanyQueryReqDTO extends IWorkOrgBaseQueryReqDTO {
|
||||
|
||||
@JsonProperty("subcompanycode")
|
||||
@Schema(description = "分部编号")
|
||||
private String subcompanyCode;
|
||||
|
||||
@JsonProperty("subcompanyname")
|
||||
@Schema(description = "分部名称")
|
||||
private String subcompanyName;
|
||||
|
||||
@JsonProperty("modified")
|
||||
@Schema(description = "修改时间戳(>=)")
|
||||
private String modified;
|
||||
|
||||
@JsonProperty("canceled")
|
||||
@Schema(description = "封存标志,默认查询非封存数据。1:封存")
|
||||
private String canceled;
|
||||
|
||||
@JsonProperty("custom_data")
|
||||
@Schema(description = "自定义字段列表(逗号分隔)")
|
||||
private String customData;
|
||||
|
||||
@JsonProperty("id")
|
||||
@Schema(description = "OA 分部 ID")
|
||||
private String id;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.zt.plat.module.system.api.iwork.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
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 IWorkUserQueryReqDTO extends IWorkOrgBaseQueryReqDTO {
|
||||
|
||||
@JsonProperty("workcode")
|
||||
@Schema(description = "人员编号")
|
||||
private String workCode;
|
||||
|
||||
@JsonProperty("subcompanyid1")
|
||||
@Schema(description = "分部 ID")
|
||||
private String subcompanyId1;
|
||||
|
||||
@JsonProperty("departmentid")
|
||||
@Schema(description = "部门 ID")
|
||||
private String departmentId;
|
||||
|
||||
@JsonProperty("jobtitleid")
|
||||
@Schema(description = "岗位 ID")
|
||||
private String jobTitleId;
|
||||
|
||||
@JsonProperty("id")
|
||||
@Schema(description = "人员 ID")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("loginid")
|
||||
@Schema(description = "登录名")
|
||||
private String loginId;
|
||||
|
||||
@JsonProperty("created")
|
||||
@Schema(description = "创建时间戳(>=)")
|
||||
private String created;
|
||||
|
||||
@JsonProperty("modified")
|
||||
@Schema(description = "修改时间戳(>=)")
|
||||
private String modified;
|
||||
|
||||
@JsonProperty("base_custom_data")
|
||||
@Schema(description = "基本信息自定义字段列表(逗号分隔)")
|
||||
private String baseCustomData;
|
||||
|
||||
@JsonProperty("person_custom_data")
|
||||
@Schema(description = "个人信息自定义字段列表(逗号分隔)")
|
||||
private String personCustomData;
|
||||
|
||||
@JsonProperty("work_custom_data")
|
||||
@Schema(description = "工作信息自定义字段列表(逗号分隔)")
|
||||
private String workCustomData;
|
||||
}
|
||||
@@ -36,9 +36,9 @@ public class IWorkWorkflowCreateReqDTO extends IWorkBaseReqDTO {
|
||||
|
||||
@Schema(description = "用印材料附件 URL(必填)")
|
||||
private String xyywjUrl;
|
||||
|
||||
@Schema(description = "用印材料附件文件名(必填)")
|
||||
private String xyywjFileName;
|
||||
|
||||
@Schema(description = "业务回调标识(回调分发使用,≤255 字符)")
|
||||
private String bizCallbackKey;
|
||||
|
||||
@Schema(description = "用印事项")
|
||||
private String yysx;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.zt.plat.module.system.api.push;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.module.system.enums.ApiConstants;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/**
|
||||
* 外部系统推送配置 Feign API
|
||||
*
|
||||
* @author ZT Cloud
|
||||
*/
|
||||
@FeignClient(name = ApiConstants.NAME)
|
||||
@Tag(name = "RPC 服务 - 外部系统推送配置")
|
||||
public interface ExternalPushConfigApi {
|
||||
|
||||
String PREFIX = ApiConstants.PREFIX + "/external-push-config";
|
||||
|
||||
/**
|
||||
* 判断是否允许推送到外部系统
|
||||
*
|
||||
* @param companyId 公司编号(可选,为 null 时表示不限制公司)
|
||||
* @param deptId 部门编号(可选,为 null 时只按公司配置判断)
|
||||
* @param businessType 业务类型(可选:PURCHASE/SALE/PRODUCTION,为 null 时表示所有业务类型)
|
||||
* @param externalSystem 外部系统标识(可选:ERP/IWORK/等,为 null 时表示所有外部系统)
|
||||
* @return 是否允许推送(true=允许,false=禁止,默认 true)
|
||||
*/
|
||||
@GetMapping(PREFIX + "/is-push-enabled")
|
||||
@Operation(summary = "判断是否允许推送到外部系统")
|
||||
CommonResult<Boolean> isPushEnabled(
|
||||
@RequestParam(value = "companyId", required = false) @Parameter(description = "公司编号") Long companyId,
|
||||
@RequestParam(value = "deptId", required = false) @Parameter(description = "部门编号") Long deptId,
|
||||
@RequestParam(value = "businessType", required = false) @Parameter(description = "业务类型") String businessType,
|
||||
@RequestParam(value = "externalSystem", required = false) @Parameter(description = "外部系统标识") String externalSystem);
|
||||
}
|
||||
@@ -230,4 +230,11 @@ public interface ErrorCodeConstants {
|
||||
// ========== 门户网站 1-002-033-000 ==========
|
||||
ErrorCode PORTAL_NOT_EXISTS = new ErrorCode(1_002_033_000, "门户不存在");
|
||||
|
||||
// ========== 外部系统推送配置 1_002_034_000 ==========
|
||||
ErrorCode EXTERNAL_PUSH_CONFIG_NOT_EXISTS = new ErrorCode(1_002_034_001, "外部系统推送配置不存在");
|
||||
ErrorCode EXTERNAL_PUSH_CONFIG_EXISTS = new ErrorCode(1_002_034_002, "该配置已存在");
|
||||
ErrorCode EXTERNAL_PUSH_CONFIG_COMPANY_INVALID = new ErrorCode(1_002_034_003, "公司编号必须是公司节点(is_company=1)");
|
||||
ErrorCode EXTERNAL_PUSH_CONFIG_DEPT_INVALID = new ErrorCode(1_002_034_004, "部门编号必须是部门节点(is_company=0)");
|
||||
ErrorCode EXTERNAL_PUSH_CONFIG_BUSINESS_TYPE_INVALID = new ErrorCode(1_002_034_005, "业务类型无效,仅支持 PURCHASE/SALE/PRODUCTION");
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.zt.plat.module.system.enums.permission;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 数据规则条件枚举
|
||||
*
|
||||
* 用于菜单数据规则的条件类型
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum DataRuleConditionEnum {
|
||||
|
||||
EQ("=", "等于"),
|
||||
NE("!=", "不等于"),
|
||||
GT(">", "大于"),
|
||||
GE(">=", "大于等于"),
|
||||
LT("<", "小于"),
|
||||
LE("<=", "小于等于"),
|
||||
IN("IN", "包含"),
|
||||
NOT_IN("NOT_IN", "不包含"),
|
||||
LIKE("LIKE", "模糊匹配"),
|
||||
LEFT_LIKE("LEFT_LIKE", "左模糊"),
|
||||
RIGHT_LIKE("RIGHT_LIKE", "右模糊"),
|
||||
NOT_LIKE("NOT_LIKE", "不匹配"),
|
||||
IS_NULL("IS_NULL", "为空"),
|
||||
IS_NOT_NULL("IS_NOT_NULL", "不为空"),
|
||||
SQL_RULE("SQL_RULE", "自定义SQL");
|
||||
|
||||
/**
|
||||
* 条件符号
|
||||
*/
|
||||
private final String condition;
|
||||
|
||||
/**
|
||||
* 条件描述
|
||||
*/
|
||||
private final String description;
|
||||
|
||||
/**
|
||||
* 根据条件符号查找枚举
|
||||
*
|
||||
* @param condition 条件符号
|
||||
* @return 枚举值
|
||||
*/
|
||||
public static DataRuleConditionEnum findByCondition(String condition) {
|
||||
if (condition == null) {
|
||||
return null;
|
||||
}
|
||||
for (DataRuleConditionEnum value : values()) {
|
||||
if (value.condition.equals(condition)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.zt.plat.module.system.enums.permission;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 数据规则变量枚举
|
||||
*
|
||||
* 用于菜单数据规则的变量替换
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum DataRuleVariableEnum {
|
||||
|
||||
USER_ID("#{userId}", "当前用户ID"),
|
||||
USERNAME("#{username}", "当前用户名"),
|
||||
DEPT_ID("#{deptId}", "当前用户部门ID"),
|
||||
DEPT_IDS("#{deptIds}", "当前用户所有部门ID"),
|
||||
ORG_CODE("#{orgCode}", "当前用户组织编码"),
|
||||
TENANT_ID("#{tenantId}", "当前租户ID"),
|
||||
CURRENT_DATE("#{currentDate}", "当前日期"),
|
||||
CURRENT_TIME("#{currentTime}", "当前时间");
|
||||
|
||||
/**
|
||||
* 变量名
|
||||
*/
|
||||
private final String variable;
|
||||
|
||||
/**
|
||||
* 变量描述
|
||||
*/
|
||||
private final String description;
|
||||
|
||||
/**
|
||||
* 根据变量名查找枚举
|
||||
*
|
||||
* @param variable 变量名
|
||||
* @return 枚举值
|
||||
*/
|
||||
public static DataRuleVariableEnum findByVariable(String variable) {
|
||||
if (variable == null) {
|
||||
return null;
|
||||
}
|
||||
for (DataRuleVariableEnum value : values()) {
|
||||
if (value.variable.equals(variable)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.zt.plat.module.system.enums.push;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务类型枚举
|
||||
*
|
||||
* @author ZT Cloud
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
public enum BusinessTypeEnum {
|
||||
|
||||
PURCHASE(1, "PURCHASE", "采购"),
|
||||
SALE(2, "SALE", "销售"),
|
||||
PRODUCTION(3, "PRODUCTION", "生产");
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
private final Integer type;
|
||||
|
||||
/**
|
||||
* 编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 根据编码获取枚举
|
||||
*/
|
||||
public static BusinessTypeEnum valueOfCode(String code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
for (BusinessTypeEnum value : BusinessTypeEnum.values()) {
|
||||
if (value.getCode().equals(code)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取枚举
|
||||
*/
|
||||
public static BusinessTypeEnum valueOfType(Integer type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
for (BusinessTypeEnum value : BusinessTypeEnum.values()) {
|
||||
if (value.getType().equals(type)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证编码是否有效
|
||||
*/
|
||||
public static boolean isValidCode(String code) {
|
||||
return valueOfCode(code) != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.zt.plat.module.system.mq.iwork;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class IWorkBizCallbackMessage {
|
||||
|
||||
/** 统一回调主题 */
|
||||
public static final String TOPIC = "SYSTEM_IWORK_BIZ_CALLBACK";
|
||||
|
||||
/** requestId 唯一标识 */
|
||||
private String requestId;
|
||||
/** 业务回调标识 */
|
||||
private String bizCallbackKey;
|
||||
/** 回调负载对象(可为 Map) */
|
||||
private Object payload;
|
||||
/** 当前尝试次数,从 0 开始 */
|
||||
private int attempt;
|
||||
/** 最大尝试次数 */
|
||||
private int maxAttempts;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.zt.plat.module.system.mq.iwork;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class IWorkBizCallbackResultMessage {
|
||||
|
||||
/** 统一回调结果主题 */
|
||||
public static final String TOPIC = "SYSTEM_IWORK_BIZ_CALLBACK_RESULT";
|
||||
|
||||
/** requestId 唯一标识 */
|
||||
private String requestId;
|
||||
/** 业务回调标识,对应发送方设置的 tag */
|
||||
private String bizCallbackKey;
|
||||
/** 是否成功 */
|
||||
private boolean success;
|
||||
/** 错误消息 */
|
||||
private String errorMessage;
|
||||
/** 当前尝试次数(业务侧可回传) */
|
||||
private int attempt;
|
||||
/** 最大尝试次数 */
|
||||
private int maxAttempts;
|
||||
/** 回调负载(用于 system 端重试再投递) */
|
||||
private Object payload;
|
||||
}
|
||||
@@ -91,6 +91,10 @@
|
||||
<logger name="com.zt.plat.module.infra.dal.mysql" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</logger>
|
||||
|
||||
<logger name="com.zt.plat.module.system.dal.mysql" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</logger>
|
||||
</springProfile>
|
||||
|
||||
<!-- 其它环境 -->
|
||||
@@ -103,8 +107,4 @@
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<!-- <logger name="com.zt.plat.module.system.dal" level="DEBUG" additivity="false">-->
|
||||
<!-- <appender-ref ref="STDOUT"/>-->
|
||||
<!-- </logger>-->
|
||||
|
||||
</configuration>
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.zt.plat.framework.common.enums.CommonStatusEnum;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.common.pojo.CompanyDeptInfo;
|
||||
import com.zt.plat.framework.common.util.object.BeanUtils;
|
||||
import com.zt.plat.framework.datapermission.core.annotation.CompanyDataPermissionIgnore;
|
||||
import com.zt.plat.framework.datapermission.core.annotation.DeptDataPermissionIgnore;
|
||||
import com.zt.plat.module.system.api.dept.dto.*;
|
||||
import com.zt.plat.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
|
||||
import com.zt.plat.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO;
|
||||
@@ -78,6 +80,8 @@ public class DeptApiImpl implements DeptApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
@CompanyDataPermissionIgnore
|
||||
@DeptDataPermissionIgnore
|
||||
public CommonResult<DeptRespDTO> getDept(Long id) {
|
||||
DeptDO dept = deptService.getDept(id);
|
||||
return success(BeanUtils.toBean(dept, DeptRespDTO.class));
|
||||
@@ -107,6 +111,12 @@ public class DeptApiImpl implements DeptApi {
|
||||
return success(BeanUtils.toBean(companyDeptInfos, CompanyDeptInfoRespDTO.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<List<DeptRespDTO>> upFindCompanyNode(Long deptId) {
|
||||
List<DeptDO> depts = deptService.upFindCompanyNode(deptId);
|
||||
return success(BeanUtils.toBean(depts, DeptRespDTO.class));
|
||||
}
|
||||
|
||||
// ========== 数据同步专用接口 ==========
|
||||
|
||||
@Override
|
||||
|
||||
@@ -77,7 +77,7 @@ public class IWorkIntegrationApiImpl implements IWorkIntegrationApi {
|
||||
// ----------------- 人力组织分页接口 -----------------
|
||||
|
||||
@Override
|
||||
public CommonResult<IWorkHrSubcompanyPageRespDTO> listSubcompanies(IWorkOrgPageReqDTO reqDTO) {
|
||||
public CommonResult<IWorkHrSubcompanyPageRespDTO> listSubcompanies(IWorkSubcompanyQueryReqDTO reqDTO) {
|
||||
IWorkSubcompanyQueryReqVO reqVO = BeanUtils.toBean(reqDTO, IWorkSubcompanyQueryReqVO.class);
|
||||
IWorkHrSubcompanyPageRespVO respVO = orgRestService.listSubcompanies(reqVO);
|
||||
IWorkHrSubcompanyPageRespDTO respDTO = BeanUtils.toBean(respVO, IWorkHrSubcompanyPageRespDTO.class);
|
||||
@@ -85,7 +85,7 @@ public class IWorkIntegrationApiImpl implements IWorkIntegrationApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<IWorkHrDepartmentPageRespDTO> listDepartments(IWorkOrgPageReqDTO reqDTO) {
|
||||
public CommonResult<IWorkHrDepartmentPageRespDTO> listDepartments(IWorkDepartmentQueryReqDTO reqDTO) {
|
||||
IWorkDepartmentQueryReqVO reqVO = BeanUtils.toBean(reqDTO, IWorkDepartmentQueryReqVO.class);
|
||||
IWorkHrDepartmentPageRespVO respVO = orgRestService.listDepartments(reqVO);
|
||||
IWorkHrDepartmentPageRespDTO respDTO = BeanUtils.toBean(respVO, IWorkHrDepartmentPageRespDTO.class);
|
||||
@@ -93,7 +93,7 @@ public class IWorkIntegrationApiImpl implements IWorkIntegrationApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<IWorkHrJobTitlePageRespDTO> listJobTitles(IWorkOrgPageReqDTO reqDTO) {
|
||||
public CommonResult<IWorkHrJobTitlePageRespDTO> listJobTitles(IWorkJobTitleQueryReqDTO reqDTO) {
|
||||
IWorkJobTitleQueryReqVO reqVO = BeanUtils.toBean(reqDTO, IWorkJobTitleQueryReqVO.class);
|
||||
IWorkHrJobTitlePageRespVO respVO = orgRestService.listJobTitles(reqVO);
|
||||
IWorkHrJobTitlePageRespDTO respDTO = BeanUtils.toBean(respVO, IWorkHrJobTitlePageRespDTO.class);
|
||||
@@ -101,7 +101,7 @@ public class IWorkIntegrationApiImpl implements IWorkIntegrationApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<IWorkHrUserPageRespDTO> listUsers(IWorkOrgPageReqDTO reqDTO) {
|
||||
public CommonResult<IWorkHrUserPageRespDTO> listUsers(IWorkUserQueryReqDTO reqDTO) {
|
||||
IWorkUserQueryReqVO reqVO = BeanUtils.toBean(reqDTO, IWorkUserQueryReqVO.class);
|
||||
IWorkHrUserPageRespVO respVO = orgRestService.listUsers(reqVO);
|
||||
IWorkHrUserPageRespDTO respDTO = BeanUtils.toBean(respVO, IWorkHrUserPageRespDTO.class);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.zt.plat.module.system.api.push;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.module.system.service.push.ExternalPushConfigService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 外部系统推送配置 Feign API 实现类
|
||||
*
|
||||
* @author ZT Cloud
|
||||
*/
|
||||
@RestController
|
||||
@Validated
|
||||
public class ExternalPushConfigApiImpl implements ExternalPushConfigApi {
|
||||
|
||||
@Resource
|
||||
private ExternalPushConfigService externalPushConfigService;
|
||||
|
||||
@Override
|
||||
public CommonResult<Boolean> isPushEnabled(Long companyId, Long deptId, String businessType, String externalSystem) {
|
||||
Boolean result = externalPushConfigService.isPushEnabled(companyId, deptId, businessType, externalSystem);
|
||||
return success(result);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import com.zt.plat.module.system.enums.social.SocialTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -22,8 +21,8 @@ public class AuthLoginReqVO extends CaptchaVerificationReqVO {
|
||||
|
||||
@Schema(description = "账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "ztyuanma")
|
||||
@NotEmpty(message = "登录账号不能为空")
|
||||
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
|
||||
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
|
||||
@Length(min = 1, max = 16, message = "账号长度为 1-16 位")
|
||||
// @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.zt.plat.module.system.controller.admin.auth.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -19,7 +18,7 @@ public class AuthTestLoginReqVO {
|
||||
@Schema(description = "账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "ztyuanma")
|
||||
@NotEmpty(message = "登录账号不能为空")
|
||||
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
|
||||
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
|
||||
// @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
|
||||
|
||||
@@ -165,4 +165,11 @@ public class DeptController {
|
||||
return success(BeanUtils.toBean(companyDeptInfos, CompanyDeptInfoRespDTO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/up-find-company-node")
|
||||
@Operation(summary = "获取公司节点信息", description = "通过部门编号,向上追溯部门信息,直到上级部门是公司,返回追溯到的部门信息列表")
|
||||
@Parameter(name = "deptId", description = "部门编号", required = true, example = "1024")
|
||||
public CommonResult<List<DeptRespVO>> upFindCompanyNode(@RequestParam("deptId") Long deptId) {
|
||||
List<DeptDO> list = deptService.upFindCompanyNode(deptId);
|
||||
return success(BeanUtils.toBean(list, DeptRespVO.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.zt.plat.module.system.controller.admin.integration.iwork;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
|
||||
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.*;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkCallbackLogService;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkOrgRestService;
|
||||
import com.zt.plat.module.system.service.integration.iwork.IWorkSyncService;
|
||||
@@ -19,6 +20,8 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import static com.zt.plat.framework.common.pojo.CommonResult.success;
|
||||
/**
|
||||
* 提供统一 iWork 流程能力的管理端接口。
|
||||
@@ -34,6 +37,7 @@ public class IWorkIntegrationController {
|
||||
private final IWorkIntegrationService integrationService;
|
||||
private final IWorkOrgRestService orgRestService;
|
||||
private final IWorkSyncService syncService;
|
||||
private final IWorkCallbackLogService callbackLogService;
|
||||
|
||||
@PostMapping("/auth/register")
|
||||
@Operation(summary = "注册 iWork 凭证,获取服务端公钥与 secret")
|
||||
@@ -87,6 +91,39 @@ public class IWorkIntegrationController {
|
||||
return success(integrationService.voidWorkflow(reqVO));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermission('system:iwork:log:query')")
|
||||
@PostMapping("/log/page")
|
||||
@Operation(summary = "iWork 回调日志分页查询")
|
||||
public CommonResult<com.zt.plat.framework.common.pojo.PageResult<IWorkCallbackLogRespVO>> pageLogs(@Valid @RequestBody IWorkCallbackLogPageReqVO reqVO) {
|
||||
com.zt.plat.framework.common.pojo.PageResult<com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO> page = callbackLogService.page(reqVO);
|
||||
java.util.List<IWorkCallbackLogRespVO> mapped = new java.util.ArrayList<>();
|
||||
for (com.zt.plat.module.system.dal.dataobject.iwork.IWorkSealLogDO log : page.getList()) {
|
||||
IWorkCallbackLogRespVO vo = new IWorkCallbackLogRespVO();
|
||||
vo.setId(log.getId());
|
||||
vo.setRequestId(log.getRequestId());
|
||||
vo.setBusinessCode(log.getBusinessCode());
|
||||
vo.setBizCallbackKey(log.getBizCallbackKey());
|
||||
vo.setStatus(log.getStatus());
|
||||
vo.setRetryCount(log.getRetryCount());
|
||||
vo.setMaxRetry(log.getMaxRetry());
|
||||
vo.setLastErrorMessage(log.getLastErrorMessage());
|
||||
vo.setRawCallback(log.getRawCallback());
|
||||
vo.setLastCallbackTime(log.getLastCallbackTime());
|
||||
vo.setCreateTime(log.getCreateTime());
|
||||
vo.setUpdateTime(log.getUpdateTime());
|
||||
mapped.add(vo);
|
||||
}
|
||||
return success(new com.zt.plat.framework.common.pojo.PageResult<>(mapped, page.getTotal(), page.getSummary()));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermission('system:iwork:log:retry')")
|
||||
@PostMapping("/log/retry")
|
||||
@Operation(summary = "iWork 回调手工重试")
|
||||
public CommonResult<Boolean> retry(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) {
|
||||
callbackLogService.resetAndDispatch(reqVO.getRequestId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
// ----------------- 人力组织接口 -----------------
|
||||
|
||||
@PostMapping("/hr/subcompany/page")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static com.zt.plat.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||
|
||||
@Schema(description = "管理后台 - iWork 用印回调日志分页查询")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
public class IWorkCallbackLogPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "requestId")
|
||||
private String requestId;
|
||||
|
||||
@Schema(description = "业务单号")
|
||||
private String businessCode;
|
||||
|
||||
@Schema(description = "业务回调标识")
|
||||
private String bizCallbackKey;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "创建时间范围")
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime[] createTime;
|
||||
|
||||
@Schema(description = "最后回调时间范围")
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime[] lastCallbackTime;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "iWork 用印回调日志响应 VO")
|
||||
@Data
|
||||
public class IWorkCallbackLogRespVO {
|
||||
|
||||
private Long id;
|
||||
private String requestId;
|
||||
private String businessCode;
|
||||
private String bizCallbackKey;
|
||||
private Integer status;
|
||||
private Integer retryCount;
|
||||
private Integer maxRetry;
|
||||
private String lastErrorMessage;
|
||||
private String rawCallback;
|
||||
private LocalDateTime lastCallbackTime;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -13,12 +14,35 @@ import lombok.ToString;
|
||||
@ToString(callSuper = true)
|
||||
public class IWorkDepartmentQueryReqVO extends IWorkOrgBaseQueryReqVO {
|
||||
|
||||
@Schema(description = "部门编码")
|
||||
@JsonProperty("departmentcode")
|
||||
@Schema(description = "部门编号")
|
||||
private String departmentCode;
|
||||
|
||||
@JsonProperty("departmentname")
|
||||
@Schema(description = "部门名称")
|
||||
private String departmentName;
|
||||
|
||||
@Schema(description = "所属分部ID")
|
||||
private String subcompanyId;
|
||||
@JsonProperty("subcompanyid1")
|
||||
@Schema(description = "分部 ID")
|
||||
private String subcompanyId1;
|
||||
|
||||
@JsonProperty("created")
|
||||
@Schema(description = "创建时间戳(>=)")
|
||||
private String created;
|
||||
|
||||
@JsonProperty("modified")
|
||||
@Schema(description = "修改时间戳(>=)")
|
||||
private String modified;
|
||||
|
||||
@JsonProperty("canceled")
|
||||
@Schema(description = "封存标志,默认查询非封存数据。1:封存")
|
||||
private String canceled;
|
||||
|
||||
@JsonProperty("custom_data")
|
||||
@Schema(description = "自定义字段列表(逗号分隔)")
|
||||
private String customData;
|
||||
|
||||
@JsonProperty("id")
|
||||
@Schema(description = "OA 部门 ID")
|
||||
private String id;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,21 @@ package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(name = "IWorkFileCallbackReqVO", description = "iWork 文件回调请求 VO")
|
||||
@Data
|
||||
public class IWorkFileCallbackReqVO {
|
||||
|
||||
@Schema(description = "iWork requestId,唯一标识", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "requestId 不能为空")
|
||||
private String requestId;
|
||||
|
||||
@Schema(description = "业务回调标识 bizCallbackKey,≤255 字符", example = "seal-flow-callback")
|
||||
@Size(max = 255, message = "bizCallbackKey 长度不能超过 255 字符")
|
||||
private String bizCallbackKey;
|
||||
|
||||
@Schema(description = "文件下载 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://example.com/file.pdf")
|
||||
@NotBlank(message = "文件 URL 不能为空")
|
||||
private String fileUrl;
|
||||
@@ -19,6 +28,9 @@ public class IWorkFileCallbackReqVO {
|
||||
@Schema(description = "文件名称,可选", example = "合同附件.pdf")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "OA 单点下载使用的 ssoToken,可选", example = "6102A7C13F09DD6B1AF06CDA0E479AC8...")
|
||||
private String ssoToken;
|
||||
@Schema(description = "业务回调状态/结果码,可选")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "原始回调文本(可选,若不传则使用 payload 或请求体序列化)")
|
||||
private String rawBody;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user